Skip to main content
A guided tour of the open-source LongLive-2.0 Director 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 (the example pins lockfiles to pnpm; npm or yarn will work but you’ll regenerate the lockfile).
  • A Reactor API key (starts with rk_).
  • Familiarity with the Next.js App Router.
1

Clone the example

The example lives alongside our other reference apps in reactor-team/js-sdk under examples/.
git clone https://github.com/reactor-team/js-sdk
cd js-sdk/examples/longlive-v2
2

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:
cp .env.example .env
# then edit .env and set REACTOR_API_KEY to your API key
See a “Setup Required” screen? Your REACTOR_API_KEY isn’t loaded. The check lives in app/page.tsxapp/SetupRequired.tsx.
3

Install dependencies and start the dev server

pnpm install
pnpm dev
Open http://localhost:3000, click Connect, compose a storyboard (or load a preset), and press Start storyboard.

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. Opening the connection isn’t instant. Reactor provisions a GPU for your session, so the client moves through four states before media starts flowing:
Connection lifecycle: disconnected → connecting → waiting → ready
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 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 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:
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}` } });
}
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:
app/LongLiveApp.tsx
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>
  );
}
The broker pattern (server mints, client consumes) is standard for any browser-side Reactor app. See Authentication for the full concept page, including the Express equivalent and the Python path that skips the broker entirely.

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):
app/components/Storyboard.tsx
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.
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 for the scheduling contract.

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:
app/components/Timeline.tsx
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:
app/components/StatusBadge.tsx
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:
app/components/NowPlaying.tsx
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:
app/components/Video.tsx
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:
app/components/Director.tsx
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.

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:
app/components/SnapClip.tsx
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.
Clip preview in Chromium and Firefox requires hls.js, already in the example’s package.json. See Recordings for the full feature page, including continuous recording, programmatic capture, and retention policies.

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:
app/components/CommandError.tsx
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.
FeatureHow to add it
Draggable timelineTimeline.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 beatThere’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 imagesLongLive-2.0 is text-to-video only. There is no image conditioning. Describe the look in words; see the prompt guide.
For the full design rationale and the patterns to follow when extending the app, read skill/SKILL.md in the example repo.