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 (
npmoryarnwork 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
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:ready the model is connected but idle; it produces no frames until an avatar image and a script
are set and you call start.
What you can change once a take is running
Nothing about the take: it is generated from the six conditions as they stood atstart. 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 freshstate_update. - Errors arrive out-of-band. A broken precondition surfaces later as a
command_errormessage, not as a thrown exception.
Authentication and the provider
Yourrk_… 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
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
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 beforestart; 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
Forset_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 realset_* 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
- 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
readynever turns true. - An emptied field is an abandoned edit, not a value. A blank script is the one thing
set_scriptrefuses, and0is a meaningfulduration_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: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 fullstate_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_commandsis 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_changesnames the fields edited during the take in flight; the panel renders aqueuedchip next to each. The values are already in the snapshot; the field only says “you will hear this on the next take”.readyreports “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).
- Only
state_updatemutates 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. - Clear session state on disconnect. The SDK emits no final
state_update, so without a reset onstatus === "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 byvalidCommands(). 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
generatingflips false instate_update. When it does and a chunk is waiting, sendset_scriptandstart. - Settle before starting.
startis refused while a take is in flight. To cut a running take short, sendstop, then poll the snapshot untilgeneratingreads false on a bounded wait (the demo gives it 4 seconds). - Guard every
startwith 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 reportseffective_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_promptre-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.
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.
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_imageis 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 whenstartgoes on the wire; stop it at the first newly-composited frame, observed withrequestVideoFrameCallbackon the<video>element, so the number is measured at the display. Arm it ongeneration_started: the track keeps compositing frames between takes, so the next frame afterstartreads 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 whilestate_updatekeeps reportinggenerating: true, and no message announces it. The only client-side signal isrequestVideoFrameCallbackgoing quiet. Watch it while the snapshot says frames should be flowing (generating, notpaused, andseconds_sent > 0, so warm-up never counts), and recover with the transport the user already has:stopthenstartkeeps 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.