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

# Video quickstart

> Your first FLUX 3 video with synchronized audio: submit, poll, download, and the settings that matter.

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

The shortest path from zero to a generated video, with sound. FLUX 3 generates video and
synchronized audio together in one call: you get an mp4 at 24 fps, 480p or 720p, 5 to 20
seconds long, with dialogue, ambient sound, and effects as part of the output.

Hear what you're about to build - a spoken, lip-synced line, generated in one call.
Sound on:

**You need:** a BFL API key from [dashboard.bfl.ai](https://dashboard.bfl.ai). Set it as an
environment variable before you start:

```bash theme={null}
export BFL_API_KEY="your-key-here"
```

<RecipeClip src="/images/cookbook/showcase/05_lighthouse_keeper.mp4" caption="“Storm’s coming in.” Voice, lip-sync, wind, and surf, one API call." />

## 1. The client

Same auth as every FLUX hosted model: your key in the `x-key` header. Submitting returns
right away with a `polling_url`; poll it every few seconds, and `status` moves through
`Pending` → `Reasoning` → `Generating` → `Ready`. `Reasoning` is the planning stage, and
complex briefs spend longer there. That's normal, not a stall.

The whole client is one function, happy path only. Three things people get wrong,
visible right in it:

* **A task can end in `Request Moderated`, `Content Moderated`, or `Error` instead of
  `Ready`.** None of these will ever become `Ready`, so the loop runs until a terminal
  status, not until `Ready`. (A fifth terminal status, `Task not found`, comes back for an
  unknown or expired id.)
* **`Error` means resubmit; moderated means reword.** A failed generation is worth one fresh
  task. A moderated prompt will be moderated again, so change the prompt, not the retry
  count.
* **The download URL in `result.sample` expires about 2 hours after the result is ready.**
  Results are not long-term storage, so the file is saved the moment it's ready.

```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
```

## 2. Your first clip

`prompt` is the only required field. Your prompt is interpreted and expanded before
generation, so plain language works: describe the scene the way you'd brief a colleague,
including what it should *sound* like, and the system fills in the rest.

Speech is part of that. Put the line in quotation marks and describe a **visible speaker
on camera**, and the character speaks it: voiced, lip-synced, generated in the same call
as the picture. The speaker doesn't need a face. It needs a described mouth: name the part
that articulates and say it moves on each word, and the model syncs to it. Add "no
on-screen text, no subtitles"; a quoted line without a visible speaker tends to render as
burned-in text instead of a voice.

This is a real several-minute wait. Submit it and let it run.

```python theme={null}
clip = generate({
    "prompt": (
        "A single large Venus flytrap stands in a terracotta pot on a workbench in a "
        "humid Victorian greenhouse, condensation on the glass behind it, one fat trap "
        "raised toward the camera. The trap-lobes part like a mouth and it says, in a "
        "slow, honeyed whisper: \"Come closer. I have been so terribly patient.\" The "
        "two lobes and their bristles open and close precisely on each word, a bead of "
        "nectar trembling on the rim. Slow dolly in to a tight close-up on the moving "
        "trap. Audio: dripping condensation, the wet click of the lobes on each "
        "syllable, a lone fly buzzing, distant greenhouse fans. No on-screen text, no "
        "subtitles."
    )
}, "outputs/01_flytrap.mp4")
```

Play it with the sound on. The voice, the wet click of the lobes on each syllable, and
the greenhouse drip were generated with the picture, not dubbed on afterward. A plant has
no mouth, which is the point: the prompt names the part that articulates (the two lobes)
and says it moves on each word, and that is all the model needs to sync to.

## 3. The settings

Everything except `prompt` is optional and defaults to letting the model decide. `auto` means
"you decide for me", not a cap: any field you set explicitly is honored, and the model only
fills in what you leave open.

| Field            | Values                                                            | Default  |
| ---------------- | ----------------------------------------------------------------- | -------- |
| `aspect_ratio`   | `auto`, `21:9`, `16:9`, `4:3`, `1:1`, `3:4`, `9:16`, `9:21`       | `auto`   |
| `resolution`     | `480p`, `720p`                                                    | `720p`   |
| `duration`       | any integer `5`-`20` (seconds) or `"auto"`                        | `auto`   |
| `generate_audio` | bool, `false` for a silent clip                                   | `true`   |
| `grounding`      | bool, allows a short research pass before generation              | `true`   |
| `version`        | `latest` picks up improvements; pin a version for stable behavior | `latest` |
| `webhook_url`    | we POST the finished result there instead of you polling          | unset    |

The schema is strict: a field the API doesn't know, or a value outside these (say
`duration: 30`), returns a `422` at submit time that tells you what it expected.

The next clip pins all three format fields, each for a reason you can reuse. `21:9`
because the subject travels top to bottom through a tall volume and a wide frame keeps
the whole descent and the floor in one shot. `duration: 10` because the sink needs time
to finish and the chord time to land. `720p` to fix the output instead of leaving it to
`auto`. Pin a setting when you can say why; leave it on `auto` when you can't.

```python theme={null}
piano = generate({
    "prompt": (
        "Wide underwater shot of a black lacquered grand piano sinking slowly through "
        "the deep end of an empty swimming pool, tiled walls receding into blue, a "
        "column of silver bubbles streaming from between the keys. It settles upright "
        "on the pool floor, silt lifting around its legs; the keys depress one after "
        "another in a slow, warped chord as the water swallows the sound. Camera drifts "
        "down alongside the falling piano in one continuous move, then holds as it "
        "lands. Audio: the muffled, detuned notes bending underwater, the low groan of "
        "the frame under pressure, a steady hiss of escaping bubbles, no surface sound. "
        "No on-screen text."
    ),
    "aspect_ratio": "21:9",
    "duration": 10,
    "resolution": "720p",
}, "outputs/01_piano.mp4")
```

## 4. Directing beats describing

The fastest way to feel what prompt detail buys: the same concept twice. The first prompt
is what most people type. The second is the same idea directed: the camera named, the
action counted out beat by beat, each sound placed. Everything else about the two requests
is identical.

Counting is the load-bearing trick. "Types by itself" leaves the choreography to chance;
"three keys strike, one after another, then the carriage returns with a bell ding" gives
the model beats to hit, and the synchronized audio gives each beat a sound to land on.

```python theme={null}
described = generate({
    "prompt": "An old typewriter types by itself in an empty office, spooky atmosphere."
}, "outputs/01_typewriter_described.mp4")

directed = generate({
    "prompt": (
        "Close-up of an old black typewriter on a desk in a dark, empty office at "
        "night, lit only by a green banker's lamp. Three keys strike by themselves, "
        "one after another, slow and deliberate. A pause. Then the carriage returns on "
        "its own with a sharp bell ding, and every key fires at once in a frantic "
        "burst. The camera pushes in slowly the whole time. Audio: each key strike as "
        "a dry mechanical clack, the single bright bell, then the clattering burst, "
        "under a low room hum and a faint fluorescent buzz. No on-screen text."
    )
}, "outputs/01_typewriter_directed.mp4")
```

Play both with sound. The described take is a coherent clip of a haunted typewriter,
and it's nobody's. The directed take has choreography: three clacks, a bell, a burst,
each landing on its own sound. The words you add aren't decoration; they're the
direction. The [prompt guide](https://github.com/black-forest-labs/bfl_cookbook/blob/main/video/PROMPTING.md) turns this into a repeatable structure.

## 5. The classic failure, on purpose

The most common first failure with speech: a quoted line with **no visible speaker**.
The model has nowhere to put the voice, so the words tend to render as burned-in text
instead. Run it and see the failure before it costs you a real generation:

```python theme={null}
# The trap: a quoted line, nobody on screen to say it.
bare = generate({
    "prompt": (
        "Wide shot of fog rolling through a pine forest at dawn, pale light between "
        "the trunks. \"Nothing here is asleep. It is all just waiting.\""
    )
}, "outputs/01_speech_trap.mp4")

# The fix: give the line a home. No mouth needed - declare it a voiceover and
# forbid text explicitly.
fixed = generate({
    "prompt": (
        "Wide shot of fog rolling through a pine forest at dawn, pale light between "
        "the trunks, one crow lifting off a branch. A low, calm voiceover says exactly "
        "once: \"Nothing here is asleep. It is all just waiting.\" These are the only "
        "words. Audio: the voiceover, wind through needles, the crow's wingbeats, a "
        "distant creak of wood. No on-screen text, no subtitles, no captions."
    )
}, "outputs/01_speech_fixed.mp4")
```

Same line, two homes. On-screen speakers lip-sync ([the first clip](#2-your-first-clip)
did); off-screen lines need to be declared as voiceover, with text forbidden explicitly.
That one habit - every quoted line gets a visible mouth or a voiceover clause - removes
the most common failure in the API.

## 6. What a request costs you in practice

* **Latency:** several minutes per generation right now. Build around submit-then-poll (or set
  `webhook_url`), never around blocking.
* **Concurrency:** limits apply to concurrent generations, not requests per second. 5 per org
  on the API. Going over returns `429 (too many active tasks)`; that's a signal to wait for a
  running task, not something to retry in a loop.
* **Storage:** download every result within 2 hours. The recipes in this section all save to
  `outputs/` immediately.

## Where to next

* The [prompt guide](https://github.com/black-forest-labs/bfl_cookbook/blob/main/video/PROMPTING.md) covers directing the three audio layers (dialogue,
  ambient, effects), multi-shot structure, and the speech vs on-screen-text rule you saw
  above.
* **Start from an image** you already have, or keep a character consistent across clips:
  [Start from images](/cookbook/video_start_from_images).
* **Edit or extend existing video**: [Edit, recast, continue](/cookbook/video_edit_recast_continue).
* **Make an actual short film**, shots in parallel, stitched:
  [Multi-shot films](/cookbook/video_multishot_films).
