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 anX-Trax-Clientheader. Send a current epoch-seconds value; without it the upgrade can be rejected with426in environments where the gate is enforcing. (The/monitorand/contributeendpoints 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 requireedit; reads requireview.
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": { /* … */ } } }
requestIdis 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.errorCodeis a stable machine token for selected failures (e.g.subscription_required); fall back toerrorfor 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
*_changedevent 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 optionalbaseRev— 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 withapplied: falseand 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_presetis last-write-wins (a discrete user action, not the high-frequency drag path), so it is not rev-gated and its reply always carriesapplied: 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
- Build a client — the transport map, native OAuth + PKCE, and the studio-control WebSocket.
- Monitor the program output — play the program your canvas produces, over WHEP.
- Contribute a camera from a phone (SRT) — feed a camera into the studio you are controlling.
- Stream paths — the path grammar shared by every media transport.