guides

Drive the studio canvas

The studio's canvas — which sources are on the program, where their tiles sit, which preset or scene is active — is control-plane state, not media. A native or desktop app drives it exactly the way the web studio does: over a single WebSocket to the studio control plane, sending mutation RPCs and receiving the state changes back as realtime events.

This guide is the "here's how you drive it" walkthrough for the WebSocket path. If you are building a third-party integration, or an app that only needs to commit changes rather than stream drag frames, the REST equivalent is Control the canvas over /v1 — same authority, same geometry, one call per change and no socket to hold open. The connection and auth are the same studio WebSocket introduced in Build a client → Controlling a studio; this page adds the canvas-specific RPCs, their payloads, and the sync model.

The model in one paragraph

Every canvas edit is a request/reply RPC you send on the socket; every edit (yours or a collaborator's) persists server-side and fans out to the other connections as a typed *_changed event. There is no separate "canvas protocol" — the studio owns the state, you mutate it, and the studio streams the new truth to every tab and device. Two layout modes decide whether the server resolves geometry for you (auto) or honors the explicit rectangles you send (advanced).

Connect to the studio WebSocket

wss://studio-api-dev.traxstreaming.live/api/v1/ws/studios/{studioId}/events
  • Auth. A native app sets Authorization: Bearer <jwt> on the upgrade request (the user's OAuth token). Where headers are impossible, the token is also accepted as a ?token=<jwt> query parameter.
  • Client stamp. The events socket is version-gated. Add a ?client=<epoch-seconds> query parameter (a build stamp) to the connect URL — a native app may instead send it as an X-Trax-Client header. Send a current epoch-seconds value; without it the upgrade can be rejected with 426 in environments where the gate is enforcing. (The /monitor and /contribute endpoints are exempt from this gate; the events socket is not.)
  • Access is resolved once at upgrade against your membership of that studio, then each RPC is gated by your role (view < edit < admin). Canvas mutations require edit; reads require view.

On connect the server sends a hello frame carrying your connection id:

{ "type": "hello", "data": { "id": "<connectionId>", "studioId": "<studioId>" } }

Frame shapes

All frames are JSON. Three shapes travel this socket:

// 1. RPC request  (client → server)
{ "type": "toggle_source", "requestId": "<uuid>", "data": { /* payload */ } }

// 2. RPC reply    (server → client, correlated by requestId)
{ "type": "reply", "requestId": "<uuid>", "ok": true,  "result": { /* … */ } }
{ "type": "reply", "requestId": "<uuid>", "ok": false, "error": "…", "errorCode": "…" }

// 3. Realtime event (server → client, no requestId)
{ "type": "source_changed", "data": { "action": "updated", "id": "…", "source": { /* … */ } } }
  • requestId is a client-generated correlation token; match the reply by it.
  • Errors come back as a reply with ok: false — the RPC is never silently dropped. errorCode is a stable machine token for selected failures (e.g. subscription_required); fall back to error for the human message.

Seed the current state, then subscribe

The hello frame does not carry the canvas. Fetch the current state over REST once, render it, then keep it live from the socket's events:

Read Endpoint
Source list (tiles, mixer rows) GET /api/v1/sources?studioId={studioId}
Layout (mode, preset, per-source geometry, tile order) GET /api/v1/studios/{studioId}/layout

Both carry Authorization: Bearer <jwt>. After that first paint, apply every source_changed / layout_changed / scene_changed event to your local model — never re-poll.

Send mutations, receive events — the sync rule

You are both a sender of mutations and a receiver of everyone's changes, including your own studio's other devices. Two properties make this converge:

  • Your own echo is dropped. The connection that made a change does not receive the *_changed event for it — you already have the RPC reply. So apply your change optimistically from the reply; the fan-out is for other connections.
  • Last-write-wins, guarded by a revision. The layout row carries a monotonic layoutRev. Geometry writes (update_layout, update_source_transform) accept an optional baseRev — the rev your edit was based on. If it is older than the current row the write is rejected as stale: the reply comes back with applied: false and the current row, and no event is fanned out. Rebase your pending edit onto the returned rev and retry instead of clobbering a newer state. set_layout_preset is last-write-wins (a discrete user action, not the high-frequency drag path), so it is not rev-gated and its reply always carries applied: true — the flag is there for a uniform result shape.

The realtime topics you will consume for the canvas:

Event type Fires when
source_changed a source was created / updated / deleted (payload {action, id, source})
layout_changed the layout row changed — geometry, preset, tile order, mode (payload {layout})
scene_changed a scene was created / updated / deleted / flipped active (payload {id, scene})
studio_state_changed live state, mode, master audio (payload {state})

Two layout modes: auto vs advanced

layout.layoutMode is either "auto" or "advanced", and it decides who owns the geometry:

  • auto (default). The server resolves each tile's rectangle from the active preset and the source set. You express intent — which preset, which source is focused, the slot order — and the studio computes the pixels. Explicit rects you send are overridden by the planner. This is the mode to use for "just make it look right"; it is authoritative while it is on.
  • advanced. You own the geometry. Each source's rectangle is whatever you last wrote (update_source_transform / sourceLayouts), and the planner does not touch it.

The mode flips atomically with the geometry in a single write: send layoutMode on the same update_layout or set_layout_preset call that carries your rects, so there is never a window where advanced geometry sits under auto mode waiting to be re-planned. In short: send explicit rectangles only when you are (or are moving to) advanced; in auto, drive presets and focus.

Driving the canvas — the key RPCs

All of these are edit-gated. Payloads below are the data object of the RPC frame. For every layout write — update_layout, update_source_transform, and set_layout_preset — the reply result is the StudioLayout row inlined at the top level plus an applied boolean, e.g.:

{ "applied": true, "activePreset": "2up", "layoutMode": "auto",
  "sourceLayouts": { … }, "slotAssignments": { … },
  "focusSourceId": "…", "layoutRev": 42, "updatedAt": "…" /* all row fields */ }

It is not nested under a layout key. Source writes return { source, … }.

Add, remove, and activate sources

// add_source — create a source (provisions its ingest). result: {source, …}
{ "type": "add_source", "requestId": "…", "data": { /* CreateSourceInput */ } }

// remove_source — delete a source
{ "type": "remove_source", "requestId": "…", "data": { "sourceId": "src-…" } }

// toggle_source — put a source on the program / take it off (activate/deactivate)
{ "type": "toggle_source", "requestId": "…",
  "data": { "sourceId": "src-…", "isActive": true } }

Move / resize a tile (advanced mode)

update_source_transform writes one source's explicit rectangle. Honored in advanced mode; in auto the planner will re-resolve it.

{ "type": "update_source_transform", "requestId": "…", "data": {
    "sourceId": "src-…",
    "x": 0, "y": 0, "width": 1280, "height": 720,   // integer canvas coords
    "zIndex": 1,
    "alpha": 1.0,
    "fitMode": "contain",                            // optional; omit to preserve
    "isVisible": true,                               // optional
    "baseRev": 42                                    // optional stale-write guard
} }

For a live drag you can stream ephemeral live_source_transform frames (no requestId, no reply, not persisted) at up to ~30 Hz for real-time motion, then commit the final rectangle with one update_source_transform.

Switch preset / focus (auto mode)

// set_layout_preset — pick the active preset (and optionally mode/focus/order)
{ "type": "set_layout_preset", "requestId": "…", "data": {
    "layoutMode": "auto",
    "activePreset": "2up",          // a preset id — see discovery below
    "focusSourceId": "src-…",       // optional — promote one source to slot 1
    "traySourceIds": ["src-…"],     // optional — the "up next" overflow tray
    "tileOrder": ["src-…", "src-…"] // optional — slot order intent
} }

// set_focus — promote a single source to the focus slot
{ "type": "set_focus", "requestId": "…", "data": { "sourceId": "src-…" } }

// swap_in_from_tray — bring a tray source onto the canvas
{ "type": "swap_in_from_tray", "requestId": "…", "data": { "sourceId": "src-…" } }

Preset ids — discover them, don't hardcode. GET /api/v1/studios/{id}/layout/capabilities returns the authoritative catalog for this studio:

{
  "presets": [
    { "id": "solo", "displayName": "Solo", "slotCount": 1,
      "slots": [ { "index": 0, "x": 0, "y": 0, "w": 1, "h": 1 } ],
      "overflowToTray": true, "schematicSvg": "<svg …>" }
    // …one entry per preset
  ],
  "libraryVersion": "1"
}

The activePreset you send is a preset id from this list. Each entry also carries a displayName, normalized slots geometry, and a schematicSvg you can render as a picker thumbnail, so a switcher can be fully data-driven; cache on libraryVersion. The current built-in ids are solo (default), 2up, 3up, 4up, spotlight_strip, pip, and screen_speaker — there is no grid. The endpoint is proxied from the encoder; on a degraded encoder it returns { "presets": [], "libraryVersion": "" }, so fall back to the built-in ids or retry.

Partial layout writes

update_layout is a partial patch of the layout row — every field is optional, and nil leaves the stored value untouched. Use it to set sourceLayouts wholesale, flip layoutMode, change aspectRatio/fitMode/tileOrder, etc.:

{ "type": "update_layout", "requestId": "…", "data": {
    "layoutMode": "advanced",
    "sourceLayouts": { "src-…": { "x": 0, "y": 0, "width": 1920, "height": 1080 } },
    "baseRev": 42
} }

Scenes (save & recall a look)

// set_active_scene — restore a saved scene's canvas to the live program
{ "type": "set_active_scene", "requestId": "…", "data": { "sceneId": "scn-…" } }

// capture_scene — re-snapshot the current canvas into an existing scene
{ "type": "capture_scene", "requestId": "…", "data": { "sceneId": "scn-…" } }

// create_scene — new scene row
{ "type": "create_scene", "requestId": "…", "data": { /* CreateSceneInput */ } }

See what you are building

To watch the composited result of the canvas you are driving, open a program monitor — the WHEP play of the same studio's program output. See Monitor the program output.

The full RPC catalog

This guide covers the canvas surface. The studio WebSocket exposes many more RPCs (destinations, go-live, chat, presence, rundown, intercom, recording…) and the full set of realtime events. The exhaustive, generated method and event reference is first-party/internal today: if you are a logged-in first-party developer, see the internal Studio WS API reference (RPCs + events), which is generated directly from the server's dispatch table. Third-party apps reach the platform through the public /v1 REST gateway and this WebSocket — never internal service endpoints directly.

Where to go next

  1. Build a client — the transport map, native OAuth + PKCE, and the studio-control WebSocket.
  2. Monitor the program output — play the program your canvas produces, over WHEP.
  3. Contribute a camera from a phone (SRT) — feed a camera into the studio you are controlling.
  4. Stream paths — the path grammar shared by every media transport.