Skip to main content
This page documents the complete X2 wire surface: the media tracks it consumes and produces, the session lifecycle, every command you can send, and the messages the model emits back. For what X2 is and a quick start, see the overview.

Tracks

DirectionNameTypeDescription
InboundsourceVideoThe stream to edit, published by the client
Outboundmain_videoVideoThe re-rendered stream, published by the model
Unlike the generation-only models, X2 takes an inbound track: the client publishes the video to be edited on source, and the model streams the transformed result back on main_video. The model’s native source pacing is 24 fps, and the output plays at 24 fps with no frame interpolation and no upscaling. The model chooses the output resolution (a bucket around 832p) once at the first generation, from the source stream’s aspect ratio. It holds for the whole session and arrives in generation_started and in every state_update.
A still image works as a source too: draw it to a canvas and capture it with canvas.captureStream(24), repainting at the stream rate so the capturer keeps emitting frames. From the model’s side that is indistinguishable from a video of a motionless scene, which is the drag-to-animate setup. The tutorial shows the pattern.

Session lifecycle

There is no start command and no staged or started state machine to drive. Generation begins on its own once two things are true: a non-empty prompt is set, and source frames are arriving. It runs until the session ends, a new reference image forces an automatic restart, or you call reset. Reset stops generation and clears the prompt, reference image, and pointer. See Sessions for the connection-level lifecycle (disconnected → connecting → waiting → ready) the session passes through first. Every control follows block semantics: the model generates one block at a time, a prompt change applies from the next block, it samples the pointer once per block, and the backlog policy switches at the next block.

Commands

Send commands with reactor.sendCommand() on the base SDK, or the typed methods on the generated model client / useX2() hook. Below are all available commands:
CommandDescription
set_promptSet or hot-swap the editing instruction (required for generation)
set_reference_imageSet the character/object image that anchors swaps and insertions
set_pointerUpdate the drag pointer: position and press state together
set_pointer_xUpdate only the pointer’s horizontal position
set_pointer_yUpdate only the pointer’s vertical position
set_pointer_activeUpdate only whether a drag is in progress
set_keep_backlogSet the source-frame policy between bounded latency and every frame
resetStop generation and clear the prompt, reference image, and pointer

set_prompt

Set the editing instruction that guides the re-render (for example a character swap or insertion). Generation needs a non-empty prompt before it begins; setting one while generating applies from the next block. Emits prompt_accepted, then state_update. Parameters:
ParameterTypeRequiredDescription
promptstringNoThe editing instruction. Up to 1000 characters. Default "" (empty)
await reactor.sendCommand("set_prompt", {
  prompt: "replace the character in the video with the character from the reference image",
});
const { setPrompt } = useX2();
await setPrompt({
  prompt: "replace the character in the video with the character from the reference image",
});
await reactor.send_command("set_prompt", {
    "prompt": "replace the character in the video with the character from the reference image",
})

set_reference_image

Provide a reference image of a character or object to insert or swap into the video. Upload the file first with uploadFile(), then pass the returned FileRef. Emits reference_image_accepted and state_update on success, or command_error if the file cannot be decoded as an image. The reference can be set before generation or replaced while generating. A mid-run replacement restarts the stream on its own (generation_stopped with reason reference_image_changed, then a fresh generation_started) and the new image conditions the edit from its first block. The prompt stays set across the swap, so the edit resumes without a re-arm; no reset is needed. Parameters:
ParameterTypeRequiredDescription
reference_imageFileRefYesA reference to an uploaded image, returned by uploadFile()
const ref = await reactor.uploadFile(imageFile);
await reactor.sendCommand("set_reference_image", { reference_image: ref });
const { uploadFile, setReferenceImage } = useX2();
const ref = await uploadFile(imageFile);
await setReferenceImage({ reference_image: ref });
ref = await reactor.upload_file("character.jpg")
await reactor.send_command("set_reference_image", {"reference_image": ref})

set_pointer

Update the drag pointer that steers the edited subject’s motion, setting its position and press state together. Valid at any time; the model samples the pointer once per generated block, and it only has an effect while active is true. Coordinates map to the output frame, so account for any letterboxing your player introduces when you map pointer events. Emits pointer_changed, then state_update. Parameters:
ParameterTypeRequiredDescription
xnumberNoHorizontal position, 0 (left) to 1 (right). Default 0.5
ynumberNoVertical position, 0 (top) to 1 (bottom). Default 0.5
activebooleanNoWhether a drag is in progress; ignored while false. Default false
// Drag in progress at the frame's center-right.
await reactor.sendCommand("set_pointer", { x: 0.8, y: 0.5, active: true });

// Release: the pointer stops steering.
await reactor.sendCommand("set_pointer", { x: 0.8, y: 0.5, active: false });
const { setPointer } = useX2();
await setPointer({ x: 0.8, y: 0.5, active: true });
await setPointer({ active: false });
await reactor.send_command("set_pointer", {"x": 0.8, "y": 0.5, "active": True})
await reactor.send_command("set_pointer", {"active": False})
Because the model samples the pointer once per block, sending positions faster than about 30 Hz buys nothing. Throttle pointer-move events, and use a trailing send so the final position of a fast gesture still lands. Always send active: false on release (and on unmount) so the model does not keep steering toward a stale point.

set_pointer_x, set_pointer_y, set_pointer_active

Single-field variants of set_pointer, for updating one pointer field at a time. Same semantics: sampled once per block, effective only while the pointer is active. Prefer set_pointer when you have all three values; the position and press state then change in one step. Parameters (one per command):
CommandParameterTypeDescription
set_pointer_xpointer_xnumberHorizontal position, 0 to 1. Default 0.5
set_pointer_ypointer_ynumberVertical position, 0 to 1. Default 0.5
set_pointer_activepointer_activebooleanWhether a drag is in progress. Default false
await reactor.sendCommand("set_pointer_x", { pointer_x: 0.25 });
await reactor.sendCommand("set_pointer_active", { pointer_active: true });
const { setPointerX, setPointerActive } = useX2();
await setPointerX({ pointer_x: 0.25 });
await setPointerActive({ pointer_active: true });
await reactor.send_command("set_pointer_x", {"pointer_x": 0.25})
await reactor.send_command("set_pointer_active", {"pointer_active": True})

set_keep_backlog

Pick the source-frame consumption policy, applied from the next block:
  • false (default): newest frames. The model always reads the most recent source frames and drops any backlog, keeping latency bounded when inference runs slower than the source. Right for live sources like a webcam, where the edit should track “now”.
  • true: every frame. The model consumes every source frame in order, for smoother motion at the cost of a growing delay. Right for pre-recorded clips and for drag-to-animate, where smoothness matters more than latency.
Emits state_update. reset does not clear this setting, unlike the prompt, reference image, and pointer: it persists until the session ends, so re-sync any UI toggle to the default on disconnect rather than on reset. Parameters:
ParameterTypeRequiredDescription
keep_backlogbooleanNotrue = every frame in order. Default false
await reactor.sendCommand("set_keep_backlog", { keep_backlog: true });
const { setKeepBacklog } = useX2();
await setKeepBacklog({ keep_backlog: true });
await reactor.send_command("set_keep_backlog", {"keep_backlog": True})

reset

Stop generation and clear the prompt, reference image, and pointer, returning the session to waiting for new conditions. Valid at any time. Emits generation_stopped with reason reset if a run was active, and state_update. Takes no arguments. Note two things reset leaves alone: the output resolution (chosen once per session, at the first generation) and the keep_backlog policy (persists until the session ends).
await reactor.sendCommand("reset", {});
const { reset } = useX2();
await reset();
await reactor.send_command("reset", {})

Messages

X2 emits the following messages, each arriving as JSON { "type": "<name>", "data": { … } }.
EventWhenPayload
prompt_acceptedAfter set_prompt{ prompt: string }
reference_image_acceptedAfter set_reference_image decodes{ width: int, height: int }
pointer_changedAfter set_pointer{ x: number, y: number, active: bool }
generation_startedWhen a run begins producing main_video{ prompt: string, width: int, height: int, has_reference_image: bool }
generation_stoppedWhen a run ends{ reason: "reset" | "reference_image_changed" }
command_errorWhen a command is rejected{ command: string, reason: string }
state_updateOn connect and after every observable changeFull session snapshot (see below)
A generation_stopped with reason reference_image_changed is an automatic restart: a fresh generation_started follows at once, so treat it as a hiccup rather than an end state. Only reason reset means the session is back to waiting for conditions; drive any “clear the drafts” UI off that reason, not off every stop.

state_update payload

state_update is the single source of truth for driving UI. It arrives on connect and after every observable state change, always carrying the full state. Subscribe once and reduce it into your app state; the only fact it does not carry is the decoded reference image’s dimensions, which come from the discrete reference_image_accepted ack.
FieldTypeMeaning
promptstring | nullThe active editing instruction, or null when none is set
has_reference_imageboolWhether a reference image is conditioning generation
pointer_xnumberCurrent horizontal pointer position, 0 to 1
pointer_ynumberCurrent vertical pointer position, 0 to 1
pointer_activeboolWhether a drag is steering the edited subject
keep_backlogboolCurrent source-frame policy (see set_keep_backlog)
generatingboolWhether a run is producing main_video frames
widthint | nullOutput frame width, or null before the resolution is chosen
heightint | nullOutput frame height, or null before the resolution is chosen
Example handler:
reactor.on("message", (msg) => {
  switch (msg.type) {
    case "state_update":
      console.log(`generating: ${msg.data.generating} · prompt: ${msg.data.prompt}`);
      break;
    case "generation_started":
      console.log(`output: ${msg.data.width}x${msg.data.height}`);
      break;
    case "command_error":
      console.error(`${msg.data.command} rejected: ${msg.data.reason}`);
      break;
  }
});
import { useX2StateUpdate, useX2CommandError } from "@reactor-models/x2";

function StateReadout() {
  useX2StateUpdate((s) => console.log(`generating: ${s.generating} · prompt: ${s.prompt}`));
  useX2CommandError((e) => console.error(`${e.command} rejected: ${e.reason}`));
  return null;
}
@reactor.on_message
def handle(msg):
    data = msg["data"]
    if msg["type"] == "state_update":
        print(f"generating: {data['generating']} · prompt: {data['prompt']}")
    elif msg["type"] == "command_error":
        print(f"{data['command']} rejected: {data['reason']}")

Complete example

Publish a webcam source, set a reference image and prompt, then steer the subject with a short drag.
import { X2Model } from "@reactor-models/x2";

const x2 = new X2Model();

x2.onGenerationStarted(({ width, height, has_reference_image }) => {
  console.log(`run started at ${width}x${height}, reference: ${has_reference_image}`);
});
x2.onCommandError(({ command, reason }) => {
  console.error(`${command} rejected: ${reason}`);
});
x2.onMainVideo((track, stream) => {
  video.srcObject = stream;
  void video.play();
});

await x2.connect(jwt);

// Publish the stream to edit.
const media = await navigator.mediaDevices.getUserMedia({ video: true });
await x2.publishSource(media.getVideoTracks()[0]);

// Anchor the swap to a reference image, then arm generation with a prompt.
const ref = await x2.uploadFile(characterImageFile);
await x2.setReferenceImage({ reference_image: ref });
await x2.setPrompt({
  prompt: "replace the character in the video with the character from the reference image",
});
// No start command: generation begins now that a prompt is set and frames are flowing.

// Steer the subject with a short drag to the right.
await x2.setPointer({ x: 0.5, y: 0.5, active: true });
await x2.setPointer({ x: 0.8, y: 0.5, active: true });
await x2.setPointer({ x: 0.8, y: 0.5, active: false });
import asyncio, os
from reactor_sdk import Reactor

async def main():
    reactor = Reactor(model_name="xmax/x2", api_key=os.environ["REACTOR_API_KEY"])

    @reactor.on_message("generation_started")
    def _(msg):
        d = msg["data"]
        print(f"run started at {d['width']}x{d['height']}")

    await reactor.connect()

    ref = await reactor.upload_file("character.jpg")
    await reactor.send_command("set_reference_image", {"reference_image": ref})
    await reactor.send_command("set_prompt", {
        "prompt": "replace the character in the video with the character from the reference image",
    })

    # Steer with a short drag.
    await reactor.send_command("set_pointer", {"x": 0.5, "y": 0.5, "active": True})
    await reactor.send_command("set_pointer", {"x": 0.8, "y": 0.5, "active": True})
    await reactor.send_command("set_pointer", {"x": 0.8, "y": 0.5, "active": False})
    await asyncio.Event().wait()

asyncio.run(main())