How to use the LTX-2.5 API
Stream LTX-2.5 video with generated audio: join the session over WebRTC, play picture and sound in one element, steer window by window, and release.
The LTX-2.5 API on Windflow runs Lightricks' LTX-2.5 as a realtime session that generates video and its sound together. It works in windows: each window is 241 frames, about ten seconds at 24 fps, with audio generated alongside the picture. While one window plays, the next is being generated, and a new prompt applies from the next window. Because the output has an audio track, LTX-2.5 is delivered over WebRTC only, through the session's LiveKit room.
Start from something runnable
You need an approved Windflow account and an API key that begins with wf_live_ (request access). Requests go to https://api.windflow.online.
1. Check the key (server). The response lists this model's commands, messages and limitations.
curl https://api.windflow.online/v1/models/ltx-2-5 \
-H "Authorization: Bearer $WINDFLOW_API_KEY"
2. Mint a browser token (server). The account key never reaches the browser.
// For example app/api/windflow-token/route.ts. Authenticate your own user first.
export async function POST() {
const res = await fetch("https://api.windflow.online/v1/tokens", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WINDFLOW_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ model: "ltx-2-5", max_sessions: 1 }),
});
return Response.json(await res.json(), { status: res.status });
}
The token is bound to this model (another model gets 403 model_mismatch) and can create one session. Read expires_at from the response instead of assuming a lifetime.
3. Open the session and join the room (browser). This uses the open-source livekit-client package for WebRTC. Save it as windflow-livekit.js:
// windflow-livekit.js: session over fetch, media over LiveKit (npm install livekit-client)
import { Room, RoomEvent, Track } from "livekit-client";
const API = "https://api.windflow.online";
export async function call(path, jwt, body) {
const res = await fetch(API + path, {
method: body ? "POST" : "GET",
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
body: body && JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw Object.assign(new Error(data.error.message), data.error, { status: res.status });
return data;
}
export function openLiveKitSession(model, video, onMessage = () => {}) {
const room = new Room();
const decoder = new TextDecoder();
let session, closed = false, released = false;
const release = () => {
closed = true;
room.disconnect();
if (!session || released) return;
released = true;
fetch(`${API}/v1/sessions/${session.session_id}`, {
method: "POST",
keepalive: true, // still sent while the page unloads
headers: { Authorization: `Bearer ${session.jwt}`, "Content-Type": "application/json" },
body: JSON.stringify({ action: "detach", connection_id: session.connection_id, release: true }),
});
};
// Picture and sound go to the SAME element, so they stay in sync.
room.on(RoomEvent.TrackSubscribed, (track) => {
if (track.kind === Track.Kind.Video || track.kind === Track.Kind.Audio) track.attach(video);
});
room.on(RoomEvent.DataReceived, (payload) => {
try {
onMessage(JSON.parse(decoder.decode(payload)));
} catch {
// not a session message
}
});
room.on(RoomEvent.Disconnected, () => onMessage({ type: "room_disconnected" }));
const ready = (async () => {
const { jwt } = await (await fetch("/api/windflow-token", { method: "POST" })).json();
session = await call("/v1/sessions", jwt, { model, transport: "livekit" });
while (session.status === "queued" && !closed) {
await new Promise((resolve) => setTimeout(resolve, 2000));
const poll = await call(`/v1/sessions/${session.session_id}`, session.jwt);
onMessage({ type: "queued", position: poll.queue_position });
if (poll.status !== "queued") {
session = await call("/v1/sessions", session.jwt, { model, session_id: session.session_id, transport: "livekit" });
}
}
const offer = session.transport.offers.find((o) => o.kind === "livekit");
if (closed || !offer) throw new Error(closed ? "released" : `no room offer (status ${session.status})`);
await room.connect(offer.url, offer.participant_token,
offer.ice_servers ? { rtcConfig: { iceServers: offer.ice_servers } } : undefined);
return session;
})();
ready.catch(release); // a failed start must not leave a session running
return {
ready,
release,
room,
get session() { return session; },
command: (type, fields = {}) =>
call(`/v1/sessions/${session.session_id}/commands`, session.jwt, { command: { type, ...fields } }),
upload: async (file) => {
const intent = await call("/v1/uploads", session.jwt, {
filename: file.name, mime: file.type, size: file.size, session_id: session.session_id,
});
const put = await fetch(intent.upload_url, { method: "PUT", headers: intent.headers, body: file });
if (!put.ok) throw new Error(`upload failed (${put.status})`);
return call(`/v1/uploads/${intent.file_id}/complete`, session.jwt, {});
},
};
}
queued and room_disconnected are messages this file generates; every other message comes from the session over the room's data channel.
4. Start LTX-2.5. The page needs a <video id="out" autoplay muted playsinline> element and a button for sound:
import { openLiveKitSession } from "./windflow-livekit.js";
const video = document.querySelector("#out");
const ltx = openLiveKitSession("ltx-2-5", video, (msg) => console.log(msg.type));
await ltx.ready;
await ltx.command("set_prompt", {
prompt: "A handheld walk through a rainy night market, vendors calling from both sides, water dripping from canvas awnings",
});
await ltx.command("start");
// Browsers only allow sound after a user gesture.
document.querySelector("#sound").addEventListener("click", async () => {
video.muted = false;
await ltx.room.startAudio();
await video.play();
});
Wait until the session is ready
Capacity is brought up on demand, so POST /v1/sessions can answer status: "queued" with a queue_position and no transport offers. Keep polling GET /v1/sessions/{id} and show the position: a queue ticket nobody polls expires with error: "scheduler_queue_expired". When the status leaves queued, POST /v1/sessions with the same session_id returns the room offer without creating a second session.
The platform starts the model in the room when the session is placed, so the browser only joins. Commands go over HTTP to POST /v1/sessions/{id}/commands; the control plane retries the brief moment while the worker is still joining the room.
Steer window by window
| Command | What it does |
|---|---|
set_prompt | Changes what the next window generates; the current window always finishes first |
scene_cut | Starts an independent window: nothing carries over from the previous one |
set_image | Sets the reference still the next window conditions on |
set_conditioning | Prompt and reference still together; both required |
start, pause, resume, reset, stop, set_seed | Run control; pause takes effect after the current window, and each window advances the seed |
Each window is generated as a whole, so a new prompt applies once the running window finishes. Send one prompt per beat; a newer prompt replaces one that is still pending.
By default, each window continues from the tail of the previous one: identity, wardrobe, room and lighting carry across, but the camera steps at the boundary. scene_cut drops that tail. It is visibly a cut, and it is cheaper to generate than a continuation, because conditioning on the previous tail is what costs the extra time.
Open on a reference still
To anchor the opening composition and identity, upload a still and stage it with the prompt before start:
const still = await ltx.upload(document.querySelector("#still").files[0]); // image/*
await ltx.command("set_conditioning", { prompt: "She turns toward the window and smiles as rain starts", image: { id: still.id } });
await ltx.command("start");
The accepted still is published on the video track while the first window generates, so the viewer sees the opening frame instead of an empty box. Later windows continue from generated frames rather than returning to the still.
For how to word scenes, sound and dialogue, see the LTX-2.5 prompting guide.
The first frame costs a whole window
LTX-2.5 delivers whole windows, so the first generated frame arrives when the first window is complete. Give it that time rather than a short client timeout, and use the session's state and messages to follow progress.
| Milestone | Signal | What the viewer should see |
|---|---|---|
| Room joined | room.connect resolves | Loading state |
| Generating | generation_started, after start | The reference still, if you set one, or a loading state |
| First window | Generated frames on the video track | The scene, muted until the sound button |
| Sound on | room.startAudio() after a click | Picture with sound |
Direct the sound
Audio is generated together with the picture, so it follows the scene you describe. Name the sounds you want in the prompt. For content that always needs music, you can mix your own bed under the stream.
Release the session on unmount
Call ltx.release() when the component unmounts and on pagehide. It leaves the room and sends { action: "detach", connection_id, release: true } with keepalive; the control plane stops the run and hands the capacity back, and the response reports session_released: true. Paused time is billed, so release is what stops the meter.
Troubleshooting by the stage where startup stopped
| Stage | Symptom | Cause and fix |
|---|---|---|
| Token | 401 invalid_api_key | Send the wf_live_ key from your server only |
| Create | 403 model_mismatch or token_sessions_exhausted | Token minted for another model, or its max_sessions used up |
| Create | 402 insufficient_balance or 429 rate_limited | Balance below the shortest useful session, or too many new sessions; honour Retry-After |
| Create | You asked for transport: "ws-h264" | LTX-2.5 has an audio track and is delivered over livekit only |
| Queue | queued for a while | Capacity is being brought up; keep polling and show the position |
| Queue | failed with scheduler_queue_expired | Polling stopped while queued |
| Ready | start rejected: "prompt is required" | Send set_prompt or set_conditioning first |
| Running | Nothing for several seconds after start | Expected: the first frame arrives when the first window is done |
| Running | Picture plays, no sound | The element is still muted; unmute and call room.startAudio() from a click |
| Running | A prompt change arrives late | It applies from the next window; the running window always finishes |
| Running | The shot shifts at a window boundary | Repeat continuity cues in adjacent prompts |
Rejected commands return 422 with a command_… code over HTTP and a command_error message on the data channel.
FAQ
Does LTX-2.5 generate audio?
Yes. Each window carries generated audio on the main_audio track, aligned with the picture. Attach it to the same element as the video so the two stay in sync.
Can I change the prompt mid-window?
No. Every command lands at the next window boundary, about every ten seconds. For a model that responds within a chunk, see Helios.
Can I use the WebSocket transport with LTX-2.5?
No. The WebSocket transport carries frames without an audio channel, so LTX-2.5 is offered over livekit only.
