Skip to main content
A guided tour of the open-source SANA-Streaming reference app, which demonstrates every important pattern for building on SANA-Streaming with the generic @reactor-team/js-sdk. By the end you’ll know how to edit your webcam feed in real time, edit an uploaded clip of your choice, steer the edit mid-stream, snap clips, and surface model errors.

Installation and setup

Get the example running before reading further. Every section below points back at code in the example repo. You will need:
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/sana-streaming
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 (the standard broker pattern); for now, drop the key into .env.local:
cp .env.example .env.local
# then edit .env.local 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, allow camera access, pick a preset prompt (or type your own edit), and press Start live.

How SANA-Streaming works

Building with SANA-Streaming is different from Reactor’s other models. Helios, LingBot, and LongLive-2.0 generate video from a prompt; SANA-Streaming edits the video you bring. You open a long-lived connection, give the model a source (your webcam or an uploaded clip) and an edit instruction, and it streams back transformed frames in 24-frame chunks, one every ~1-1.5s. Re-prompt at any time and the new edit lands at the next chunk boundary, with no re-render and no break in the stream. 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 the session lifecycle begins in its idle state. StatusBadge.tsx surfaces every connection state with a label and a Connect / Disconnect toggle. See Sessions for the full breakdown. Two properties of the API are worth internalizing before you read on:
  • Commands are asynchronous; messages are the source of truth. Calling set_video doesn’t mean the model has a source yet; it confirms with video_accepted and a state snapshot whose has_video flips to true.
  • Errors arrive out-of-band. A broken precondition like start with no source surfaces later as a command_error message, not a thrown exception.
There is no typed @reactor-models/sana-streaming package yet, so the app drives the base SDK directly. SanaStreamingApp.tsx wires the generic provider with the broker’s JWT resolver and the model name, <ReactorProvider getJwt={fetchToken} modelName="sana-streaming">. Where a typed package would expose setPrompt(), this app calls sendCommand("set_prompt", {prompt}) with the wire shapes from the schema.

The model is the source of truth

The browser sends commands and renders the state the model reports back; it never tracks generation state on its own. That discipline lives in one reducer in app/lib/state.ts, which projects the model’s state messages into a small SanaState and ignores every other message type:
app/lib/state.ts
// Projects model `state` snapshots into SanaState. Returns the previous
// object when nothing changed so React can bail out of re-rendering the
// whole tree on the model's frequent identical echoes.
export function reduce(state: SanaState, msg: SanaMessage): SanaState {
  if (msg.type !== "state") return state;
  const d = msg.data;
  const next: SanaState = {
    running: d.running,
    started: d.started,
    paused: d.paused,
    currentChunk: d.current_chunk,
    currentPrompt: d.current_prompt,
    hasVideo: d.has_video,
    seed: d.seed,
  };
  const changed = (Object.keys(next) as (keyof SanaState)[]).some((k) => next[k] !== state[k]);
  return changed ? next : state;
}
The Workspace shell in SanaStreamingApp.tsx owns the single useReactorMessage subscription. It runs every inbound message through reduce (non-state messages fall straight through), then handles the two that need side effects, command_error and generation_reset, imperatively:
app/SanaStreamingApp.tsx
const [state, setState] = useState(DEFAULT_STATE);

useReactorMessage((msg: SanaMessage) => {
  setState((s) => reduce(s, msg));
  // Transient set_video "decode failed" errors are auto-retried by
  // FileInput; don't flash the banner for them.
  if (msg.type === "command_error" && !isTransientDecodeFailure(msg)) {
    showCommandError(msg.data.reason);
  }
  if (msg.type === "generation_reset") {
    // Model reset clears its source video + prompt; mirror that locally.
    setSourceUrl(null);
    setResetNonce((n) => n + 1);
    setStageCleared(true);
  }
});
Every command can fail a precondition (start with no source, resume while not paused); the shell turns each command_error into a banner that dismisses itself after six seconds. Every control in the app gates off the reduced SanaState: the file-mode Start button on state.hasVideo, the mode toggle and clip picker on state.running, the transport buttons on state.started and state.paused. Informational messages (video_accepted, prompt_accepted, chunk_complete) are not state inputs; whatever they report also arrives in the next state snapshot, which is the canonical payload.
command_error is one of several messages SANA-Streaming emits. See the Messages table for the full list, including generation_started, generation_complete, and the per-chunk chunk_complete.
The same discipline shapes the commands going out. A command only takes effect once the model echoes it back in a state snapshot, so the start path doesn’t confirm anything itself: every start flow, live or file, fires the same two commands and lets the reducer report when generation is running:
app/lib/state.ts
// The start flow is always set_mode -> start. Re-sending set_mode keeps the
// flow self-contained regardless of which mode the model is in; the model
// treats a repeated set_mode as idempotent.
export async function startGeneration(sendCommand, mode: SanaMode) {
  await sendCommand("set_mode", { mode });
  await sendCommand("start", {});
}

Live mode: editing your webcam

Live mode is the headline feature and the app’s default. Send your webcam to SANA-Streaming by publishing your camera to the model’s camera input track, then set_mode {mode:"live"} and start. Edited frames come back on the main_video track about a second later. LiveInput.tsx owns the camera acquisition rather than reaching for a declarative webcam component:
app/components/LiveInput.tsx
const stream = await navigator.mediaDevices.getUserMedia({
  video: { width: { ideal: 640 }, height: { ideal: 360 }, facingMode: "user" },
});
const videoTrack = stream.getVideoTracks()[0];
videoTrack.contentHint = "detail"; // hold resolution; adapt framerate
Browsers shrink and grow a camera track’s resolution mid-stream to cope with bandwidth, and a resolution change mid-chunk crashes the live session. Setting contentHint = "detail" before publish makes the browser hold resolution steady and adapt the frame rate instead, which the model handles fine. Owning the MediaStreamTrack is what makes that line possible; set the hint in any client you build. See Tracks.
Publishing is an effect keyed on the track and the connection status: it publishes once the session is ready, re-publishes after a reconnect, and unpublishes on unmount. The Start live button calls startGeneration(sendCommand, "live") and is disabled until status === "ready", the publish has resolved, and no generation is running. Switching the mode toggle to File unmounts LiveInput, which unpublishes the track and stops the webcam, so a mode switch can’t leave the camera running.

File mode: editing an uploaded clip

File mode trades the camera for an uploaded clip of at least 33 frames. The flow in FileInput.tsx is uploadFile → set_video → start, with the model’s state as the gate in the middle:
app/components/FileInput.tsx
const sendCommand = useReactor((s) => s.sendCommand);
const uploadFile = useReactor((s) => s.uploadFile);

// Shared upload path for manual picks and preset clips.
async function uploadVideo(file: File) {
  const ref = await uploadFile(file);
  lastRefRef.current = ref;
  retriesRef.current = DECODE_RETRIES;
  await sendCommand("set_video", { video: ref });
  onSource(URL.createObjectURL(file)); // the stage plays this next to the output
}
The model accepts the upload without decoding it (frames decode during generation) and replies with video_accepted plus a state snapshot whose has_video is true. The Start edit button is disabled on !state.hasVideo, which flips when the model accepts the clip, not when the upload promise resolves. See set_video for the command contract and File Uploads for what the SDK does with the bytes. One quirk is worth handling in any client you build: the model sometimes rejects a perfectly valid clip with a decode failed error. It’s a timing glitch on the model side, not a problem with your file, and re-sending the same set_video almost always clears it. So FileInput watches for that one error and retries up to twice with the already-uploaded clip before treating it as real:
app/components/FileInput.tsx
useReactorMessage((msg: SanaMessage) => {
  if (msg.type !== "command_error") return;
  const { command, reason } = msg.data;
  if (command !== "set_video" || !reason.startsWith("decode failed")) return;
  if (retriesRef.current > 0 && lastRefRef.current) {
    retriesRef.current -= 1;
    sendCommand("set_video", { video: lastRefRef.current }).catch(() => {
      setError("Upload failed: " + reason);
    });
  } else {
    setError("Upload failed: " + reason);
  }
});
There’s no need to re-upload; the clip is still on the server. And while FileInput retries, the error banner stays silent, so the user never sees a flash of failure for something the app is about to fix on its own. (The banner skips these by checking an isTransientDecodeFailure helper in app/lib/state.ts, the same condition FileInput matches above.) Two behaviors that follow from the model latching its source at start:
  • Clip picks are disabled while a run is in progress. A mid-run set_video would not take effect until the next start, and the UI would show a clip the model isn’t using. Reset first, then pick a new clip.
  • A file-mode run ends on its own. Once every source frame is transformed, the model emits generation_complete and returns to idle with the clip, prompt, and seed still staged. start replays the clip from the top; reset is only needed to swap clips. On completion the main_video track freezes on the last transformed frame rather than going dark; the next section covers what the stage does with frozen frames.

The stage

Stage.tsx renders the model output with the base SDK’s <ReactorView>:
app/components/Stage.tsx
import { ReactorView } from "@reactor-team/js-sdk";

<ReactorView
  track="main_video"
  videoObjectFit="contain"
  className="absolute inset-0 h-full w-full"
/>;
It manages the <video> element, srcObject binding, and browser autoplay quirks for you. Apply your styling to the container around it, not to the video element it renders. In file mode with a source loaded, the stage splits into two panes: the original clip on the left, the transformed stream on the right. The local clip is driven off the reducer state (play when running, pause when paused, rewind when the source clears) as an approximate sync by design, with no seeking or drift correction. A status row along the bottom reads running / paused, currentChunk, and currentPrompt straight off the reduced state. After a reset the model emits nothing new, so the view would freeze on the last transformed frame; the shell blacks the stage out until state.running flips back to true. A completed file-mode run freezes the view the same way, but there the example leaves the last frame visible (the status row drops back to idle) until the next start or reset.

Steering the prompt mid-stream

Prompts are editing instructions, not scene descriptions: “apply a Van Gogh oil painting style,” not “a Van Gogh painting of a room.” Prompt.tsx is one textarea, one Apply button, and a row of preset chips, and every path funnels into the same call:
app/components/Prompt.tsx
const sendCommand = useReactor((s) => s.sendCommand);
const [text, setText] = useState("");

const apply = (prompt: string) => {
  if (status !== "ready") return;
  sendCommand("set_prompt", { prompt }).catch(console.error);
};
set_prompt works before start and at any point mid-stream; the model applies it at the next chunk boundary. The textarea’s placeholder (“changes apply live, about one chunk later”) spells out the latency. The active-prompt readout under the button renders state.currentPrompt, so it reflects what the model is using rather than what was last typed. The preset chips come from app/lib/examples.ts. Read one before writing your own: each names the edit up front, asks for temporal consistency across frames, and ends by listing what must carry through from the source (“preserve all original motion, character actions, camera movement, and composition”). That preservation clause is the heart of SANA-Streaming prompting; the prompt guide covers the anatomy and a recipe per edit type.

Transport, seed, and reset

Transport.tsx is the smallest of the control panels: every button is a bare sendCommand, enabled or hidden by the reduced state.
app/components/Transport.tsx
const send = (cmd: string, data: Record<string, unknown> = {}) =>
  sendCommand(cmd, data).catch(console.error);

{
  started &&
    (paused ? (
      <IconButton icon="play" label="Resume" onClick={() => send("resume")} />
    ) : (
      <IconButton icon="pause" label="Pause" onClick={() => send("pause")} />
    ));
}
<IconButton icon="reset" label="Reset" onClick={() => send("reset")} />;
Pause and resume only render once state.started is true; reset and the seed field are useful any time. Set a seed before the first start for reproducible output; a reset or an external set_seed refreshes the field with the model’s value. reset does the most work: it aborts the run and clears the model’s source, prompt, and progress, emitting generation_reset. The shell’s handler (from The model is the source of truth) mirrors that on the client, dropping the side-by-side source URL, blacking out the stage, and clearing the prompt draft and file selection so the UI matches the model.

What’s intentionally left out

The demo covers the connect + edit + steer + capture loop. Clip capture is a shared base-SDK feature, so Recordings covers it, including continuous recording, programmatic capture, and retention. A few other patterns are out of scope, and each is a small addition:
FeatureHow to add it
Screen capture as the sourceSwap getUserMedia for getDisplayMedia in LiveInput.tsx and publish the track to camera the same way. The contentHint = "detail" line still applies; any unhinted track can crash the live session.
Swapping clips between runsThe model latches its source at start, so send reset, then upload and set_video the new clip. The demo’s UI guides users there by disabling clip picks while a run is in progress.
For the full design rationale and the patterns to follow when extending the app, including the constraints behind the SDK pin and the manual camera publish, read skill/SKILL.md in the example repo.