SurfaceCast writes plain, indented JSON. Files are written to a temporary name and then renamed into place, so an interrupted save cannot truncate an existing scene.
{
"format": "surfacecast.scene",
"version": 1,
"app_version": "0.1.0",
"id": "5f1c2a90b3d4",
"name": "Main stage",
"background": "#000000ff",
"canvas": [1920, 1080],
"notes": "",
"duration": 0,
"transition": "cut",
"transition_seconds": 1.0,
"lights": {
"mode": "hold",
"intensity": 0.7,
"colors": ["#204080"]
},
"objects": []
}
objects is ordered top first: the first entry is composited over the rest.
duration is how many seconds the show timer holds this scene before moving
on. Zero - the default, and what a file written before this field existed reads
as - means hold here until the next scene is taken by hand. Values are clamped
to 12 hours on load.
transition is how the scene arrives on the output when it is taken: "cut"
(the default, and what an older file reads as) or "fade", which
cross-dissolves from whatever was on air. transition_seconds is how long that
fade takes, clamped to 0.1-30 seconds on load. It is kept even while the
transition is a cut, so switching back to a fade does not lose the time.
Anything else in transition reads as a cut rather than failing the load.
lights is what this scene tells the practical lights to do, and it is
omitted entirely unless the scene says something - so a show that never
touches lights writes exactly the file it wrote before this field existed, and
a file without it reads as "follow".
mode is "follow" (take the colors off the picture, the default),
"hold" (sit on the colors below), or "off" (dark for this scene).
Anything else reads as "follow".intensity is this scene's own level, multiplied on top of each zone's gain.
Clamped to 0-2 on load.colors are the held colors as #rrggbb, one per zone, and are only read
in "hold". One entry means the whole rig, which is what survives moving
to a venue whose rig has a different number of zones; past the end of a
longer list the last color carries on. An empty list holds dark.The zones themselves are deliberately not in here. Which part of the canvas
drives which LEDs describes where the lamps physically are, so it lives with
the machine (settings.json) and stays behind when a show travels. This is the
same split a lighting desk makes between the patch and the cue.
Unknown object types are skipped on load rather than failing the whole file, so
a scene written by a later version still opens. A file whose version is higher
than the running build is refused with an explanatory message.
A show bundles up to five scenes:
{
"format": "surfacecast.show",
"version": 1,
"active_slot": 0,
"slots": [ { "slot": 0, "scene": { "...": "a scene document" } } ]
}
The recovery file written by autosave is a show document with one extra key, which loaders ignore:
{
"recovery": {
"saved_at": 1758153600.0,
"source": "C:\\shows\\gala.json",
"scene_names": ["Walk-in", "Act one"]
}
}
saved_at is a Unix timestamp, source the file the show came from (empty if
it has never been saved), and scene_names is there so the recovery dialog can
describe the file without loading the scenes and their media. Because the rest
of the document is an ordinary show, the file opens with File > Open show
whatever happens to the dialog.
| Field | Type | Meaning |
|---|---|---|
type |
string | video, image, mesh_image, mesh_video, gradient or mask |
id |
string | Unique within the scene |
name |
string | Shown in the layer list |
visible |
bool | Drawn or not |
locked |
bool | Ignored by clicks and drags in the preview |
opacity |
0.0-1.0 | Layer opacity |
transform |
object | Placement, see below |
{
"rect": [0.25, 0.25, 0.5, 0.5],
"rotation": 12.5,
"corner_offsets": [[0.01, 0.0], [0.0, 0.0], [0.0, -0.02], [0.0, 0.0]]
}
rect is [x, y, width, height] as a share of the canvas, not pixels. A
scene authored at 1920x1080 therefore lines up at any other resolution.rotation is in degrees, clockwise, about the rectangle's center. It is
applied in pixel space, so a rotated object is not sheared by the canvas
aspect ratio.corner_offsets is the keystone correction: four [dx, dy] pairs in
normalized units for the top-left, top-right, bottom-right and bottom-left
corners, applied after rotation. The key is omitted entirely when there is no
warp.The final quad is rotate(rect) + corner_offsets, which is why the numeric
rectangle fields keep working after a corner pin.
video| Field | Type | Default |
|---|---|---|
path |
string | "" |
fit |
contain | cover | stretch |
cover |
loop |
bool | true |
autoplay |
bool | true |
restart_on_live |
bool | true |
muted |
bool | false |
volume |
0.0-1.0 | 1.0 |
rate |
0.1-4.0 | 1.0 |
in_point, out_point |
seconds | 0.0 (out_point of 0 means "to the end") |
audio_device |
string | "" (system default) |
image| Field | Type | Default |
|---|---|---|
path |
string | "" |
fit |
contain | cover | stretch |
contain |
smooth |
bool | true |
flip_h, flip_v |
bool | false |
gradient| Field | Type | Default |
|---|---|---|
gradient_type |
solid | linear | radial | conical |
linear |
stops |
list of [position, color] |
white to black |
angle |
degrees | 0 |
center |
[x, y], 0-1 within the object |
[0.5, 0.5] |
radius |
0.01-4.0 | 0.5 |
mask| Field | Type | Default |
|---|---|---|
shape |
rectangle | ellipse | polygon |
rectangle |
points |
list of [x, y], 0-1 within the object |
[] |
feather |
0.0-1.0 | 0.0 |
invert |
bool | false |
color |
#RRGGBBAA |
#000000ff |
corner_radius |
0.0-1.0 | 0.0 |
An inverted mask covers the whole canvas except its shape, so its rectangle positions the hole rather than bounding the cover.
Colors are #RRGGBBAA. #RGB and #RRGGBB are accepted on load and
normalized to eight digits on save.
Every value is validated on load. Numbers outside their range are clamped rather than rejected, unknown enum values fall back to the default, malformed colors fall back to black, and a gradient with fewer than two stops is repaired. A corrupt field costs you that field, not the scene.
A mesh_image is an image with one extra field, and a mesh_video is a
video with two - the same mesh, plus a smooth flag, because turning
smooth sampling off is the one reliable way to buy headroom when a machine
cannot keep up with warping every frame. It carries every image field
(path, fit, smooth, flip_h, flip_v) plus:
"mesh": {
"columns": 2,
"rows": 2,
"offsets": [[0, 0], [0, 0], [0, 0], [0, 0], [0.04, -0.02], [0, 0], [0, 0], [0, 0], [0, 0]]
}
columns and rows count cells, so the grid holds
(rows + 1) * (columns + 1) points and offsets is that many [dx, dy] pairs,
row-major from the top left. Both counts are clamped to 1-16 on load, and the
offset list is padded or trimmed to match rather than failing the file.
Each offset is a fraction of the object's own size, not of the canvas -
unlike transform.corner_offsets, which are a fraction of the canvas. That is
why a mesh keeps its shape when the object is resized, and why converting
between the two needs to know how big the object is and which way it is turned.
A one-cell mesh is exactly a corner pin. For a mesh object the transform's own
corner_offsets are left at zero: the grid owns the warp, and applying both
would warp the object twice.
A build without mesh support skips a mesh_image layer on load, the same as
any other unknown type - the rest of the scene opens normally.
A mesh_video carries every video field unchanged (path, fit, loop,
autoplay, restart_on_live, muted, volume, rate, in_point,
out_point, audio_device) and adds:
"mesh": { "columns": 2, "rows": 2, "offsets": [] },
"smooth": true
Converting between video and mesh_video keeps all of it, in both
directions.