> ## Documentation Index
> Fetch the complete documentation index at: https://docs.reactor.inc/llms.txt
> Use this file to discover all available pages before exploring further.

# LTX tutorial

> An end-to-end LTX walkthrough against the open-source reference frontend.

A guided tour of the open-source
[LTX reference app](https://github.com/reactor-team/js-sdk/tree/main/examples/ltx2), 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](#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](https://pnpm.io/installation) (`npm` or `yarn` work but regenerate the lockfile).
* A [Reactor API key](/authentication) (starts with `rk_`).
* Familiarity with the [Next.js App Router](https://nextjs.org/docs/app).

<Steps>
  <Step title="Clone the example">
    ```bash theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
    git clone https://github.com/reactor-team/js-sdk
    cd js-sdk/examples/ltx2
    ```
  </Step>

  <Step title="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`:

    ```bash theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
    cp .env.example .env.local
    # then edit .env.local and set REACTOR_API_KEY to your API key
    ```
  </Step>

  <Step title="Install dependencies and start the dev server">
    ```bash theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
    pnpm install
    pnpm dev
    ```

    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.
  </Step>
</Steps>

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

<Frame>
  <img src="https://mintcdn.com/reactortechnologiesinc/oCeFlbp0H3XO2l0l/diagrams/connection-lifecycle.svg?fit=max&auto=format&n=oCeFlbp0H3XO2l0l&q=85&s=3f00f9076a6550f4f071f30d07eea1e6" alt="Connection lifecycle: disconnected → connecting → waiting → ready" width="760" height="148" data-path="diagrams/connection-lifecycle.svg" />
</Frame>

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`](/model-api-reference/ltx2/schema#start).

<Warning>
  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.
</Warning>

### 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`](/model-api-reference/ltx2/schema#messages-from-model) 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](/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:

```tsx app/Ltx2App.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
import { Ltx2Provider } from "@reactor-models/ltx2";
import { REACTOR_API_URL } from "@/app/lib/config";

// The coordinator URL rides along as a query parameter purely as a cache key:
// tokens are signed per-coordinator, so moving this app between environments
// must not let the browser replay a JWT cached for the previous one.
async function fetchToken(): Promise<string> {
  const r = await fetch(`/api/reactor/token?coordinator=${encodeURIComponent(REACTOR_API_URL)}`);
  if (!r.ok) throw new Error(`Token fetch failed: ${r.status}`);
  const { jwt } = (await r.json()) as { jwt: string };
  return jwt;
}

export function Ltx2App() {
  return (
    <Ltx2Provider
      apiUrl={REACTOR_API_URL}
      getJwt={fetchToken}
      connectOptions={{ autoConnect: false }}
    >
      <Workspace />
    </Ltx2Provider>
  );
}
```

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

```tsx app/components/Stage.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const { tracks } = useLtx2();
const videoTrack = tracks["main_video"] ?? null;
const audioTrack = tracks["main_audio"] ?? null;

const mediaStream = useMemo(() => {
  const list: MediaStreamTrack[] = [];
  if (videoTrack) list.push(videoTrack);
  if (audioTrack) list.push(audioTrack);
  return list.length ? new MediaStream(list) : null;
}, [videoTrack, audioTrack]);

useEffect(() => {
  const el = videoRef.current;
  if (!el) return;
  el.srcObject = mediaStream;
  if (!mediaStream) return;
  el.play().catch(() => {
    // Autoplay with audio needs a user gesture. The model's voice matters
    // here, so we ask for the gesture rather than falling back to muted.
    setNeedsAudioUnlock(true);
  });
}, [mediaStream]);
```

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.

<Note>
  `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.
</Note>

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

```tsx app/Ltx2App.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const { setScript, setPrompt, setWpm, setDurationSeconds, setSeed } = useLtx2();

switch (edit.field) {
  case "script":
    return setScript({ script: edit.value }); // up to 10,000 characters
  case "prompt":
    return setPrompt({ prompt: edit.value }); // up to 800 characters
  case "wpm":
    return setWpm({ wpm: edit.value }); // default 140
  case "duration_seconds":
    return setDurationSeconds({ duration_seconds: edit.value }); // 0 = derive from the script
  case "seed":
    return setSeed({ seed: edit.value });
}
```

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:

```text theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
set_avatar_image → set_script → set_prompt → set_wpm → set_seed
                 → set_duration_seconds → start
```

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.

| Preset          | Pace    | Seed |
| --------------- | ------- | ---- |
| Teddy Bear      | 110 wpm | 220  |
| Grandma         | 120 wpm | 1938 |
| Radio Announcer | 160 wpm | 1946 |

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](/model-api-reference/ltx2/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`](/model-api-reference/ltx2/schema#state_update-payload)
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.

```tsx app/Ltx2App.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const [ui, setUi] = useState<Ltx2UiState>(DEFAULT_UI_STATE);

// The snapshot is the only thing that mutates UI state.
useLtx2StateUpdate((msg) => setUi((prev) => reduce(prev, msg)));
```

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

```tsx app/components/Transport.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const valid = validCommands(status, ui);

<Button disabled={!valid.has("start") || held} onClick={() => fire("start")}>Start take</Button>
<Button disabled={!valid.has("pause")} onClick={() => fire("pause")}>Pause</Button>
<Button disabled={!valid.has("resume")} onClick={() => fire("resume")}>Resume</Button>
<Button disabled={!valid.has("stop")} onClick={() => fire("stop")}>Stop</Button>
<Button disabled={!valid.has("reset")} onClick={() => fire("reset")}>Reset</Button>
```

`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](#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:

```tsx app/Ltx2App.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
useLtx2CommandError((msg) =>
  setNotice({ kind: "error", text: `${msg.command} refused: ${msg.reason}` }),
);
useLtx2GenerationFailed((msg) =>
  setNotice({ kind: "error", text: `Generation failed: ${msg.reason}` }),
);
```

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

<Warning>
  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.
</Warning>

<Tip>
  See [Recordings](/concepts/recordings) for the full feature, including continuous recording and
  retention policies.
</Tip>

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

| Feature             | Why, and how to add it                                                                                    |
| ------------------- | --------------------------------------------------------------------------------------------------------- |
| Live / `say()` mode | Not in this release of the model. Takes are atomic; there is no way to inject speech into a running take. |

For the full design rationale, and the patterns to follow when extending the app, read
`skill/SKILL.md` in
[the example repo](https://github.com/reactor-team/js-sdk/tree/main/examples/ltx2).
