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 typed @reactor-models/sana-streaming SDK. By the end you’ll know how to edit your webcam feed in real time, 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/reactor-cookbook under examples/api-models-examples/.
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:
See a “Setup Required” screen? Your REACTOR_API_KEY isn’t loaded. The check lives in app/page.tsx → app/SetupRequired.tsx.
3

Install dependencies and start the dev server

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, publish your webcam and an edit instruction, and the model streams back transformed frames in 24-frame chunks, paced to your camera’s own rate. 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 setPrompt doesn’t mean the edit changed yet; the model confirms with a prompt_accepted message and a state snapshot carrying the new current_prompt.
  • Errors arrive out-of-band. A broken precondition like resume with nothing paused surfaces later as a command_error message (useSanaStreamingCommandError), not a thrown exception.
The app drives the model through the typed @reactor-models/sana-streaming SDK, which wraps the base SDK with the model name and tracks baked in. SanaStreamingApp.tsx mounts <SanaStreamingProvider getJwt={fetchToken}>; components read status and call one typed method per command off useSanaStreaming() (setPrompt({ prompt }) rather than sendCommand("set_prompt", { prompt })), and subscribe to messages with per-message hooks like useSanaStreamingState. The wire shapes behind every method and message are in the schema.
Clip recording is the one exception. It is model-agnostic and not re-exported by the typed package, so SnapClip.tsx uses the base @reactor-team/js-sdk directly (useReactor, <ClipPlayer>). Drop it into any model example unchanged.

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 typed state messages into a small SanaState. Because it is fed only by the useSanaStreamingState hook, the reducer never has to filter by message type, and it reads the snapshot’s flat, typed fields directly:
app/lib/state.ts
The Workspace shell in SanaStreamingApp.tsx subscribes with three typed message hooks: useSanaStreamingState feeds the reducer, and useSanaStreamingCommandError and useSanaStreamingGenerationReset handle the two messages that need side effects, imperatively:
app/SanaStreamingApp.tsx
Every command can fail a precondition (pause with nothing generating, 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 Start button on state.started, the pause/resume and reset controls on state.started and state.paused. Informational messages (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, each with its own typed hook. 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 confirms nothing itself: it calls start() and lets the reducer report when generation is running.

Editing your webcam

Send your webcam to SANA-Streaming by publishing your camera to the model’s camera input track, then call 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
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 uses publish and unpublish from useSanaStreaming() in 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 typed SDK also ships a declarative <SanaStreamingCameraView> that acquires and publishes the webcam for you, but it gives no hook to set contentHint, so LiveInput owns the track and publishes it by hand. The Start live button calls start() and is disabled until status === "ready", the publish has resolved, and no generation is running. A run has no length limit: it keeps transforming the camera until the user resets it or the session ends.

The stage

Stage.tsx renders the model output with the typed <SanaStreamingMainVideoView>:
app/components/Stage.tsx
It is <ReactorView> with track="main_video" pre-bound, and 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. 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.

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
setPrompt works before start and at any point mid-stream; the model applies it at the next chunk boundary. A prompt is optional: start without one and the model streams a near-reconstruction until you set a prompt to steer it. The textarea’s placeholder (“Describe the edit. 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. They are deliberately short style tags (“Van Gogh oil painting, swirling brushstrokes, vivid colors”) that show how a whole-frame restyle reads against a live feed, leaning on the model’s default to carry everything else through. For surgical edits, a garment swap or a removal where you must spell out what stays fixed, write the fuller instructions the prompt guide lays out, with its anatomy and a recipe per edit type.

Playback, seed, and reset

The Input panel (ModeInput.tsx) is phase-aware, driven by the model’s started flag. Before a run it shows the webcam input and the seed field; once started flips true it keeps the input slot mounted (so the camera keeps publishing) and swaps the setup controls for Playback.tsx: pause, resume, and reset. Each is a typed method off useSanaStreaming(), gated on the reduced state:
app/components/Playback.tsx
The seed lives in SeedField.tsx, a setup-phase control that calls setSeed({ seed }) on blur. The model reads the seed at start, so the same source, prompt, and seed reproduce a run; the field is keyed to the model-reported seed, so a reset or an external setSeed refreshes it with the model’s value. reset does the most work: it aborts the run, clears the model’s prompt and progress, and emits generation_complete and generation_reset. The shell’s handler (from The model is the source of truth) mirrors that on the client. It blacks out the stage and clears the prompt draft, 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: For the full design rationale and the patterns to follow when extending the app, including the typed-SDK surface and the manual camera publish, read skill/SKILL.md in the example repo.