> ## 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.

# Multi-shot films

> Direct cuts inside one generation, run a concurrent shot pipeline, and cut a finished film.

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/04_multishot_films.ipynb" raw="https://raw.githubusercontent.com/black-forest-labs/bfl_cookbook/main/video/04_multishot_films.ipynb" />

A single generation is capped at 20 seconds; a film isn't. This recipe builds length both
ways the API gives you:

1. **Cuts inside one generation**: write the shots into the prompt and the model executes
   real cuts, with one soundtrack binding them.
2. **A shot pipeline**: script a film as a shot list, generate the shots concurrently
   (rate limits are per *concurrent generation*, so a film's shots can run in parallel),
   and cut them together with ffmpeg.

By the end you'll have a four-shot, \~20-second film with a continuous musical idea, built
from a shot list you can rewrite into anything.

## 1. Setup

<Accordion title="Setup: the API client from the quickstart (run this first)">
  ```python theme={null}
  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 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. Cuts inside one generation

Write the shots explicitly and mark the cuts. Two things make in-prompt cuts land:

* **Consecutive shots must differ strongly**, and scale is the strongest kind of
  difference: macro to wide to aerial reads as three deliberate cuts, where three similar
  angles blend into one drifting take.
* **One audio idea across all shots.** Name a continuous bed and the cuts read as editing
  instead of channel-surfing. Here the bed has two layers: a cello drone that never
  breaks, and one substance (fire) whose sound is *scaled* per shot rather than
  restarted.

Keep it to two or three shots per generation. A film's worth of cuts is what the pipeline
in the next section is for.

```python theme={null}
fire_scale = generate({
    "prompt": (
        "SHOT ONE: extreme macro of a single wooden match dragging across a strike "
        "strip and bursting into flame, sulfur sparks flying, the wood blackening. "
        "HARD CUT. SHOT TWO: wide shot on a dark beach at night, that flame is now a "
        "tall bonfire, sparks climbing into the black, driftwood collapsing in the "
        "heat. HARD CUT. SHOT THREE: high aerial at night, the bonfire is one orange "
        "point among a long chain of bonfires burning down an entire dark coastline, "
        "waves faint below. One continuous low cello drone swells across all three "
        "shots without a break, and the sound of fire scales with the picture: a tiny "
        "crackle in shot one, a full roar in shot two, a vast distant wash of many "
        "fires in shot three. No on-screen text."
    ),
    "aspect_ratio": "16:9",
    "duration": 10,
}, "outputs/04_fire_scale.mp4")
```

The same fire, followed from the size of a fingertip to the size of a coastline; the
cuts land where the scale jumps.

## 3. A film as a shot list

Past two or three shots, generate each shot as its own clip and edit. This is how you get
films of any length, and it has real advantages over one long generation: shots run in
parallel, a miss costs one shot instead of the film, and you keep editorial control of
timing.

The craft that holds a multi-generation film together:

* **A world bible.** One paragraph describing the world (place, palette, light, film grade)
  pasted into every shot prompt *word for word*. Consistency across generations comes from
  consistent text, so copy it, don't paraphrase it per shot.
* **An audio motif.** Name the same musical idea in every shot, staged for where the shot
  sits in the film (enters, warmer, builds, resolves). You cut the picture; the motif
  carries the through-line.
* **One beat per shot.** Each shot does one thing. The film is the sequence, not any single
  clip.

The film here: **the night shift in the glasshouse**. A vast Victorian glasshouse after
closing, and the plants are working. Four shots, one night, played straight.

```python theme={null}
WORLD = (
    "The world: a vast Victorian glasshouse after closing, at night. Moonlight and a "
    "few sodium service lamps glow through fogged, dripping glass. Wet leaves, black "
    "soil, brass pipes and old iron staging. Deep green-and-amber palette, soft film "
    "grain, gentle slow-drifting camera, photorealistic. No on-screen text. "
)

MOTIF = "a slow, sparse celeste melody with a glass-bell tone"

shots = {
    "s1_orchid": {
        "prompt": WORLD + (
            "Wide establishing shot down the dark central aisle of the glasshouse, "
            "mist hanging between the benches, one slow drip falling. A single pale "
            "orchid on the nearest bench slowly turns its face to follow a shaft of "
            f"moonlight. Audio: {MOTIF} enters quietly, under dripping water, creaking "
            "glass, and a faint night wind outside."
        ),
        "aspect_ratio": "16:9", "duration": 5,
    },
    "s2_mimosa": {
        "prompt": WORLD + (
            "Macro shot on a sensitive mimosa plant. A dandelion seed drifts down and "
            "lands on one leaf, and the leaflets fold closed one after another along "
            "the stem, a slow chain reaction in the lamplight. Audio: "
            f"{MOTIF} continues, warmer and a little fuller, over a soft ripple of "
            "tiny leaf-folds and the hum of a service lamp."
        ),
        "aspect_ratio": "16:9", "duration": 5,
    },
    "s3_sunflowers": {
        "prompt": WORLD + (
            "Medium tracking shot along a row of tall potted sunflowers. As a security "
            "lamp's beam sweeps past outside the glass, every sunflower head rotates "
            "in unison to follow it, then holds. Audio: "
            f"{MOTIF} builds, another voice joining it, over the creak of many stems "
            "turning together and the low buzz of the lamp."
        ),
        "aspect_ratio": "16:9", "duration": 5,
    },
    "s4_roof": {
        "prompt": WORLD + (
            "Slow overhead shot looking down through the misted roof glass at the "
            "whole glasshouse. One by one the plants settle still as the first grey "
            "daylight seeps into the panes. Audio: "
            f"{MOTIF} resolves and fades to a single held note under the returning "
            "dawn birdsong through the glass."
        ),
        "aspect_ratio": "16:9", "duration": 5,
    },
}
```

## 4. Generate the shots concurrently

Rate limits are on **concurrent generations** (5 per org on the API), not requests per
second. Going over returns `429 (too many active tasks)`: a signal to wait for a slot,
never something to retry in a loop.

So the pattern for a film is a thread pool. `run_shots` is the whole thing: three
workers keeps two tasks of headroom under the cap. If a shot fails or gets moderated,
the cell stops with that shot's name in the traceback; finished shots are already on
disk, so reword the miss and re-run the cell - only the missing shots cost anything.

```python theme={null}
from concurrent.futures import ThreadPoolExecutor

def run_shots(shots, workers=3):
    """Generate a dict of {name: payload} concurrently; returns {name: path}."""
    with ThreadPoolExecutor(workers) as pool:
        futures = {name: pool.submit(generate, payload, f"outputs/04_{name}.mp4")
                   for name, payload in shots.items()}
        return {name: f.result() for name, f in futures.items()}
```

### First job for the pool: one line, three languages

Before the film, a controlled experiment: three payloads, identical except for one
templated block, run through the same pool. Hold everything else fixed and any
difference in the output traces to the one lever you moved.

The lever here is the spoken language, and it rides on two prompt rules that make
dialogue reliable anywhere:

* **The exactly-once direction.** "Speaks on camera exactly once, the complete sentence,
  no repetition and no other words in the entire shot." Without it, lines repeat or pick
  up filler.
* **The language lock.** "Every word in French, no English words at all." Without it,
  the model tends to translate the line back to English and speak that.

Because the three payloads are independent, they fill the pool at once: rate limits are
per active generation (5 per org), so three in flight is one full, legal batch.

```python theme={null}
LINE = (
    "A carved wooden cuckoo bird snaps out of the little door of an antique cuckoo "
    "clock on a workshop wall, sawdust in the air, dozens of other clocks ticking "
    "around it. It speaks on camera exactly once, its carved beak articulating each "
    "word, the complete sentence with no repetition and no other words in the entire "
    "shot: {spoken} {lock} The camera pushes in slowly on the little door. Audio: its "
    "spoken line, the massed ticking of the workshop clocks, one chain rattle as it "
    "retracts. No on-screen text, no subtitles."
)

takes = {
    f"cuckoo_{name}": {"prompt": LINE.format(spoken=spoken, lock=lock),
           "aspect_ratio": "16:9", "duration": 6}
    for name, spoken, lock in [
        ("fr", "« Le temps, c'est moi qui le dis. »",
         "It speaks entirely in French, every word in French, native accent and "
         "cadence, no English words at all."),
        ("ja", "「時間なら、私が決めます。」",
         "It speaks entirely in Japanese, every word in Japanese, native accent and "
         "cadence, no English words at all."),
        ("ar", "«الوقت أنا من يعلنه.»",
         "It speaks entirely in Arabic, every word in Arabic, native accent and "
         "cadence, no English words at all."),
    ]
}

cuckoos = run_shots(takes)
```

The same carved beak lip-syncs a line it was drawn saying in three languages, voiced and
synced in the single generation that draws the picture. Sound on: the words come back
transcribable in each language, so the model is speaking the line, not captioning it.

### Now the film

Same pool, real workload: the four shots of the shot list, with the retry policy from the
quickstart. An `Error` is worth one resubmit; a moderated result means reword, never
resubmit unchanged.

```python theme={null}
films = run_shots(shots)
```

## 5. The edit

Straight cuts in script order, one re-encode for uniform codec parameters. The dissolve-free
cut is the right default: the shots were written to contrast, and the motif carries the
continuity.

```python theme={null}
order = ["s1_orchid", "s2_mimosa", "s3_sunflowers", "s4_roof"]
open("/tmp/_join.txt", "w").write(
    "".join(f"file '{os.path.abspath(films[n])}'\n" for n in order))
subprocess.run([FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", "/tmp/_join.txt",
                "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac",
                "outputs/04_glasshouse_film.mp4"], capture_output=True, check=True)
```

## Scaling it up

Everything past four shots is the same loop with a longer shot list.

* **Length:** a 12-shot film is one dict and the same `run_shots` call. Duration 5 suits
  action and single beats; 10 to 20 suits scenes that breathe or carry dialogue.
* **A recurring character:** hold identity with `reference_images` on every shot the
  character appears in. Build the reference sheet once, as in
  [Start from images](/cookbook/video_start_from_images); a world bible holds the world
  together, references hold a face.
* **A shot that ended too soon:** extend it with `start_video`
  ([Edit, recast, continue](/cookbook/video_edit_recast_continue)) instead of re-rolling.
* **Webhooks over polling:** for long shot lists, set `webhook_url` on each request and let
  the results come to you.

The whole workflow (write, generate in parallel, gate, cut) is also the shape an agent can
drive unattended. That's the [agent skill](https://github.com/black-forest-labs/bfl_cookbook/blob/main/video/agent-skill/) recipe.
