SurfaceCast

Scene file format

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.

Scene document

{
  "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.

Show document

A show bundles up to five scenes:

{
  "format": "surfacecast.show",
  "version": 1,
  "active_slot": 0,
  "slots": [ { "slot": 0, "scene": { "...": "a scene document" } } ]
}

The autosave copy

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.

Common object fields

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

Transform

{
  "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.

Type specific fields

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

Colors are #RRGGBBAA. #RGB and #RRGGBB are accepted on load and normalized to eight digits on save.

Robustness

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.

Mesh objects

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.