Skip to main content
A guided tour of the open-source LTX reference app, which demonstrates every important pattern in the LTX SDK: setting up a take from a photo and a script, driving the whole UI from the model’s state_update snapshot, and chaining takes into one continuous performance. Going further collects the sharper edges: the upload race, the latency measurement, and stall detection.

Installation and setup

Get the example running first; every section below points back at code in the repo. You will need:
  • Node.js 18.18+ (the example runs Next.js 15 and React 19).
  • pnpm (npm or yarn work but regenerate the lockfile).
  • A Reactor API key (starts with rk_).
  • Familiarity with the Next.js App Router.
1

Clone the example

2

Add your API key

The example reads your rk_… key server-side and mints a short-lived JWT for the client; the key itself never reaches the browser. Drop it into .env.local:
3

Install dependencies and start the dev server

Open http://localhost:3000 and press Connect. The app does not connect on page load: a session holds a whole GPU, and the click doubles as the user gesture browsers require before video may play with sound.

How LTX works

The model takes a still image and a script, generates the voice and the lip-synced video together, and streams both back over WebRTC, window by window, while you watch. There is no inbound media track: the face is a file upload, the speech is generated from text, and the whole take is shaped by six conditions you can see and change. Reactor provisions a GPU for your session, so the client moves through four states before commands take effect:
Connection lifecycle: disconnected → connecting → waiting → ready
At ready the model is connected but idle; it produces no frames until an avatar image and a script are set and you call start.
A session holds a whole GPU and bills for every second it is open, including idle time between takes. This example leaves disconnect manual so the lifecycle stays visible; a surface meant to be left open should end idle sessions itself and reconnect when the user generates again.

What you can change once a take is running

Nothing about the take: it is generated from the six conditions as they stood at start. The session is a different matter. All six setters stay valid during a take; the model takes the change, applies it to the next take, and reports the queue back in state_update.queued_changes. The app never tracks pending edits itself, which is why the take panel has no Apply button. Two more things to hold onto:
  • Commands are asynchronous; messages are the source of truth. A command method resolves when the command is on the wire, not when the model has acted on it. The model confirms with its own event (script_accepted, avatar_image_accepted, …) and a fresh state_update.
  • Errors arrive out-of-band. A broken precondition surfaces later as a command_error message, not as a thrown exception.

Authentication and the provider

Your rk_… key stays on the server. One route, app/api/reactor/token/route.ts, exchanges it for a short-lived JWT scoped to LTX sessions and sets a Cache-Control header, so repeat calls come from the browser’s HTTP cache until the JWT expires. See Authentication for the token endpoint, scoping, and lifetimes. On the client, a getJwt resolver is handed to the typed provider. The SDK calls it on every Reactor API hop:
app/Ltx2App.tsx
Pass getJwt as a plain function. The provider stabilizes it through a ref, so a parent re-render does not tear the session down. <Ltx2Provider> wraps the base SDK’s provider with the model name and its two media tracks baked in. Below it, useLtx2() exposes the connection (status, connect, disconnect), uploadFile, and one typed method per command (setScript, setWpm, start, …); one hook per message (useLtx2StateUpdate, useLtx2CommandError, …) replaces a hand-rolled message switch.

The stage: one element, both tracks

The model publishes two tracks, main_video and main_audio, generated in lockstep on one sample clock. Stage.tsx combines them into a single MediaStream played by a single <video> element; played from separate elements, they drift apart. (The generated <Ltx2MainVideoView> carries the video track only, so an app that wants sound owns its own element like this one.)
app/components/Stage.tsx
The play() rejection handling matters for this model: the track arrives seconds after the Connect click, after the browser’s transient activation has lapsed, so playing with sound can be refused and a second gesture is required. Priming the element during the click does not help; it has no source to play yet. The example shows a small “Enable audio” chip, and only when play() rejected. One more consequence of WebRTC: the track stays live between takes. The last frame of a finished take stays composited on the element, so reset appears to do nothing to the stage. The example keeps one flag, stageHasTake, flipped on by generation_started and off by generation_reset or disconnect, and covers the frame while it is false. Both inputs are discrete model events, so the flag cannot get stuck.
finished is the nearest snapshot field and it is not the same question: changing a condition clears finished too, so keying the stage off it would blank a completed take the moment the user edits the script for the next one.

Setting up a take

Both the avatar image and the script are required before start; scene prompt, pace, seed, and duration have defaults. TakePanel.tsx collects all six conditions.

Crop before upload

The model fits whatever you upload to its 640×352 generation canvas, a wide frame. That fit takes the top of the head off an ordinary portrait photo, and the avatar image defines the face for the entire take. CropModal.tsx therefore sits between the file picker and the upload: it offers the largest 640:352 region that fits, defaults the framing from the browser’s FaceDetector where available (top-center otherwise), lets the user drag, and uploads only those pixels. Letterboxing is the other valid answer: the reactor.inc sandbox scales the whole image onto the canvas and fills the margins with black. Both work because both hand the model an image already in its aspect; what fails is letting the server’s fit decide. Pick one and route every face-supplying path through it.

Wait for the image before you start

For set_script, “resolves when on the wire” is invisible. For set_avatar_image it is a real defect: the model has to fetch and decode the upload, and a start racing in behind it generates the take with the previous face. So the app’s setAvatarImage() waits for avatar_image_accepted, or a command_error for the same command, and raises an imagePending flag that holds both Start and Upload for the length of the wait. It raises the flag itself, in a try/finally, so an upload path you add inherits the hold for free. Going further names the two traps in the waiter.

Everything else commits straight to the wire

The other five conditions need no such care. Each blur, idle pause, or slider release commits a real set_* command, idle or mid-take; the model queues mid-take changes itself. The switch below is exhaustive over the edit union, so every branch hands an already-narrowed value to its typed method.
app/Ltx2App.tsx
Three details in the panel are worth lifting into your own UI:
  • The pace slider reads its bounds off the snapshot (wpm_min / wpm_max); the range is deployment-configured. It commits on release rather than on every drag step, which would put one command per pixel on the wire.
  • Free text commits after a 600 ms idle, not on blur alone. Blur alone deadlocks the script field: a disabled Start button swallows the mousedown that would have blurred the textarea, so the script never commits and ready never turns true.
  • An emptied field is an abandoned edit, not a value. A blank script is the one thing set_script refuses, and 0 is a meaningful duration_seconds (“derive the length from the script”). The panel keeps a per-field dirty set so a snapshot cannot clobber half-typed input, and every edit ends by committing or reverting.

Presets are macros

Each preset row fires the real command sequence, in order, with nothing hidden:
Click a preset, then reproduce the same take by hand from the take panel. Seeds are pinned so takes reproduce, and set_duration_seconds: 0 clears a duration a previous take may have pinned, since presets derive their length from the script. The three presets are the public demo’s cast: each script, voice prompt, pace and seed is the production record. They bracket the useful part of the pace range, the knob whose effect is most audible. Every preset prompt also ships inside the same camera lock, because left loose the model drifts the camera over a take with a slow push in or a reframe:
Locked-off tripod shot, fixed framing from the first frame to the last. (the scene and voice description) The camera never moves, never pans, never zooms, and never pushes in; the framing at the end of the video is identical to the framing at the start.
Stating it twice, before and after the scene, held in production. Wrap generated or user-supplied prompts the same way unless camera motion is the point. See the prompt guide for what else belongs in a scene prompt: it is where the voice is cast, not just the shot.

Driving UI from the snapshot

The model broadcasts a full state_update snapshot on connect and after every observable change, and the whole UI renders from a reduction of that snapshot. The app never infers session state from its own button clicks.
app/Ltx2App.tsx
reduce() (app/lib/state.ts) projects the snapshot into a UI state object, returning the previous object when nothing changed so React can bail out of re-rendering. The model emits a snapshot after every window, so the bail-out does real work; valid_commands and queued_changes arrive as fresh arrays every time and must be compared by content, or the bail-out never fires. Three snapshot fields carry most of the UI:
  • valid_commands is the authoritative list of what the session would accept right now. validCommands() (app/lib/machine.ts) is the single place every component asks; it adds the one thing the snapshot cannot know, whether there is a session at all. Buttons the model would refuse go dead on their own.
  • queued_changes names the fields edited during the take in flight; the panel renders a queued chip next to each. The values are already in the snapshot; the field only says “you will hear this on the next take”.
  • ready reports “an avatar image and a script are both set” as one flag. Since that is two conditions in one bit, startBlockedReason() tells the user which one a dead Start button is waiting on (the pre-filled scene prompt makes it easy to think the script is already written).
Two rules to keep when extending:
  1. Only state_update mutates the reducer. The confirmation events are notifications; every one is followed by a snapshot carrying the same information. Reconstructing state from those events is a second, racier path to the same place.
  2. Clear session state on disconnect. The SDK emits no final state_update, so without a reset on status === "disconnected", a reconnect renders the previous session’s conditions. The same effect drops any pending image waiter and the stage flag.
reset needs one extra piece of handling: the take panel holds local drafts for fields being edited, and those would survive a server-side reset and re-apply themselves on the next commit. useLtx2GenerationReset bumps a nonce that keys <TakePanel>, remounting it and dropping the drafts.

Transport

The transport buttons map one-to-one onto the argument-free commands, each gated by validCommands(). Components ask for a command by name, and the app shell’s Record<TransportCommand, …> map is exhaustive, so adding a command to the union without wiring it up is a type error.
app/components/Transport.tsx
held is the one thing valid_commands cannot tell you: the model keeps listing start while an avatar image is on its way, and starting then generates the take from the face the model still has. Holding Start across that window is the client’s job, done here with the imagePending and presetPending flags from the sections above. Pause is worth demonstrating to users: the stream freezes on the last frame while generation runs ahead into a bounded buffer, so resume continues mid-word with no warm-up. stop ends the take but keeps every condition for the next one; reset wipes the session back to defaults and is the only way to clear the avatar image. The model is windowed rather than frame-causal, so nothing streams until the leading window has denoised and decoded, a few seconds after start. The status line under the stage measures that gap; Going further shows how, plus the watchdog for a take that dies mid-sentence.

Chaining takes into a continuous performance

stop is a warm restart: it ends the take and keeps every condition on the session. So the tightest loop the model supports is a remix, setScript({ script }) then start(), and the public demo at reactor.inc keeps an avatar talking for minutes at a time on exactly that, with new script chunks written while the current take plays. There is no long-generation mode behind it. The example stops at the remix loop; the demo’s loop adds these mechanics:
  • Drive the loop off the snapshot, never off your own sends. A take has ended when generating flips false in state_update. When it does and a chunk is waiting, send set_script and start.
  • Settle before starting. start is refused while a take is in flight. To cut a running take short, send stop, then poll the snapshot until generating reads false on a bounded wait (the demo gives it 4 seconds).
  • Guard every start with a grace timer. A take that never begins streaming must not wedge the loop. The demo waits 25 seconds, then abandons that chunk and chains the next.
  • Know how much speech is left. For the running take that is effective_seconds - seconds_sent; before the model reports effective_seconds, estimate from the script (words / wpm * 60).
  • Refill early, write small. The demo asks its writer for the next chunk when less than about 12 seconds of speech is ahead, and keeps chunks to two or three sentences that end where a sentence ends. A take boundary is a hard cut, and a cut inside a clause is heard.
  • Pin the conditions once. The portrait, prompt, pace and seed are set before the first take and never re-sent, which is what keeps one face and one voice across every cut. Re-sending set_prompt re-casts the voice.
  • Skip the unchanged sends. Re-sending an unchanged value is harmless, but every send is a round-trip plus a snapshot echo, and the noise buries the command that mattered.
The writer behind the loop (an LLM, a playlist, a queue of user submissions) is not Reactor surface; the example does not choose one for you.

Surfacing errors

Every command can fail a precondition check, and a take can fail mid-flight. The example surfaces both:
app/Ltx2App.tsx
command_error is also the safety net for UI gating: since validCommands() passes the model’s own list straight through, a refusal landing here means the snapshot the UI acted on was already stale by the time the command arrived. Rare, and worth seeing rather than swallowing.

Saving a take

Recording is base-SDK surface: SnapClip.tsx imports only @reactor-team/js-sdk and is copied unchanged from the sibling examples. One adaptation is worth making: this model produces discrete takes, so size the request to the take, requestClip(ui.secondsSent + margin), rather than using requestRecording(), which would return every take plus the idle gaps between them.
On the current deployment requestClip() is accepted and returns a Clip, but the clip never materializes: the playlist never becomes playable and the request neither fails nor times out. The panel ships anyway, because recording needs no model-specific code, so it starts working with no client change. Verified still broken 2026-08-09.
See Recordings for the full feature, including continuous recording and retention policies.

Going further

Three patterns in the example are worth knowing and not worth blocking on. Each names the file that implements it.
  • Waiting for the avatar image (app/Ltx2App.tsx). Register the waiter before sending the command, or a fast confirmation lands before the listener exists. has_avatar_image is a sound fallback for the first image only: it is a level, not an edge, so on later uploads a snapshot already in flight satisfies it before the model has decoded anything. The waiters carry no correlation ids, so the Upload control is disabled while one is outstanding.
  • Measuring time to first frame (app/Ltx2App.tsx). Start the clock when start goes on the wire; stop it at the first newly-composited frame, observed with requestVideoFrameCallback on the <video> element, so the number is measured at the display. Arm it on generation_started: the track keeps compositing frames between takes, so the next frame after start reads a few milliseconds instead of a few seconds. Expect a few seconds warm, more on a cold pod: a measurement, not a spec.
  • Noticing a stalled stream (app/components/Stage.tsx). The snapshot and the frames travel on different transports and fail on their own: a take can die mid-sentence while state_update keeps reporting generating: true, and no message announces it. The only client-side signal is requestVideoFrameCallback going quiet. Watch it while the snapshot says frames should be flowing (generating, not paused, and seconds_sent > 0, so warm-up never counts), and recover with the transport the user already has: stop then start keeps every condition.

What’s intentionally left out

For the full design rationale, and the patterns to follow when extending the app, read skill/SKILL.md in the example repo.