Control the canvas over /v1
You are holding a phone. The show is on a laptop in another room, or on an encoder in a rack, and you want to move a tile — or put the wide shot back, or cut to the scene you saved before the show. This guide is that: read the canvas, change one thing, watch it land.
Eight endpoints, all under /v1/studios/{id}, all reaching the same control
plane the studio's own canvas uses. A tile you move here and a tile the operator
drags on the laptop are the same write to the same authority, which is why the
two cannot end up disagreeing about where it is.
Which host. These endpoints are on dev today:
https://api-dev.traxstreaming.live/v1with a dev key. This page will say so when they reach production.
Scopes.
canvas:readfor the two reads,canvas:writefor everything that changes something. Both are opt-in — a key minted without naming them has neither, andstudios:writedoes not confercanvas:write. Renaming a studio and moving what viewers are looking at are different powers, and only one of them is visible on air the instant it lands.
The model in one paragraph
A studio has a canvas: a fixed 1920×1080 coordinate space with one
placement per source — a rectangle, a stacking order, an opacity, a crop.
Two layout modes decide who owns those rectangles. In auto the server's
planner resolves them from the active preset and the current source set; you
express intent and it computes pixels. In advanced you own them and the
planner keeps its hands off. Either way the rectangles are stored in one place,
so what you read is what the encoder is compositing.
Read the canvas
GET /v1/studios/{studioId}/canvas
Authorization: Bearer sk_live_...
{
"layoutMode": "auto",
"activePreset": "2up",
"activeSceneId": "",
"canvasWidth": 1920, // the COORDINATE SPACE rectangles live in
"canvasHeight": 1080,
"aspectRatio": "16/9",
"fitMode": "contain",
"focusSourceId": "src-camera",
"tileOrder": ["src-camera", "src-screen"],
"traySourceIds": [],
"output": { "width": 1920, "height": 1080, "fpsNum": 60, "fpsDen": 1 },
"layoutRev": 42,
"membershipBasis": "resolved",
"placements": [
{
"sourceId": "src-camera",
"name": "Main camera",
"type": "webcam",
"active": true,
"onCanvas": true,
"inTray": false,
"rect": { "x": 0, "y": 270, "width": 960, "height": 540 },
"zIndex": 0,
"alpha": 1,
"visible": true
}
// …one row per source in the studio
]
}
One call, not two. A tile is a source and a rectangle; if you fetched the layout and the source list separately you would have two answers taken at two moments, and the miniature you drew from them could show a tile that was deleted between the requests.
canvasWidth/canvasHeight is not the broadcast resolution. Rectangles are
in canvas units — always a 1920×1080 space — while output is what the encoder
actually produces. A 720p60 program still composites on a 1920×1080 canvas.
Scale your miniature by the canvas; label the stream by output.
Drawing the miniature
placements comes back in paint order: placed tiles first, back to front
(z ascending, source id breaking ties), then the sources that have never been
placed. Walk it in order and draw each rect scaled by
yourWidth / canvasWidth. Three fields decide what a row means:
| Field | Means |
|---|---|
onCanvas |
The canvas is compositing this source right now. Draw it. |
inTray |
Active and eligible, but it did not get a seat. "Up next", not on air. |
no rect |
Not placed. There is no rectangle — do not draw a zero one. |
onCanvas is resolved per layout mode, from the same state the encoder is
driven from, so you never have to work it out yourself:
- auto — the source holds a seat in the server planner's current
resolution of
activePreset. A source the preset displaced comes backonCanvas: falseand with norect, even though the underlying layout row still holds the rectangle it had under the previous preset. - advanced — the source has a stored, visible, non-degenerate rectangle.
Do not derive membership from traySourceIds. That list is the auto
planner's overflow intent. Nothing maintains it in advanced mode, so a tile you
placed by hand reads onCanvas: true while still appearing in it — that is
correct, not a contradiction. It ships on the read because
POST /canvas/preset takes it as a whole-set replacement and you need to be
able to send back what you read; it is not a membership signal.
A source that is inactive is neither onCanvas nor inTray. It is an input
that exists. Saying it is in the tray would imply it is one action from air when
it is two.
How sure is that answer?
membershipBasis on the canvas tells you where onCanvas/inTray came from:
| Value | Means |
|---|---|
resolved |
The normal, authoritative answer. Trust it. |
stored |
The auto planner could not resolve this activePreset — the encoder ships the preset catalog independently, so a studio can hold one this server does not know. Membership fell back to persisted columns and may be stale. Render the canvas, but do not conclude a source is off air. |
"" |
An older server. It makes no claim; do not read it as either value. |
ownerSub, when present, is the person a device source belongs to (a browser
camera, a shared screen, a phone leg). Group rows by it if you want to fold one
person's several legs into one tile the way the studio does.
There are no thumbnail images here. The canvas read is geometry and identity; to see the actual picture, open a program monitor — Monitor the program output.
Polling cheaply
The read is conditional. Keep the ETag and send it back:
GET /v1/studios/{studioId}/canvas
If-None-Match: "a3f9…"
An unchanged canvas answers 304 with no body. The tag covers the whole
snapshot, not just layoutRev, so a source renamed or deleted invalidates it
too — a 304 always means your picture is still correct.
Move a tile
PATCH /v1/studios/{studioId}/canvas/placements/{sourceId}
Content-Type: application/json
{
"rect": { "x": 100, "y": 50, "width": 800, "height": 450 },
"baseRev": 42
}
Every field is optional and omitted means preserve. That is what makes this legal:
{ "zIndex": 3 } // bring it forward. Rect, alpha, crop all untouched.
{ "visible": false } // hide it, keeping its rectangle so it does not jump back
{ "cropZoom": 1.8, "cropPanX": 0.4, "cropPanY": 0.5 } // punch in
The one exception: a source that has never been placed has nothing to
preserve, so its first write must carry a rect. You will get a 400 that says
so.
This flips the studio to advanced. Per-source geometry is the mode: the
server writes it alongside the rectangle, atomically, because a rectangle
sitting under auto is geometry the next re-plan is about to erase. If you
wanted to nudge one tile inside an auto arrangement, you wanted a preset — or
this, then a replan to hand it back.
Units, and what happens if you get them wrong:
| Field | Range | Out of range |
|---|---|---|
rect |
canvas units; must intersect the canvas; positive, no larger than it | 400 |
zIndex |
-10000 … 10000, higher draws in front | 400 |
alpha |
0.0 … 1.0 | 400 |
cropZoom |
1.0 (none) … 10.0 | 400 |
cropPanX / cropPanY |
0.0 … 1.0, the visible-rect centre | 400 |
fitMode |
contain or stretch |
400 |
These are refused, never clamped. If you sent a pan as a percentage, a 400 is the only version of this exchange where you find out.
Editing next to a live operator
layoutRev is a revision that every canvas write bumps. Send back the one you
read as baseRev and a write based on a revision the canvas has already passed
comes back 409 instead of clobbering. Re-read, rebase, retry.
Omit baseRev and you get last-write-wins. A phone editing while someone else
is at the desk should send it; a script making one considered change need not.
Retrying on a bad connection
A retried identical write is not a conflict. If your request lands and the
response never arrives — which on a train is most of them — send the same
request again with the same baseRev. A stale baseRev whose request
describes the tile exactly as it already is comes back 200, because the
write it asks for is already in effect.
That exception is narrow on purpose. A different write on the same stale
baseRev is still a 409. So: retry until you get an answer, and treat a 409 as
what it is — someone else moved something.
Every 503 carries Retry-After. Back off on it rather than hammering.
Pick a preset
Usually the better call from a phone. You name the arrangement; the server resolves the pixels — and keeps resolving them as guests join and leave, which six hand-placed rectangles will not.
POST /v1/studios/{studioId}/canvas/preset
{ "presetId": "2up", "focusSourceId": "src-camera" }
Discover the ids, do not hardcode them:
GET /v1/studios/{studioId}/canvas/capabilities
{
"presets": [
{ "id": "solo", "displayName": "Solo", "slotCount": 1,
"slots": [ { "index": 0, "x": 0, "y": 0, "w": 1, "h": 1 } ],
"overflowToTray": true, "schematicSvg": "<svg …>" }
],
"libraryVersion": "1",
"layoutModes": ["auto", "advanced"],
"fitModes": ["contain", "stretch"],
"transitions": ["cut", "fade", "slide_left", "…"],
"canvasWidth": 1920, "canvasHeight": 1080
}
The catalog belongs to the encoder, not to this API — ids come and go with
encoder releases, and a client with a baked-in list quietly loses the ones it
never heard of. Cache on libraryVersion and refetch when it changes.
slots are normalized 0–1 fractions, so schematicSvg (or the slots
themselves) draws as a picker thumbnail at any size.
An empty presets with an empty libraryVersion means the encoder could
not be reached. That is a state to render — fall back to what you cached, or
hide the picker — not an error to retry into. The vocabularies below it never
degrade; they are the gateway's own.
tileOrder, traySourceIds and slotAssignments are whole-set replacements
when you send them, and untouched when you do not. Send the complete list; a
partial one silently drops what it omits.
Which means read before you write. GET /canvas always returns
slotAssignments — never null, and an empty object means "no sticky seats
are assigned", a real state rather than a field that failed to populate. Take
that map, change the entries you mean, and send the whole thing back:
GET /v1/studios/{id}/canvas -> { "slotAssignments": { "2up": { "0": "src-a" } }, ... }
POST /v1/studios/{id}/canvas/preset <- { "presetId": "2up",
"slotAssignments": { "2up": { "0": "src-a", "1": "src-b" } } }
Sending {"2up": {"1": "src-b"}} there does not add a seat — it replaces the
map and unseats src-a.
Preset selection is last-write-wins and takes no baseRev. Picking a preset is
a discrete human action taken a few times a show — two people a second apart
should land on the second one, not on a conflict.
Switch scenes
A scene is a snapshot of the canvas an operator captured: the rectangles, the mode, and which sources were up.
GET /v1/studios/{studioId}/scenes
POST /v1/studios/{studioId}/scenes/active { "sceneId": "scn-wide" }
{ "data": [
{ "id": "scn-wide", "name": "Wide", "isActive": true,
"transitionType": "cut", "transitionDuration": 0, "hasComposition": true }
] }
hasComposition: false means the scene is a label with nothing saved behind it
— recalling it changes nothing. Grey the row rather than offering a recall that
silently does nothing.
Recalling a scene on a live studio changes what viewers see, immediately. There is no preview bus here to rehearse on. Scene rows carry an is-preview flag, but no second canvas is composited from it, so a "preview" on this API would name a rehearsal that does not happen. Program is the only destination a scene has.
The recall returns the resulting canvas, so you can render straight from the response.
Go back to auto
POST /v1/studios/{studioId}/canvas/replan { "layoutMode": "auto" }
The way out of advanced mode: the row flips and the planner re-runs in one call,
replacing hand-placed rectangles with its own. Sent without a mode against a
studio that is already in auto, it is a pure re-resolve.
Sent without a mode against an advanced studio it does nothing, and says so by returning the unchanged canvas. That is deliberate: the planner does not own geometry in advanced mode, and quietly re-planning a studio whose operator took manual control would move tiles on air that nobody asked to move.
You will rarely need it. A guest joining, a camera going live, a preset being
picked — all of those re-plan on their own. replan is for the case where your
own edits have wandered and the honest fix is to hand the arrangement back.
What is not here: dragging
There is no REST call for a drag, and there should not be one. Every endpoint on this page persists to the database, re-asserts the geometry to a running encoder, and fans an event out to every open studio tab. That is the right cost for a committed move and completely the wrong cost thirty times a second.
If you are building a surface where someone drags a tile with a finger, do it
the way the web studio does: move the tile locally at whatever frame rate you
like, and commit the final rectangle once with a single
PATCH …/canvas/placements/{sourceId}. If you need the intermediate frames to
appear on the program in real time — a hardware controller, a live nudge — that
is the studio WebSocket's ephemeral transform frame, described in
Drive the studio canvas, which persists nothing and
acknowledges nothing by design.
A whole session
Read, move one tile, put it back the way the studio would have it:
BASE=https://api-dev.traxstreaming.live/v1
STUDIO=…; KEY=sk_live_…
# 1. What does it look like?
curl -s -H "Authorization: Bearer $KEY" "$BASE/studios/$STUDIO/canvas"
# 2. Move the camera tile, guarded by the rev you just read.
curl -s -X PATCH -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
"$BASE/studios/$STUDIO/canvas/placements/src-camera" \
-d '{"rect":{"x":0,"y":0,"width":1920,"height":1080},"baseRev":42}'
# 3. Changed your mind — hand it back to the planner.
curl -s -X POST -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
"$BASE/studios/$STUDIO/canvas/replan" -d '{"layoutMode":"auto"}'
Each of those returns the whole canvas, so you never have to re-read to find out what your change did.
Where to go next
- Monitor the program output — see the picture the canvas you are driving actually produces.
- Control the mixer from your app — the audio half of the same surface.
- Drive the studio canvas — the WebSocket path, for a first-party app that needs live drag frames and push updates.
- The /v1 reference — every endpoint, every scope.