--- title: Video generation | Luma Agents description: Generate videos with Ray 3.2 — text-to-video, image-to-video, multi-keyframe i2v, resolution, duration, looping, and HDR. --- Generate videos from text or from anchor images with `ray-3.2`. This guide covers every parameter for `type: "video"` requests. For editing existing videos, see [Video editing](/guides/videos/editing/index.md). `type: "video"` covers both text-to-video and image-to-video. To edit an existing video, use `type: "video_edit"` — see [Video editing](/guides/videos/editing/index.md). Output settings like `resolution`, `duration`, `hdr`, and the anchor frames live under `video`. ## Basic request — text-to-video A text-to-video request needs `model`, `type`, a `prompt`, and (typically) a resolution and duration under `video`: - [Python](#tab-panel-65) - [TypeScript](#tab-panel-66) - [Go](#tab-panel-67) - [cURL](#tab-panel-68) ``` from luma_agents import Luma client = Luma() generation = client.generations.create( model="ray-3.2", type="video", prompt="A slow dolly shot through a misty greenhouse at sunrise", aspect_ratio="16:9", video={ "resolution": "720p", "duration": "5s", }, ) ``` ``` import Luma from "luma-agents"; const client = new Luma(); const generation = await client.generations.create({ model: "ray-3.2", type: "video", prompt: "A slow dolly shot through a misty greenhouse at sunrise", aspect_ratio: "16:9", video: { resolution: "720p", duration: "5s", }, }); ``` ``` generation, err := client.Generations.New(ctx, lumaagents.GenerationNewParams{ Model: lumaagents.F(lumaagents.ModelRay3_2), Type: lumaagents.F(lumaagents.GenerationNewParamsTypeVideo), Prompt: lumaagents.F("A slow dolly shot through a misty greenhouse at sunrise"), AspectRatio: lumaagents.F(lumaagents.GenerationNewParamsAspectRatio16_9), Video: lumaagents.F(lumaagents.VideoOptionsParam{ Resolution: lumaagents.F(lumaagents.VideoResolution720p), Duration: lumaagents.F(lumaagents.VideoDuration5s), }), }) ``` Terminal window ``` curl -X POST https://agents.lumalabs.ai/v1/generations \ -H "Authorization: Bearer $LUMA_AGENTS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "ray-3.2", "type": "video", "prompt": "A slow dolly shot through a misty greenhouse at sunrise", "aspect_ratio": "16:9", "video": { "resolution": "720p", "duration": "5s" } }' ``` Output is delivered as an MP4 via a presigned URL once the generation completes — same submit-poll-download pattern as image generation. Keep the completed generation’s top-level `id` if you want to edit, extend, or reframe it later with `source.generation_id` or a `generation_id` keyframe. ## Image-to-video with anchor frames Pass an image as `video.start_frame`, `video.end_frame`, or both to seed the generation. Each accepts the same `ImageRef` shape as `image_ref` on image requests — a publicly accessible `url`, or inline base64 `data` with `media_type`. - [Python](#tab-panel-69) - [TypeScript](#tab-panel-70) - [Go](#tab-panel-71) - [cURL](#tab-panel-72) ``` generation = client.generations.create( model="ray-3.2", type="video", prompt="The character turns to face the camera and smiles", aspect_ratio="16:9", video={ "resolution": "720p", "duration": "5s", "start_frame": {"url": "https://example.com/opening-frame.jpg"}, "end_frame": {"url": "https://example.com/closing-frame.jpg"}, }, ) ``` ``` const generation = await client.generations.create({ model: "ray-3.2", type: "video", prompt: "The character turns to face the camera and smiles", aspect_ratio: "16:9", video: { resolution: "720p", duration: "5s", start_frame: { url: "https://example.com/opening-frame.jpg" }, end_frame: { url: "https://example.com/closing-frame.jpg" }, }, }); ``` ``` generation, err := client.Generations.New(ctx, lumaagents.GenerationNewParams{ Model: lumaagents.F(lumaagents.ModelRay3_2), Type: lumaagents.F(lumaagents.GenerationNewParamsTypeVideo), Prompt: lumaagents.F("The character turns to face the camera and smiles"), AspectRatio: lumaagents.F(lumaagents.GenerationNewParamsAspectRatio16_9), Video: lumaagents.F(lumaagents.VideoOptionsParam{ Resolution: lumaagents.F(lumaagents.VideoResolution720p), Duration: lumaagents.F(lumaagents.VideoDuration5s), StartFrame: lumaagents.F(lumaagents.ImageRefParam{ URL: lumaagents.F("https://example.com/opening-frame.jpg"), }), EndFrame: lumaagents.F(lumaagents.ImageRefParam{ URL: lumaagents.F("https://example.com/closing-frame.jpg"), }), }), }) ``` Terminal window ``` curl -X POST https://agents.lumalabs.ai/v1/generations \ -H "Authorization: Bearer $LUMA_AGENTS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "ray-3.2", "type": "video", "prompt": "The character turns to face the camera and smiles", "aspect_ratio": "16:9", "video": { "resolution": "720p", "duration": "5s", "start_frame": {"url": "https://example.com/opening-frame.jpg"}, "end_frame": {"url": "https://example.com/closing-frame.jpg"} } }' ``` Either, both, or neither of `start_frame` / `end_frame` is valid. Omitting both falls back to pure text-to-video. `start_frame` and `end_frame` are not supported with `duration: "10s"`. Use inline base64 `data` only for small media files. For anything larger, pass a hosted `url` instead of inlining. ## Multi-keyframe image-to-video `video.start_frame` / `video.end_frame` only pin the first and last frame. To guide the motion through the clip — pinning images at **arbitrary positions** — use two parallel arrays under `video`: - **`keyframes`** — 1–64 guide-frame images, each taking the same `ImageRef` shape as the anchor frames above (a `url`, inline base64 `data`, or a prior generation’s `generation_id`). - **`keyframe_indexes`** — a parallel list of non-negative, unique **output-frame** positions where each `keyframes[i]` is anchored, in the `duration × 24fps` grid: `5s → 0–120`, `10s → 0–240`. The two arrays must be the same length, and you provide both or neither — one without the other is a `400`. `keyframes` is the multi-anchor generalization of `start_frame`: a single anchor at index `0` (`keyframes: [X], keyframe_indexes: [0]`) is equivalent to `start_frame: X`, so the two surfaces are mutually exclusive — setting both on one request is a `400`. Use one or the other. Unlike the legacy `start_frame` / `end_frame` pair, multi-keyframe i2v routes through the full Ray 3.2 path, so it lifts those anchors’ restrictions: arbitrary frame positions, `duration: "10s"`, and `hdr: true` are all supported. - [Python](#tab-panel-73) - [TypeScript](#tab-panel-74) - [cURL](#tab-panel-75) ``` generation = client.generations.create( model="ray-3.2", type="video", prompt="A hot-air balloon drifts across the valley as the sun rises", aspect_ratio="16:9", video={ "resolution": "720p", "duration": "5s", "keyframes": [ {"url": "https://example.com/launch.jpg"}, {"url": "https://example.com/midflight.jpg"}, {"url": "https://example.com/sunrise.jpg"}, ], "keyframe_indexes": [0, 60, 120], }, ) ``` ``` const generation = await client.generations.create({ model: "ray-3.2", type: "video", prompt: "A hot-air balloon drifts across the valley as the sun rises", aspect_ratio: "16:9", video: { resolution: "720p", duration: "5s", keyframes: [ { url: "https://example.com/launch.jpg" }, { url: "https://example.com/midflight.jpg" }, { url: "https://example.com/sunrise.jpg" }, ], keyframe_indexes: [0, 60, 120], }, }); ``` Terminal window ``` curl -X POST https://agents.lumalabs.ai/v1/generations \ -H "Authorization: Bearer $LUMA_AGENTS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "ray-3.2", "type": "video", "prompt": "A hot-air balloon drifts across the valley as the sun rises", "aspect_ratio": "16:9", "video": { "resolution": "720p", "duration": "5s", "keyframes": [ { "url": "https://example.com/launch.jpg" }, { "url": "https://example.com/midflight.jpg" }, { "url": "https://example.com/sunrise.jpg" } ], "keyframe_indexes": [0, 60, 120] } }' ``` Indexes are in the **output** frame grid (`duration × 24fps`), so the maximum valid index is `120` for a `5s` clip and `240` for `10s`. They must be unique and within range. `keyframes` is mutually exclusive with `start_frame`, `end_frame`, and `loop`. For anchoring guide frames to an existing video’s grid (video-to-video), use `video.edit.keyframes` on `type: "video_edit"` — see [Video editing](/guides/videos/editing#guide-frames/index.md). ## Extending a prior video To continue or prepend a previous video, pass that completed generation’s top-level `id` as exactly one `generation_id` keyframe on a `type: "video"` request: | Shape | Result | | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- | | `video.start_frame: { "generation_id": "" }` | Forward extend — the prior clip becomes the start, and the new generation continues after it | | `video.end_frame: { "generation_id": "" }` | Backward extend — the prior clip becomes the end, and the new generation is prepended before it | ``` { "model": "ray-3.2", "type": "video", "prompt": "continue the camera move into the glowing forest", "video": { "resolution": "720p", "start_frame": { "generation_id": "d290f1ee-6c54-4b01-90e6-d701748f0851" } } } ``` Extend is standard dynamic range only: `video.hdr` and `video.exr_export` are rejected. `video.loop: true` is only supported on forward extend. Interpolation from a prior generation plus another keyframe is not available yet; use a single `generation_id` keyframe for extend. ## Parameters ### `prompt` (required) A text description of the video, 1–6,000 characters. Be specific about subject, motion, camera movement, lighting, and pacing. ### `model` Use `ray-3.2` for video generation. ### `type` `"video"` for generation. For editing an existing video, use `"video_edit"` — see [Video editing](/guides/videos/editing/index.md). For aspect-ratio reframing, use `"video_reframe"` — see [Video reframing](/guides/videos/reframing/index.md). ### `aspect_ratio` Ray 3.2 video accepts these six aspect ratios — a subset of the API-wide [`AspectRatio` enum](/guides/images/generation#aspect_ratio/index.md): | Value | Orientation | | ------ | ------------------- | | `9:16` | Standard portrait | | `3:4` | Portrait | | `1:1` | Square | | `4:3` | Classic landscape | | `16:9` | Standard widescreen | | `21:9` | Cinematic ultrawide | When omitted, the model selects a ratio based on the prompt and anchor frames. ### `video.resolution` Output resolution. Defaults to `720p`. | Value | Notes | | ------- | -------------------------------------------------------------------------- | | `360p` | Draft tier — fast, low-cost previews. SDR only (rejected with `hdr: true`) | | `540p` | Standard definition. Not available with `hdr: true` | | `720p` | HD — default | | `1080p` | Full HD | ### `video.duration` Video duration. Defaults to `5s`. | Value | Notes | | ----- | ------------------------------------------------------------- | | `5s` | Default | | `10s` | Not supported with `hdr: true`, `start_frame`, or `end_frame` | ### `video.loop` Boolean. When `true`, generates a seamlessly looping video. `loop` is **create-only** — valid for `type: "video"` only; rejected on `type: "video_edit"`. It is not supported with `duration: "10s"`, `hdr: true`, or `end_frame`. ### `video.hdr` Boolean. When `true`, generates an HDR-encoded MP4. HDR requires `720p` or `1080p` (not `540p`) and is not supported with `duration: "10s"` or `loop`. HDR generations bill at a higher rate — see [Pricing](/guides/pricing#ray-32--per-video-pricing/index.md). ### `video.exr_export` Boolean. When `true`, exports an EXR file alongside the MP4 for professional colour-grading workflows. Requires `hdr: true`. ### `video.start_frame` / `video.end_frame` Optional anchor `ImageRef`s. `start_frame` is the first frame (or, on `video_edit`, the single guide frame). `end_frame` is the last frame and is valid for `type: "video"` only. Each can also carry a prior generation `id` as `{ "generation_id": "..." }` for the single-keyframe extend flows above. Neither is supported with `duration: "10s"`. ``` { "video": { "start_frame": { "url": "https://example.com/first.jpg" }, "end_frame": { "data": "iVBORw0KGgo...", "media_type": "image/png" } } } ``` ### `video.keyframes` / `video.keyframe_indexes` Optional multi-anchor image-to-video controls — see [Multi-keyframe image-to-video](#multi-keyframe-image-to-video). `keyframes` is a list of 1–64 guide-frame `ImageRef`s; `keyframe_indexes` is a parallel, same-length list of unique output-frame positions (`duration × 24fps`: `5s → 0–120`, `10s → 0–240`). Mutually exclusive with `start_frame`, `end_frame`, and `loop`. ## Validation rules | Rule | Constraint | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `model` | Must be `ray-3.2` for video | | `type` | Must be `"video"` for this flow | | `prompt` | Required, 1–6,000 characters | | `aspect_ratio` | One of `9:16`, `3:4`, `1:1`, `4:3`, `16:9`, `21:9`, or omitted | | `video.resolution` | One of `360p`, `540p`, `720p`, `1080p`, or omitted (defaults `720p`). `360p` (draft) and `540p` are rejected with `hdr: true` | | `video.duration` | One of `5s`, `10s`, or omitted (defaults `5s`). `10s` is rejected with `hdr`, `start_frame`, or `end_frame` | | `video.loop` | Boolean. `type: "video"` only; rejected with `10s`, `hdr`, `end_frame`, or `keyframes` | | `video.hdr` | Boolean. Requires `720p`/`1080p`; rejected with `360p`/`540p`, `10s`, or `loop` | | `video.exr_export` | Boolean. Requires `hdr: true` | | `video.start_frame`, `video.end_frame` | Optional `ImageRef` (`url` or base64 `data`, max 50 MB and 8000 px per side; or a `generation_id`). Rejected with `duration: "10s"`. `end_frame` is `type: "video"` only. Mutually exclusive with `keyframes` | | `video.keyframes` | Optional list of 1–64 guide-frame `ImageRef`s. Provide with `keyframe_indexes` (same length). Mutually exclusive with `start_frame`, `end_frame`, and `loop` | | `video.keyframe_indexes` | Parallel list of non-negative, unique output-frame positions (`5s → 0–120`, `10s → 0–240`); same length as `keyframes` | | `video.edit` | Rejected for `type: "video"` — edit-only | | `source` | Rejected for `type: "video"` — use `source` only for `image_edit`, `video_edit`, or `video_reframe` | | `source.generation_id` | Rejected for `type: "video"` — use `video.start_frame.generation_id` or `video.end_frame.generation_id` for extend | See [Error handling](/guides/error-handling/index.md) for the exact `detail` strings rejected values produce. ## Response A successful submit returns HTTP 201: ``` { "id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "type": "video", "state": "queued", "model": "ray-3.2", "created_at": "2026-05-26T12:00:00Z", "output": [], "failure_reason": null, "failure_code": null } ``` Poll `GET /v1/generations/{id}` until `state` is `completed` or `failed`. On `completed`, each `output` entry carries a presigned `url` to the MP4: ``` { "output": [ { "type": "video", "url": "https://storage.example.com/generations/d290f1ee/output.mp4?X-Amz-Expires=3600&..." } ] } ``` Presigned URLs expire after **1 hour**. Download to your own storage promptly, or re-poll `GET /v1/generations/{id}` to mint a fresh URL. The generation `id` does not expire — keep it if you plan to edit, extend, or reframe the video later. ## Polling Video generations take longer than image generations. Tune your initial wait and deadline accordingly — a 5s/720p generation typically completes in well under two minutes, but 10s/1080p with HDR can run several times longer. - [Python](#tab-panel-76) - [TypeScript](#tab-panel-77) - [Go](#tab-panel-78) - [cURL](#tab-panel-79) ``` import time deadline = time.time() + 600 # 10-minute hard timeout time.sleep(30) # don't bother polling right away while generation.state not in ("completed", "failed"): if time.time() > deadline: raise TimeoutError(f"Generation {generation.id} did not complete in time") generation = client.generations.get(generation.id) time.sleep(5) ``` ``` const deadline = Date.now() + 600_000; // 10-minute hard timeout await new Promise((r) => setTimeout(r, 30_000)); while (generation.state !== "completed" && generation.state !== "failed") { if (Date.now() > deadline) { throw new Error(`Generation ${generation.id} did not complete in time`); } generation = await client.generations.get(generation.id); await new Promise((r) => setTimeout(r, 5_000)); } ``` ``` deadline := time.Now().Add(10 * time.Minute) time.Sleep(30 * time.Second) for generation.State != lumaagents.GenerationStateCompleted && generation.State != lumaagents.GenerationStateFailed { if time.Now().After(deadline) { return fmt.Errorf("generation %s did not complete in time", generation.ID) } generation, err = client.Generations.Get(ctx, generation.ID) if err != nil { return fmt.Errorf("poll: %w", err) } time.Sleep(5 * time.Second) } ``` Terminal window ``` DEADLINE=$(($(date +%s) + 600)) # 10-minute hard timeout sleep 30 # don't bother polling right away while true; do if [ "$(date +%s)" -gt "$DEADLINE" ]; then echo "Generation $ID did not complete in time" >&2 exit 1 fi RESULT=$(curl -s -H "Authorization: Bearer $LUMA_AGENTS_API_KEY" \ "https://agents.lumalabs.ai/v1/generations/$ID") STATE=$(echo "$RESULT" | jq -r '.state') [ "$STATE" = "completed" ] || [ "$STATE" = "failed" ] && break sleep 5 done ``` ## Pricing Ray 3.2 video generation is priced by duration, resolution, and dynamic range. Standard dynamic range has separate 5-second and 10-second totals; HDR and HDR + EXR are 5-second only. See [Pricing — `ray-3.2` per-video pricing](/guides/pricing#ray-32--per-video-pricing/index.md) for the full grid. ## Next steps - [**Video editing**](/guides/videos/editing/index.md) — Edit existing videos with Ray 3.2 - [**Video reframing**](/guides/videos/reframing/index.md) — Reframe an existing video to a new aspect ratio - [**Models**](/guides/model/index.md) — Capability matrix for every model - [**Pricing**](/guides/pricing/index.md) — Per-video pricing for Ray 3.2 - [**Error handling**](/guides/error-handling/index.md) — Every error code with troubleshooting steps - [**API Reference**](/api/index.md) — Complete endpoint specifications