---
name: looping-video
description: Turn one approved still into a seamless 6–8 second loop with Kling 3 Pro on fal's queue API by using the same image as start and end frame, writing a motion prompt in small beats, avoiding camera moves, extra people and text, then compressing with ffmpeg and embedding it as a muted, inline, offscreen-paused video. Use it for avatars, headers and ambient clips.
license: MIT
metadata:
  title: "Looping video from a still"
  category: create
  summary: "Same frame in and out on Kling 3 Pro, a beat-based motion prompt, a small web file that loops without a seam."
  level: advanced
  time: "45 min"
  version: "1.0.0"
  updated: "2026-10-12"
---
# Looping video from a still

A short loop makes a character feel present: she blinks, glances at a monitor, the rain moves, and the clip restarts without a visible cut. The trick is to give the video model the same image as both the first and the last frame, so it must come back to where it started. This skill covers the generation call, the motion prompt, and the web delivery that keeps the file small.

## When to use this

- You want a living avatar or header for an agent profile or your own site.
- You have an approved still and want motion without changing the face.
- Your existing loops have a visible jump, drift in color, or weigh several megabytes.

## What you need

- One approved still, publicly reachable by URL ([character-consistency](../character-consistency/SKILL.md)).
- A fal.ai key in `FAL_KEY`.
- Node 18 or newer, and `ffmpeg` installed locally.
- Model: `fal-ai/kling-video/v3/pro/image-to-video`.

## Steps

### 1. Prepare the still

- Use the exact image you will show as the poster. Same crop, same color.
- Prefer a calm pose with hands visible or fully out of frame; half-visible hands invent fingers.
- No legible text in the image. Video models will animate fake letters into worse fake letters.
- Background color should already match where the clip will sit; the model will not fix it.

### 2. Write the motion prompt in beats

Describe two or three small actions, in order, and the return. Lock the camera.

```text
Locked-off static camera, no camera motion.
Beat 1: she blinks slowly and her eyes move from the left monitor to the center screen.
Beat 2: a slight nod, the strand of hair behind her ear stays in place, the mint glow on her glasses flickers as logs scroll.
Background: steady rain on the window, distant neon softly pulsing.
She returns to the starting pose and gaze at the end.
Keep the exact same face, hair, glasses and jacket. No extra people. No text or legible signage anywhere.
```

Good beats: blinks, small head turns, breathing, a hand moving a few centimeters on a desk, light changes, rain, screen flicker, hair moving slightly in a draft. Bad beats: walking, standing up, turning around, anything that ends far from the start pose.

### 3. Submit to the queue, poll, fetch the result

Video takes longer than a normal request, so use the queue: submit, poll status, then get the result. Use the `status_url` and `response_url` returned by the submit call rather than building them yourself.

```js
// loop.mjs — run with: FAL_KEY=... node loop.mjs
const MODEL = "fal-ai/kling-video/v3/pro/image-to-video";
const STILL = "https://your-host.example/margot-desk.png";
const headers = {
  Authorization: `Key ${process.env.FAL_KEY}`,
  "Content-Type": "application/json",
};

const input = {
  prompt: "Locked-off static camera, no camera motion. ... She returns to the starting pose. No text or legible signage anywhere.",
  start_image_url: STILL,
  end_image_url: STILL, // same frame in and out = seamless loop
  // Check the model page for the exact names and allowed values of the
  // duration and audio fields; the team uses 6–8 s with audio off.
};

const submit = await fetch(`https://queue.fal.run/${MODEL}`, {
  method: "POST",
  headers,
  body: JSON.stringify(input),
});
if (!submit.ok) throw new Error(`submit ${submit.status}: ${await submit.text()}`);
const { request_id, status_url, response_url } = await submit.json();
console.log("queued", request_id);

const wait = (ms) => new Promise((r) => setTimeout(r, ms));
for (;;) {
  const s = await (await fetch(status_url, { headers })).json();
  if (s.status === "COMPLETED") break;
  if (s.status !== "IN_QUEUE" && s.status !== "IN_PROGRESS") {
    throw new Error(`unexpected status: ${JSON.stringify(s)}`);
  }
  await wait(5000);
}

const result = await (await fetch(response_url, { headers })).json();
console.log(result.video?.url);
```

Log the request id, prompt, still URL and date in your provenance log ([ai-disclosure](../ai-disclosure/SKILL.md)).

### 4. Review the raw clip

Watch it on repeat for at least five loops:

- Does the last frame land on the first? A small pop is acceptable only if invisible at playback size.
- Face drift at the middle of the clip (the farthest point from both reference frames).
- Duplicated props (a second pair of headphones appearing), new people in the background, letters on screens.
- Color or brightness shift between start and middle.

If one beat causes trouble, remove it and regenerate. Fewer, smaller beats loop better.

### 5. Compress for the web

The team delivers 720 px H.264, CRF 26–28, no audio, with `+faststart`. Loops on the Margot site land roughly between 60 and 650 KB.

```bash
ffmpeg -i raw.mp4 -vf "scale=720:-2" -c:v libx264 -preset slow -crf 27 \
  -pix_fmt yuv420p -an -movflags +faststart loop-720.mp4
```

- `scale=720:-2` sets width to 720 px and keeps an even height. For vertical clips use `scale=-2:720` if you want 720 px height instead, or keep width at 720.
- Raise CRF toward 28 if the file is heavy; lower toward 26 if the face gets blocky.
- `-an` drops audio; `-movflags +faststart` lets playback start before the file is fully downloaded.

### 6. Export a poster frame

Use the first frame so the poster and the start of the loop are identical:

```bash
ffmpeg -i loop-720.mp4 -frames:v 1 -q:v 2 poster.png
```

Convert to AVIF or WebP with your usual tool. Or simply use the original still, resized to the same dimensions.

### 7. Embed it properly

```html
<video
  src="/media/loop-720.mp4"
  poster="/media/poster.webp"
  autoplay muted loop playsinline preload="metadata"
  width="720" height="720"
  aria-label="AI-generated loop of Margot at her trading desk">
</video>
```

`muted` and `playsinline` are required for autoplay on mobile. Set width and height to avoid layout shift. Pause it when it leaves the screen, and respect reduced motion:

```js
const v = document.querySelector("video");
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
  v.removeAttribute("autoplay");
  v.pause();
}
new IntersectionObserver(([e]) => {
  if (reduce) return;
  e.isIntersecting ? v.play().catch(() => {}) : v.pause();
}).observe(v);
```

## Checks before you publish

- [ ] Start and end frames are the same approved still.
- [ ] Prompt says locked-off static camera and return to the starting pose.
- [ ] No camera moves, no extra people, no legible text in any frame.
- [ ] Duration 6–8 s, audio off.
- [ ] Compressed to 720 px H.264, no audio, `+faststart`; file size checked.
- [ ] Poster matches the first frame; background color matches the page.
- [ ] Autoplay muted playsinline, paused offscreen, reduced motion respected.
- [ ] Labelled AI-generated and logged with provenance.

## Common mistakes

- Asking for a dolly-in or orbit. The end frame cannot match, so the loop jumps.
- Using a different end image "for variety". That is a transition, not a loop.
- Long, busy prompts with five actions. The model rushes and the face drifts.
- Shipping the raw model output (often several MB) straight to the page.
- A poster from a different render, so the clip "snaps" when it starts.
- Forgetting `muted`; the browser refuses to autoplay.

## Related skills

- [../character-consistency/SKILL.md](../character-consistency/SKILL.md)
- [../scene-and-portrait-prompts/SKILL.md](../scene-and-portrait-prompts/SKILL.md)
- [../ai-disclosure/SKILL.md](../ai-disclosure/SKILL.md)
