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

# LongLive-2.0 tutorial

> An end-to-end LongLive-2.0 walkthrough against the open-source example.

A guided tour of the open-source
[LongLive-2.0 Director](https://github.com/reactor-team/js-sdk/tree/main/examples/longlive-v2)
reference app, which demonstrates every important pattern in the LongLive-2.0 SDK. By the end of
this tutorial you'll know how to compose a storyboard of shots and cuts, direct the session live,
and surface model errors. It uses the typed `@reactor-models/longlive-v2` SDK throughout.

## Installation and setup

Get the example running locally before reading further. Every section below points back at code in
the repo. You will need:

* Node.js 18+.
* [pnpm](https://pnpm.io/installation) (the example pins lockfiles to pnpm; `npm` or `yarn` will
  work but you'll 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">
    The example lives alongside our other reference apps in
    [`reactor-team/js-sdk`](https://github.com/reactor-team/js-sdk) under `examples/`.

    ```bash theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
    git clone https://github.com/reactor-team/js-sdk
    cd js-sdk/examples/longlive-v2
    ```
  </Step>

  <Step title="Add your API key">
    Your `rk_…` key must never reach the browser; the example reads it server-side and mints a
    short-lived JWT for the client. We'll cover the broker pattern below; for now, drop the key
    into `.env`:

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

    <Tip>
      See a "Setup Required" screen? Your `REACTOR_API_KEY` isn't loaded. The check lives in
      `app/page.tsx` → `app/SetupRequired.tsx`.
    </Tip>
  </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`, click **Connect**, compose a storyboard (or load a preset), and
    press **Start storyboard**.
  </Step>
</Steps>

## How LongLive-2.0 works

Building with LongLive-2.0 is different from calling a typical generative API. There's no prompt-in
/ video-out request. You open a long-lived connection, set an opening **shot**, and the model begins
producing a continuous stream of 29-frame chunks (\~1.2s each). You direct it by sending evolving
**shots** (same scene) and hard **cuts** (new scene), in real-time or scheduled ahead against the
cumulative `session_chunk` clock. A single scene can run up to 48 chunks (\~58s). A cut resets that
budget and extends the video. See
[Chunks, scenes, and length](/model-api-reference/longlive-v2/overview#chunks-scenes-and-length).

Opening the connection isn't instant. Reactor provisions a GPU for your session, so the client moves
through four states before media starts flowing:

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

The `waiting` state is when the GPU is being assigned, which takes a few seconds. Once the status
reaches `ready`, commands take effect and chunks start arriving. See
[Sessions](/concepts/sessions#connection-lifecycle) for the full breakdown.

Two properties of the API are worth internalizing before you read on:

* **Commands are asynchronous; events are the source of truth.** Calling `setShot` doesn't mean the
  next chunk uses it; the model confirms with `shot_set`, and every `state` snapshot reflects the
  authoritative session position.
* **Errors arrive out-of-band.** A broken precondition like `start` with no opening shot surfaces
  later as a `command_error` event, not a thrown exception.

The example's UI is **phase-driven** by the model's `state` snapshot. While idle you compose a plan
(`<Storyboard>`); once `snapshot.started` is `true` you can direct it live (`<NowPlaying>` +
`<Director>`). `app/LongLiveApp.tsx` wires the provider and the phase-driven layout; each panel
subscribes with `useLongliveV2State` and returns `null` when it's not its phase.

## Authentication

LongLive-2.0 opens a long-lived WebRTC connection the server needs to trust for hours. Shipping a
raw `rk_…` key to the browser would hand full account access to anyone with devtools open. Instead,
the SDK presents a [JWT](https://jwt.io/introduction) minted server-side from your API key,
short-lived (Reactor caps it at 6 hours), and safe to hand to the client. Your `rk_…` key stays on
the server.

Every LongLive frontend needs one server-side route that mints JWTs. In the example that's
`app/api/reactor/token/route.ts`, which exchanges your `rk_…` key for a JWT and returns it with a
`Cache-Control` header derived from the token's real expiry:

<CodeGroup>
  ```typescript app/api/reactor/token/route.ts theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
  import { NextResponse } from "next/server";

  const TOKEN_LIFETIME_SECONDS = 6 * 60 * 60; // Reactor caps this at its server max
  const CACHE_SKEW_SECONDS = 60;

  // Exposed as GET so the browser's HTTP cache can serve repeat calls.
  export async function GET() {
    const apiKey = process.env.REACTOR_API_KEY!;
    // ...error handling omitted...

    const res = await fetch("https://api.reactor.inc/tokens", {
      method: "POST",
      headers: { "Reactor-API-Key": apiKey, "Content-Type": "application/json" },
      body: JSON.stringify({ expires_after: TOKEN_LIFETIME_SECONDS }),
    });

    const { jwt, expires_at } = (await res.json()) as { jwt: string; expires_at: number };

    // Derive cache lifetime from the actual expiry, with a one-minute safety skew.
    const maxAge = Math.max(0, expires_at - Math.floor(Date.now() / 1000) - CACHE_SKEW_SECONDS);
    return NextResponse.json({ jwt }, { headers: { "Cache-Control": `private, max-age=${maxAge}` } });
  }
  ```

  ```python server.py theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
  import os

  from fastapi import FastAPI
  from reactor_sdk import fetch_jwt_token

  app = FastAPI()


  # Mint a short-lived JWT server-side and hand it to the browser. The
  # rk_… key never leaves the server.
  @app.get("/api/reactor/token")
  async def token():
      jwt = await fetch_jwt_token(api_key=os.environ["REACTOR_API_KEY"])
      return {"jwt": jwt}
  ```
</CodeGroup>

`LongLiveApp.tsx` hands a **resolver** (not a static string) to `<LongliveV2Provider getJwt>`. The
SDK calls it on every Coordinator hop (clip manifests, ICE refreshes), and the route's
`Cache-Control` header lets the browser serve repeat calls from its HTTP cache until the JWT
expires:

```tsx app/LongLiveApp.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
import { LongliveV2Provider } from "@reactor-models/longlive-v2";

async function fetchToken(): Promise<string> {
  const r = await fetch("/api/reactor/token");
  if (!r.ok) throw new Error(`Token fetch failed: ${r.status}`);
  const { jwt } = (await r.json()) as { jwt: string };
  return jwt;
}

export function LongLiveApp() {
  // No autoConnect: the user clicks Connect so they see the state machine first-hand.
  return (
    <LongliveV2Provider getJwt={fetchToken}>
      {/* ...sidebar panels + <Video /> + <Timeline /> ... */}
    </LongliveV2Provider>
  );
}
```

<Tip>
  The broker pattern (server mints, client consumes) is standard for any browser-side Reactor app.
  See [Authentication](/authentication) for the full concept page, including the Express equivalent
  and the Python path that skips the broker entirely.
</Tip>

## Composing a storyboard

LongLive-2.0's signature is directing a whole sequence up front. `Storyboard.tsx` is the setup-phase
panel: you set an opening shot, then add **shots** (soft, same scene) and **cuts** (hard, new scene)
at chunk positions. The example calls each one a *beat*, a `{ kind, prompt, atChunk }` entry in the
storyboard store. "Start storyboard" compiles the plan into the wire sequence (the opener with
`setShot`, each later beat with `scheduleShot` / `scheduleSceneCut`, then `start`):

```tsx app/components/Storyboard.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const { status, setShot, scheduleShot, scheduleSceneCut, start } = useLongliveV2();
const { beats } = useStoryboard(); // authored plan: [{ kind, prompt, atChunk }]

async function startStoryboard() {
  const opener = beats.find((b) => b.atChunk === 0);
  await setShot({ prompt: opener.prompt }); // opener fires before start

  for (const b of beats.filter((b) => b.atChunk !== 0)) {
    if (b.kind === "cut") {
      await scheduleSceneCut({ prompt: b.prompt, at_session_chunk: b.atChunk });
    } else {
      await scheduleShot({ prompt: b.prompt, at_session_chunk: b.atChunk });
    }
  }
  await start();
}
```

The authored plan is plain client state (a small zustand store in `app/lib/storyboard-store.ts`),
distinct from the model's live position, which you read from `useLongliveV2State`. Presets in
`app/lib/prompts.ts` load full multi-shot sequences in one click; their header comment is worth
reading, because **terse prompts produce weak output**: every beat is a dense, cinematic paragraph
on purpose. See the [prompt guide](/model-api-reference/longlive-v2/prompt-guide).

<Tip>
  Keep each scene's beats inside its 48-chunk budget, or put a `cut` before the ceiling; a beat
  scheduled past where its scene auto-completes never fires. See
  [`schedule_shot`](/model-api-reference/longlive-v2/schema#commands) for the scheduling contract.
</Tip>

## The chunk timeline

`Timeline.tsx` is a read-only visual of the plan on a chunk axis: scene dividers at each cut, beats
as ticks, and (once running) a playhead at the model's cumulative `session_chunk`. It's the clearest
way to *see* the shot-vs-cut grammar and the per-scene budget:

```tsx app/components/Timeline.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
import { useLongliveV2State } from "@reactor-models/longlive-v2";
import { useStoryboard, sceneStarts, SCENE_BUDGET } from "../lib/storyboard-store";

const { beats } = useStoryboard();
const [snapshot, setSnapshot] = useState<LongliveV2StateMessage | null>(null);
useLongliveV2State((msg) => setSnapshot(msg));

const sessionChunk = snapshot?.session_chunk ?? 0; // playhead position
const starts = sceneStarts(beats); // chunk index where each scene begins
// render scene dividers at `starts`, a tick per beat, and a playhead at `sessionChunk`
```

`session_chunk` is the cumulative clock that never resets; it's what scheduled beats fire against
and where the playhead sits. `current_chunk` (used in *Now playing* below) is the per-scene counter
that resets to 0 on every cut.

## Going live

Once generation starts, the sidebar flips to its live panels. `StatusBadge.tsx` is the user's window
into the four-state connection machine. Every state, including the multi-second `waiting` GPU step,
gets a visible label:

```tsx app/components/StatusBadge.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
import { useLongliveV2 } from "@reactor-models/longlive-v2";

const { status, lastError, connect, disconnect } = useLongliveV2();
// status ∈ "disconnected" | "connecting" | "waiting" | "ready"
// render a colored dot + label; toggle Connect/Disconnect on `status === "disconnected"`
```

`NowPlaying.tsx` mirrors the `state` snapshot: the active prompt, the per-scene budget
(`current_chunk` / 48) and the cumulative `session_chunk`, plus pause / resume / reset transport.
Subscribe once and read fields off the snapshot, no event aggregation:

```tsx app/components/NowPlaying.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const { status, pause, resume, reset } = useLongliveV2();
const [snapshot, setSnapshot] = useState<LongliveV2StateMessage | null>(null);
useLongliveV2State((msg) => setSnapshot(msg));

// Phase switch: render nothing until generation is running.
if (status !== "ready" || !snapshot?.started) return null;

const remaining = SCENE_BUDGET - (snapshot.current_chunk ?? 0); // warn near the ceiling → cut
return snapshot.paused ? (
  <button onClick={() => resume()}>Resume</button>
) : (
  <button onClick={() => pause()}>Pause</button>
);
// `reset` clears the model AND the local storyboard so the composer starts fresh.
```

The video pane is one component, the typed `<LongliveV2MainVideoView />`, a pre-bound
`<ReactorView track="main_video">` that handles the `<video>` element, `srcObject` binding, and
autoplay quirks:

```tsx app/components/Video.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
import { LongliveV2MainVideoView } from "@reactor-models/longlive-v2";

export function Video() {
  return <LongliveV2MainVideoView className="h-full w-full" videoObjectFit="contain" />;
}
```

## Directing live

`Director.tsx` is the live-phase counterpart to the storyboard: fire a soft shot or hard cut at the
next chunk boundary ("now"), or schedule one ahead at a chunk index. Same four methods, applied to a
running session:

```tsx app/components/Director.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const { setShot, sceneCut, scheduleShot, scheduleSceneCut } = useLongliveV2();
const sessionChunk = snapshot.session_chunk ?? 0;

async function fire({ kind, prompt, when, atChunk }) {
  if (when === "now") {
    if (kind === "cut")
      await sceneCut({ prompt }); // hard break, fresh 48-chunk budget
    else await setShot({ prompt }); // soft beat, same scene
  } else {
    const target = Math.max(sessionChunk + 1, atChunk); // can't schedule in the past
    if (kind === "cut") await scheduleSceneCut({ prompt, at_session_chunk: target });
    else await scheduleShot({ prompt, at_session_chunk: target });
  }
}
```

A soft `setShot` keeps the scene's memory and continuity; a hard `sceneCut` makes a clean break to a
new scene and resets the budget. Choosing between them is the core creative decision. See
[Shots vs cuts](/model-api-reference/longlive-v2/prompt-guide#shots-vs-cuts-the-core-decision).

## Snapping a clip

The SDK ships recording primitives so you don't have to wire up `MediaRecorder` yourself.
`SnapClip.tsx` captures the last few seconds of the live stream and opens a modal with the SDK's
built-in preview player and a download button:

```tsx app/components/SnapClip.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
import {
  ClipDownloadButton,
  ClipPlayer,
  RecordingError,
  useReactor,
  type Clip,
} from "@reactor-team/js-sdk";

const { status, requestClip } = useReactor((s) => ({
  status: s.status,
  requestClip: s.requestClip,
}));
const [clip, setClip] = useState<Clip | null>(null);

async function snap() {
  try {
    setClip(await requestClip(10)); // last 10 seconds
  } catch (e) {
    if (e instanceof RecordingError) {
      // render e.code + e.reason (omitted)
    }
  }
}

return (
  clip && (
    <Modal onClose={() => setClip(null)}>
      <ClipPlayer clip={clip} />
      <ClipDownloadButton clip={clip} filename="longlive-clip.mp4" />
    </Modal>
  )
);
```

Notice the imports come from `@reactor-team/js-sdk`, not `@reactor-models/longlive-v2`. **Recording
is a base-SDK feature**: it works the same for every Reactor model, and the typed model packages
don't re-export the recording surface, so direct base-SDK imports are idiomatic here. `<ClipPlayer>`
and `<ClipDownloadButton>` auto-inherit the JWT resolver from `<LongliveV2Provider getJwt={…}>` via
React context. Errors come back as a `RecordingError` with a typed `code` and `reason`, distinct
from the `command_error` events covered next.

<Tip>
  Clip preview in Chromium and Firefox requires `hls.js`, already in the example's `package.json`.
  See [Recordings](/concepts/recordings) for the full feature page, including continuous recording,
  programmatic capture, and retention policies.
</Tip>

## Surfacing command\_error

Every LongLive-2.0 command can fail a precondition check: `start` with no opening shot, a beat
scheduled in the past, an empty prompt. `CommandError.tsx` never lets these fail silently:

```tsx app/components/CommandError.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const [error, setError] = useState<{ command: string; reason: string } | null>(null);

useLongliveV2CommandError((msg) => setError({ command: msg.command, reason: msg.reason }));

// Clear on the next state snapshot: any state change implies the user has moved on.
useLongliveV2State(() => setError(null));

if (!error) return null;
return (
  <div>
    {error.command} failed: {error.reason}
  </div>
);
```

`useLongliveV2CommandError` is the typed wrapper for the `command_error` message. The panel renders
nothing until an error arrives and clears itself on the next snapshot so a stale banner can't pile
up.

## What's intentionally left out

Not every pattern is surfaced in this demo. See below for what's missing and how to add it.

| Feature                   | How to add it                                                                                                                                                                               |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Draggable timeline        | `Timeline.tsx` is read-only here. Make beats draggable to reschedule them: re-emit `scheduleShot` / `scheduleSceneCut` at the new chunk. The Reactor webapp playground has the full editor. |
| Removing a scheduled beat | There's no `unschedule` command in this release; `reset` clears everything. Compose the full sequence before `start`, or fire live beats from `<Director>` as you go.                       |
| Reference images          | LongLive-2.0 is text-to-video only. There is no image conditioning. Describe the look in words; see the [prompt guide](/model-api-reference/longlive-v2/prompt-guide).                      |

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/longlive-v2).
