> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bfl.ml/llms.txt
> Use this file to discover all available pages before exploring further.

# Start a video from images

> Pin a still as the exact opening frame, pin both ends of time, or steer identity with reference images.

export const RecipeMeta = ({authors = [], notebook, raw, note}) => <div className="not-prose recipe-meta" style={{
  margin: "1.4rem 0 2.4rem"
}}>
    <div className="recipe-meta__band" style={{
  display: "flex",
  flexWrap: "wrap",
  alignItems: "center",
  justifyContent: "space-between",
  gap: "0.7rem 1.5rem",
  padding: "0.7rem 0.15rem"
}}>
      <span style={{
  display: "inline-flex",
  flexWrap: "wrap",
  alignItems: "center",
  gap: "0.5rem 1.3rem"
}}>
        {authors.map((a, i) => <a key={i} href={`https://github.com/${a.github}`} target="_blank" rel="noreferrer" className="recipe-meta__author" style={{
  display: "inline-flex",
  alignItems: "center",
  gap: "0.5rem",
  textDecoration: "none"
}}>
            <img src={`https://github.com/${a.github}.png?size=96`} alt={a.name} loading="lazy" style={{
  width: "1.4rem",
  height: "1.4rem",
  borderRadius: "0.35rem",
  objectFit: "cover"
}} />
            <span className="recipe-meta__author-name" style={{
  fontSize: "0.85rem",
  fontWeight: 550
}}>
              {a.name}
            </span>
          </a>)}
      </span>
      <span style={{
  display: "inline-flex",
  flexWrap: "wrap",
  alignItems: "center",
  gap: "0.4rem 1.4rem"
}}>
        {notebook && <a href={notebook} target="_blank" rel="noreferrer" className="recipe-meta__link" style={{
  display: "inline-flex",
  alignItems: "center",
  gap: "0.4rem",
  fontSize: "0.82rem",
  fontWeight: 550,
  textDecoration: "none"
}}>
            <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
              <path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.55v-1.94c-3.2.7-3.87-1.54-3.87-1.54-.52-1.33-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.19 1.76 1.19 1.03 1.75 2.69 1.25 3.35.95.1-.74.4-1.25.72-1.53-2.55-.29-5.23-1.28-5.23-5.68 0-1.26.45-2.28 1.19-3.09-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.18 1.18a11.1 11.1 0 0 1 5.79 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.76.12 3.05.74.81 1.18 1.83 1.18 3.09 0 4.41-2.69 5.38-5.25 5.66.41.36.77 1.05.77 2.13v3.16c0 .3.21.67.8.55A11.5 11.5 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" />
            </svg>
            View on GitHub
          </a>}
        {raw && <a href={raw} className="recipe-meta__link" style={{
  display: "inline-flex",
  alignItems: "center",
  gap: "0.4rem",
  fontSize: "0.82rem",
  fontWeight: 550,
  textDecoration: "none"
}}>
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
              <polyline points="7 10 12 15 17 10" />
              <line x1="12" y1="15" x2="12" y2="3" />
            </svg>
            Download notebook
          </a>}
      </span>
    </div>
    {note && <p className="recipe-meta__note" style={{
  margin: "0.8rem 0 0",
  fontSize: "0.82rem",
  lineHeight: 1.5
}}>
        {note}
      </p>}
  </div>;

export const RecipeClip = ({src, caption, aspectRatio = "1280 / 704"}) => <figure className="not-prose recipe-clip" style={{
  margin: "1.5rem 0 2rem"
}}>
    <video controls playsInline preload="metadata" src={src} style={{
  display: "block",
  width: "100%",
  aspectRatio,
  borderRadius: "0.9rem",
  border: "1px solid rgba(72,106,88,0.18)",
  background: "#0c1512"
}} />
    {caption && <figcaption className="recipe-clip__caption">{caption}</figcaption>}
  </figure>;

<div className="cookbook-recipe" />

<RecipeMeta authors={[{ name: "Stephen Batifol", github: "stephen37" }]} notebook="https://github.com/black-forest-labs/bfl_cookbook/blob/main/video/02_start_from_images.ipynb" raw="https://raw.githubusercontent.com/black-forest-labs/bfl_cookbook/main/video/02_start_from_images.ipynb" />

Images drive a generation in three ways, and the field (plus how many images you put in
it) picks the behavior. In the API's own error text these carry short codes: `i2v`,
`ii2v`, `ir2v`.

* **One `keyframes` entry** (`i2v`): your image is **on screen, pixel for pixel**, as the
  opening frame. The clip animates out of it.
* **Two `keyframes` entries** (`ii2v`): your images are the opening **and closing**
  frames. You pin both ends of time and the model generates the in-between.
* **`reference_images`** (`ir2v`): the images control **who or what appears**. The model
  keeps the subject recognizable in a new scene; the images never show up on screen.

The decision rule: *must this image appear in the video exactly as shot?* Then
`keyframes`. *Do I also know how it must end?* Two keyframes. *Do I just need this
subject in the video?* Then `reference_images`.

This notebook generates its own input images with FLUX.2 on the same API, so it runs end
to end with nothing but a `BFL_API_KEY`. Any image works the same way: a photo, a render,
a frame from another clip.

## 1. Setup

The client from the [quickstart](/cookbook/video_quickstart), plus `b64`: there is no upload
service, so local files travel inline as base64 (public URLs work too). Images can be PNG,
JPEG, or WebP.

<Accordion title="Setup: the API client from the quickstart (run this first)">
  ```python theme={null}
  import base64
  import os, re, subprocess, time, requests
  from PIL import Image
  from IPython.display import Video, display
  from imageio_ffmpeg import get_ffmpeg_exe

  API_KEY = os.environ.get("BFL_API_KEY")
  assert API_KEY, "Set BFL_API_KEY - create one at https://dashboard.bfl.ai"
  BASE = "https://api.bfl.ai/v1"
  HEADERS = {"x-key": API_KEY}
  DONE = {"Ready", "Request Moderated", "Content Moderated", "Error", "Task not found"}
  FFMPEG = get_ffmpeg_exe()
  os.makedirs("outputs", exist_ok=True)

  def generate(payload, path, model="flux-3-preview-high"):
      """The whole client: submit, poll to a terminal status, save the file."""
      task = requests.post(f"{BASE}/{model}", headers=HEADERS, json=payload, timeout=60)
      task.raise_for_status()
      poll = task.json()["polling_url"]
      result = {"status": None}
      while result["status"] not in DONE:
          time.sleep(5)
          result = requests.get(poll, headers=HEADERS, timeout=30).json()
          print(f'  {os.path.basename(path)}: {result["status"]}', flush=True)
      assert result["status"] == "Ready", f'{result["status"]} - Error: resubmit. Moderated: reword.'
      video = requests.get(result["result"]["sample"], timeout=60).content
      open(path, "wb").write(video)          # the result URL expires ~2h after Ready
      return path

  def b64(path):
      """Local files travel inline: no upload service, just a data URL."""
      mime = "video/mp4" if path.endswith(".mp4") else "image/jpeg"
      return f"data:{mime};base64," + base64.b64encode(open(path, "rb").read()).decode()

  def seconds(path):
      """Clip duration, read from ffmpeg's banner."""
      info = subprocess.run([FFMPEG, "-i", path], capture_output=True, text=True).stderr
      h, m, s = re.search(r"Duration: (\d+):(\d+):([\d.]+)", info).groups()
      return int(h) * 3600 + int(m) * 60 + float(s)

  def strip(path, n=5):
      """One image: n frames sampled evenly across the clip."""
      subprocess.run([FFMPEG, "-y", "-i", path, "-frames:v", "1",
                      "-vf", f"fps={n}/{seconds(path)},scale=320:-1,tile={n}x1",
                      "/tmp/_strip.jpg"], capture_output=True, check=True)
      return Image.open("/tmp/_strip.jpg")
  ```
</Accordion>

## 2. Make the input images with FLUX.2

Four stills: one to animate, a matched before/after pair for the morph, and a two-view
character sheet for the reference workflow. FLUX.2 returns in seconds, so generating
inputs is the cheap part of this notebook. The same client works; only the model name and
payload change.

Two input-craft rules, both load-bearing:

* **The morph pair is one generation plus one edit, not two generations.** Still B is
  made *from* still A with FLUX.2's image input, so the pedestal, background, and light
  match exactly and only the subject changes. Generate the two ends independently and the
  video has to morph the room along with the swan.
* **The character sheet shows the subject twice in a single render.** Two separate
  generations of "the same" character drift apart in the details, and a reference is only
  as good as its consistency.

```python theme={null}
def flux2_image(prompt: str, path: str, width: int = 1280, height: int = 720, **extra) -> str:
    return generate({"prompt": prompt, "width": width, "height": height, **extra},
                    path, model="flux-2-pro")

table = flux2_image(
    "A formal dining room photographed head-on: a long dark-wood table set for a dinner "
    "party, white cloth, tall lit candles in brass holders, crystal glasses half full "
    "of red wine, porcelain plates, folded napkins, a low floral centerpiece, warm "
    "chandelier light, deep shadows, photographic, shallow depth of field.",
    "outputs/02_input_table.jpg",
)

swan_a = flux2_image(
    "An ice sculpture of a swan with folded wings, carved from clear blue-tinted ice, "
    "standing on a round black stone pedestal in a dark empty banquet hall, warm "
    "candlelight raking it from the left, photographic, shallow depth of field.",
    "outputs/02_input_swan_a.jpg",
)

swan_b = flux2_image(
    "Keep the same round black stone pedestal, the same dark banquet hall, the same "
    "warm candlelight from the left and the same framing. The swan has fully melted: a "
    "wide pool of water spreads across the pedestal, and one last beak-shaped shard of "
    "clear ice stands upright in the middle of the pool.",
    "outputs/02_input_swan_b.jpg",
    input_image=b64(swan_a),
)

badger_sheet = flux2_image(
    "Character reference sheet: one stop-motion claymation badger naturalist shown "
    "twice on a single plain warm-grey studio background, full-body front view on the "
    "left and full-body side profile on the right, identical design in both views. "
    "Hand-sculpted modeling-clay texture with visible fingerprints and tool marks, "
    "black-and-white striped face, small round brass spectacles, a mustard tweed "
    "waistcoat with a pocket watch chain, stubby clay paws, soft even studio light, "
    "macro, photographic.",
    "outputs/02_input_badger_sheet.jpg",
)
```

## 3. One keyframe (`i2v`): your image is the opening frame

Each entry is `{"image_url": <URL or base64>, "frame_index": <int>}`. `frame_index` is a
position at 24 fps, so frame 0 opens the clip: the still becomes the pixel-exact first
frame and the video moves from there.

**Prompt the motion, not the scene.** The frame already contains the scene; your prompt's
job is what happens next. Re-describing what's in the frame (the lighting, the setting,
the subject's look) invites the model to re-imagine it instead of animating it. Say what
moves, and when several things move, give them an order.

The clip below turns a set dinner table into a physics problem: the room rotates 90
degrees and gravity follows. Two prompt choices carry it. The gravity vector is named
("the right-hand wall becomes the new floor"), and the collapse is sequenced (flames,
then wine, then plates, then chair) so the model animates an order instead of a blur.
Locking the camera to the room is what makes it read as "gravity turned" rather than
"the camera rolled".

```python theme={null}
tilt = generate({
    "prompt": (
        "The video begins exactly on the provided image, the fully set dinner table "
        "holding still for a beat. Then the entire room begins to rotate slowly "
        "clockwise, ninety degrees over the length of the clip, so the right-hand wall "
        "becomes the new floor. Everything on the table obeys the turning gravity at "
        "once: the candle flames swing to stay upright, the wine arcs sideways out of "
        "the glasses, plates and cutlery slide and then tumble and shatter against the "
        "wall, the cloth drags after them, a chair topples last. The camera is locked "
        "rigidly to the room so the world itself appears to tip. Audio: the long creak "
        "of the room turning, wine splashing, the staggered smash of porcelain landing "
        "on the wall, one chair thudding over. No on-screen text."
    ),
    "keyframes": [{"image_url": b64(table), "frame_index": 0}],
    "duration": 10,
}, "outputs/02_table_tilt.mp4")
```

The input still against the clip's decoded frame 0, extracted losslessly: pixel for
pixel, the still is the opening frame.

```python theme={null}
subprocess.run([FFMPEG, "-y", "-i", tilt, "-frames:v", "1", "/tmp/_first.png"],
               capture_output=True, check=True)
```

## 4. Two keyframes (`ii2v`): pin the start and the end

Add a second entry and the clip now ends on your second image: the closing frame sits at
position `duration × 24`, which is why a start-plus-end morph needs an integer `duration`
(with `"auto"` there is no defined closing position). Both stills are on screen, pixel
for pixel, at their pinned frames; the model's job is everything in between.

You've pinned both ends of time, so the prompt's job shrinks to *how* the change happens:
name the stages and their order, and keep the camera still so the transformation is the
only motion. Here the ends are the matched swan pair from the setup cell: same pedestal,
same hall, same light, different swan. The model has to solve ten seconds of melting
between them.

End-frame pinning is newer than frame-0 pinning and the API marks it experimental:
expect more variance between runs than the single-keyframe path, and re-roll a miss.

```python theme={null}
melt = generate({
    "prompt": (
        "The video begins exactly on the first provided image and ends exactly on the "
        "second: a locked-off time-lapse of the ice swan melting. The wings slump "
        "first, then the neck bows and thins, the body sags into itself, meltwater "
        "spreading across the black pedestal, until only one beak-shaped shard stands "
        "upright in the pool. The candlelight and the hall never change. Audio: soft "
        "dripping, slow at first then quickening, a low creak of settling ice, quiet "
        "hall room tone, never silent. No on-screen text."
    ),
    "keyframes": [
        {"image_url": b64(swan_a), "frame_index": 0},
        {"image_url": b64(swan_b), "frame_index": 240},   # 10s x 24fps
    ],
    "duration": 10,
}, "outputs/02_swan_melt.mp4")
```

## 5. `reference_images` (`ir2v`): the subject, not the pixels

1 to 10 images that define identity. The model keeps the subject recognizable and composes a
fresh scene from your prompt; the references never appear on screen. Multiple views of the
subject (our two-view sheet) pin identity down harder than a single angle.

In the prompt, point at the references explicitly ("the claymation badger from the
reference images") and re-list the anchors that must survive (striped face, brass
spectacles, tweed waistcoat), then describe the new scene around them.

The first clip also speaks. Identity comes from a sheet the model has never seen move,
and the mouth is clay: "his clay mouth reshapes on every syllable" is what keeps the
lip-sync reading as sculpted stop-motion instead of a smooth human mouth pasted on. The
line is five words because a 5-second clip fits only a few seconds of speech.

```python theme={null}
badger_refs = [b64(badger_sheet)]

meadow = generate({
    "prompt": (
        "The stop-motion claymation badger from the reference images, same striped "
        "face, same brass spectacles, same mustard tweed waistcoat and clay "
        "fingerprints, stands in a wind-bent meadow at dusk holding a tiny clay "
        "clipboard. He looks into the lens and says, clipped and severe: \"The specimen "
        "you are looking for is behind you.\" His clay mouth reshapes on every "
        "syllable, the modeling marks catching the light as it moves. Handheld camera "
        "eases in to a chest-up framing. Audio: wind through dry grass, the creak of "
        "stiff clay as he turns, a single distant crow. No on-screen text, no "
        "subtitles."
    ),
    "reference_images": badger_refs,
    "duration": 5,
}, "outputs/02_badger_meadow.mp4")
```

Same references, new scene, hard lighting change. The sheet was shot flat and even; this
scene is a single warm lantern on black water at night. Identity has to survive lighting
the references never showed, which is exactly what the multi-view sheet buys. One action
fills the 5 seconds, and the world, light, and sound all come from the prompt:

```python theme={null}
lake = generate({
    "prompt": (
        "The same stop-motion claymation badger from the reference images, identical "
        "striped face, brass spectacles, mustard tweed waistcoat and visible clay "
        "fingerprints, rows a small clay boat across a black lake at night, a single "
        "oil lantern hooked on the bow throwing a warm circle on the water. He pulls "
        "the oars in slow, deliberate strokes, clay ripples spreading behind the boat. "
        "Low tracking shot gliding alongside at water level. Audio: oars dipping and "
        "creaking, water lapping the hull, night insects, the lantern's faint hiss. No "
        "on-screen text."
    ),
    "reference_images": badger_refs,
    "duration": 5,
}, "outputs/02_badger_lake.mp4")
```

The same sheet kept the badger recognizable in both scenes, including one it was never
lit for. For a character, a product, or a mascot across a campaign, make (or shoot) the
sheet once, then write scenes.

## Choosing, and the details that bite

| You want                            | Field                                      | Remember                                                                           |
| ----------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------- |
| The image on screen, exactly        | `keyframes`, one entry at `frame_index: 0` | Prompt the motion, not the scene                                                   |
| The start **and** the end on screen | `keyframes`, two entries                   | Integer `duration`; closing frame at `duration × 24`; experimental, re-roll a miss |
| The subject in a new scene          | `reference_images`                         | 1-10 images, never shown; a multi-view sheet locks identity harder                 |

* One input field per request. `keyframes` and `reference_images` together return a `422`.
* No upload service: pass a public URL or base64. Oversized pixel dimensions are downscaled
  automatically; files over the limit are rejected.
* Every `frame_index` must fit within `duration × 24`, and all must be unique.
* `aspect_ratio: "auto"` picks the output ratio from your prompt and references.

## Where to next

* Work from existing **video** (recast its subjects or its medium, continue it):
  [Recast and continue](/cookbook/video_edit_recast_continue)
* Put a consistent character through a whole multi-shot film:
  [Multi-shot films](/cookbook/video_multishot_films)
