The on-screen rules of the specification, implemented once: give it a committed cartridge and the passage of time, and it says what is on screen, when that changes, and why. It draws nothing. A host (a browser page, a kiosk shell, an authoring tool’s preview) calls it from its display loop and draws what it returns.
dist/ are committed, so a
page or a program imports them directly; there is no build step for consumers.src/ TypeScript source
model.ts the Snapshot: records and indexes
schema.ts the v25.0.1 baseline the Loader checks
loader.ts bytes → Snapshot, through a small database adapter
snapshot.ts decoded rows → indexed Snapshot
errors.ts CartridgeError and its codes
venue-time.ts venue timezone offsets, Day 1, the Surface show clock
clock.ts surfaceClock(), previewClock()
rules.ts the pure rules: file by orientation, start and duration, directives
lanes.ts filesForLanes: which files a host needs for the lanes it renders (§7.7)
engine.ts the state machine: tick, reports, the trace
render.ts RenderItem
trace.ts TraceEvent and the reason codes
board.ts the board resolver contract, a minimal resolver, page timing
sqljs.ts the adapter for sql.js (browsers)
index.ts the public entry (any JavaScript runtime)
node.ts the Node entry: index + a node:sqlite binding
dist/ compiled ES modules and type declarations — committed, never edited by hand
test/ unit tests (node --test) and the benchmark
import { loadCartridge, sqlJsOpener, createEngine, surfaceClock } from "./engine/dist/index.js";
const SQL = await initSqlJs({ locateFile: (f) => `https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/${f}` });
const bytes = new Uint8Array(await (await fetch("SHOW26/LOBBY3.db")).arrayBuffer());
const snapshot = await loadCartridge(bytes, { open: sqlJsOpener(SQL) });
const engine = createEngine({ snapshot, orientation: "portrait", clock: surfaceClock() });
function frame() {
const { renderItem, trace } = engine.tick(Date.now(), performance.now());
for (const event of trace) console.log(event.code, event.message);
if (renderItem) show(renderItem); // your drawing code; see "The host" below
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
dist/node.js is the same API with loadCartridge reading through node:sqlite
(Node 22.13 or later), so nothing needs installing:
import { loadCartridge, createEngine, surfaceClock } from "./engine/dist/node.js";
const snapshot = await loadCartridge(new Uint8Array(readFileSync("LOBBY3.db")));
It is the module the conformance runner loads:
node conformance/run.mjs --engine engine/dist/node.js
Each tick, call engine.tick(wallMs, monoMs) with the wall clock (Unix ms) and a
monotonic clock (performance.now()). The engine samples one show-clock instant and
uses it for every decision in that tick. The returned object is reused by the next
tick; read it before ticking again.
When renderItem is not null, put it on screen and report back with its token:
| Report | When |
|---|---|
onFirstFrame(token) |
Its first frame is actually on screen. The engine arms the render marker from this moment and records the render event. |
onMediaCompleted(token) |
A video reached the end of its window (the out-point, or the end of the clip). |
onLoadFailed(token, reason) |
It cannot be shown: not held, not decodable, not fetched. The engine skips it. |
Only reports for the item on screen, from its first frame until its time is over or it is cut, and for the item awaiting its first frame, are acted on; anything else is ignored, so a late or repeated callback cannot move the rotation. A new render item supersedes any earlier one that has not reported its first frame. With no first frame within 10 s of monotonic time, the item counts as a load failure. A takeover that falls due while an item is still loading, or before a late first frame’s natural end, still cuts at its time.
Render items come in three kinds, and each says with pip whether it plays in a DemoStation’s
picture-in-picture (below; always false on a host without the mode):
kind |
Put on screen |
|---|---|
media |
media.mediaFileId (the file in this orientation’s slot, played as authored whatever its shape). Choose the rendition you hold (§7.6). Start a video at media.start; media.duration seconds later is its out-point (null: to the end of the clip). |
sessionBoard |
The board for board.sessionSetId, drawn from board.model (what your board resolver returned). Page i shows from the first frame + i × board.pageDuration, from board.anchorPage, wrapping — boardPageAt() computes it from a tick’s showNow. |
blank |
Clear the screen: an authored blank (§5.2). Needs no report. |
backing, when present, goes behind the content (§5.10): a still holds, a video loops,
and neither advances the rotation. The frame rules that make a Surface look right are the
host’s: hold the previous frame until the next item’s first frame is ready and never
flash to black; clear only for a blank; stop a trimmed video on the last frame before
its out-point; play video with sound, muted only by a local device choice (§5.8).
Tell the engine about the world with setOrientation(o) (the device’s orientation changed:
the operator set it, or the driven display turned — §6) and commit(snapshot) (a newly
delivered cartridge). Both take effect at the next tick, which cuts and re-evaluates. A
rotation keeps the schedule: the playlist is the same, so the engine keeps both rotation
cursors and continues after them with the new orientation’s files (§5.1, §5.6).
A host that runs the DemoStation mode (§5.11) says so: createEngine({ …, demoStation: true }).
The engine then resolves the demo_station slot beside the one playlist schedule. While a demo
is on:
demo carries its branding — background and overlay, each resolved like
a backing in the device’s own orientation, null for an empty slot. Draw the demo with it.
demo is null whenever no demo is on, and always on a host without the mode;pip: true): the same playlist, with
the files of the orientation opposite the device’s. It is the same engine, so everything —
takeovers, the watchdog, the reports — works there as it does full screen.When a demo starts, the rotation moves into the picture-in-picture, and when it ends, back to
the full screen. The trace records a cut with mode.change, and the rotation continues after
its cursors: the picture-in-picture picks up where the full-screen player was, and the other way
round. A boundary that keeps the demo on changes only demo. A host without the mode leaves
demoStation out, and the slot is ignored.
const engine = createEngine({ snapshot, orientation: "portrait", demoStation: true, clock: surfaceClock() });
const { renderItem, demo } = engine.tick(Date.now(), performance.now());
layout(demo); // the demo and its branding, or the full screen
if (renderItem) show(renderItem, renderItem.pip ? pipFrame : fullFrame);
A lane is one orientation over the one schedule. filesForLanes(snapshot, { lanes, demo })
returns the media file ids a host rendering lanes needs (§7.7): every file the manifest
lists, except those referenced only as an item’s slot file for an orientation the host does not
render, or only as demo branding on a host without the DemoStation mode. An item’s portrait
file serves the portrait lane and its landscape file the landscape lane, however the item is used
(a playlist entry, a backing, demo branding), since each resolves by orientation with no
fallback. An item used only as demo branding — a demo_station entry’s background or overlay
that no playlist entry, backing, logo or wallpaper names — is in a lane only with demo: true:
a host without the mode never draws it. A session board template is wanted on every lane: the
file of an item the project or a session set names as its template. A file no item references is
wanted. With demo: true (a host that runs the DemoStation mode), while the
demo_station slot has any entry with a background, the opposite of each lane is rendered too
— the picture-in-picture plays the same playlist in it. A file is judged by every slot that
names it. The ids iterate ascending; a project snapshot reads the same way.
const wanted = filesForLanes(snapshot, { lanes: ["portrait"], demo: false });
const toFetch = [...snapshot.manifest.values()].filter((m) => wanted.has(m.mediaFileId));
Nothing on screen depends on it: an entry whose file is not held is reported with
onLoadFailed and skipped, as any missing file is, until the file arrives. After an
orientation change, fetch the new lane’s files.
| Export | |
|---|---|
loadCartridge(bytes, { open?, kind? }) → Promise<Snapshot> |
The Loader. kind: "project" reads project.db into a ProjectSnapshot. In index.js, open is required (sqlJsOpener(SQL)); node.js defaults to node:sqlite. |
sqlJsOpener(SQL) |
The database adapter for a loaded sql.js module. |
readCartridge(adapter, kind) |
The same checks over an adapter you already have: { all(sql) → rows, close?() }. |
buildSnapshot(kind, rows, warnings) |
Rows keyed by wire column → Snapshot. For a tool that has the data without a file. |
isSqlite(bytes) |
The 16-byte magic check (§8.5). |
CartridgeError |
Thrown for a cartridge the Loader will not use. code is stable; table and row say where. |
CartridgeError.code: not_sqlite, not_v25 (no cartridge_meta), format_unsupported
(first version component not 25), kind_mismatch, meta_invalid, table_missing,
column_missing, table_unreadable, row_undecodable (names the table and the row;
§10.3), structure_invalid (not exactly one project or surface_config row), damaged
(the magic is there but SQLite cannot read the file: a truncated or corrupted copy),
no_sqlite_binding.
Unknown tables and columns, and rows with an unknown enumerated value, load with a
warning in snapshot.warnings and are ignored (§10.4). So do dangling references,
malformed JSON columns, an unknown timezone, and a cartridge with no days. A column the
format has retired — surface_location.orientation, which an earlier draft wrote — is
ignored without a warning, and never read (TableSpec.retired). A value it has retired is
another matter: a schedule row on the slot portrait or landscape — an earlier draft
scheduled playlists per orientation — is malformed (§4.4), skipped with a value.malformed
warning naming the column, and never read as a playlist entry (RETIRED in schema.ts).
A row holding a NUL byte (U+0000) in a text column is malformed (§4): skipped with a
value.malformed warning that names the column, or refused with the table’s own code in
cartridge_meta (meta_invalid), project and surface_config (structure_invalid).
SQLite itself finds the byte, so every binding answers alike — including sql.js and
node:sqlite before Node 24, which end the string at it.
| Export | |
|---|---|
createEngine({ snapshot, orientation, clock, demoStation?, boardResolver?, boardVariant? }) |
One engine per device. It resolves the one schedule; orientation is the device’s (§6), and demoStation: true runs the DemoStation mode (above). boardVariant (§5.15) is the device’s board setting — now-next, schedule or both (the default) — handed to the board resolver and recorded on every board; setBoardVariant(v) changes it, cutting a board on screen (variant.change) at the next tick. Options carrying a slot — an earlier draft’s schedule per orientation — throw a TypeError that says to remove it. |
engine.tick(wallMs, monoMs) → { showNow, projected, renderItem, trace, demo } |
Evaluates the next content when the marker is forced (0) or showNow ≥ marker. A forced marker is a state of its own, not an instant compared with the show time, so a show time before 1970 (negative milliseconds, reachable from a preview clock) evaluates at the next tick like any other (§5.9). |
engine.onFirstFrame(token), onMediaCompleted(token), onLoadFailed(token, reason) |
Each returns the trace events it caused. |
engine.setOrientation(o), engine.commit(snapshot) |
Applied at the next tick. A rotation keeps the schedule and the cursors. |
engine.orientation, engine.renderedOrientation, engine.demo |
The device’s orientation; the one whose files play — the device’s, or the opposite in the picture-in-picture; the demo while it is on. |
engine.inspect() |
What the engine sees at its last tick — the day, the orientation rendered, the demo, the schedule entry, every entry’s gate and directive states, the working set, cursors, marker. For status views; allocates. |
engine.current, engine.currentSince, engine.pending, engine.showNow |
|
surfaceClock() |
The Surface show clock (§8.2). |
previewClock() |
An authoring tool’s transport: set(showMs), play(), pause(). Opens on the Surface value and runs at real-time rate on the monotonic clock. Commands apply at the next tick. |
minimalBoardResolver, boardPageAt(board, sinceShow, showNow) |
The board contract below. |
playbackWindow, fileIdFor, DirectiveSeries, Calendar, … |
The pure rules, for tools that want one without an engine. |
filesForLanes(snapshot, { lanes, demo? }) |
The media file ids a host rendering lanes needs (§7.7; above). |
ORIENTATION_WHEN_MISSING |
"landscape": with no orientation from the host, each entry plays this orientation’s file, else the other one (§5.14). |
{ showTime, kind, code, entryId?, set?, mediaFileId?, sessionSetId?, message }. code
is the stable reason code from the suite’s table;
message is plain words for operators and is never compared. A render is recorded at
the first frame; a hold on entering the hold, not on each retry.
A board’s look is each platform’s decision, so the engine asks an injected
boardResolver(set, { showNow, timezone, entries, sessions, snapshot, variant }) for
{ model, pageCount, anchorPage } once per render item. The board lasts
session_set.duration × pageCount. The resolver shipped here is minimal: one page, the
set’s sessions in start order, each marked past, now, next or later.
The rules the specification states are implemented as stated. Where it was silent, the engine had to do something; these are the choices. The specification now lists them in its §5.14, so a second implementation makes the same ones.
| Situation | What this engine does |
|---|---|
| Two directives, schedule entries or playlist entries share a timestamp or position | The higher id is later. Cursors compare (position, id). |
No playlist entry has started yet |
Holds (set.empty), like an empty working set, and plays from the first changeover. |
Which cut an interruption records |
One event per cause: a jump records jump only, and a new cartridge cartridge.commit only; what the schedule changes as a result (the playlist, the mode) is applied silently. A boundary that starts or ends a demo records mode.change, and a playlist change at the same boundary resets the cursors silently. A rotation and a boundary in the same tick record one cut each. A cut is recorded only when an entry is on screen, naming it. |
| An authored blank | Re-evaluated every 2 s like a hold, without repeating the blank event. |
| Rotation, new cartridge | Applied at the next tick; the cut carries that tick’s show time. |
Which failures count toward set.all_failed |
Load failures (before or after the first frame), missing first frames, and entries that are not image or video media. The streak ends when an item finishes its time: naturally, at its watchdog, when it completes, or when a takeover or an alert cuts it. It also ends when the playlist or the orientation rendered changes, and at a new cartridge. |
| A takeover or an alert due while the next item is still loading, or before a late first frame’s natural end | It still cuts at its time (§5.9: only the activation of a higher set preempts an item, and it does so at its activation). The loading item is abandoned; a late item’s marker is armed at the activation instead. |
| The preview clock before its first command | Reads what a Surface would show now, and runs. |
| Day 1 on a daylight-saving date | §8.2’s “next valid instant”, read literally: a skipped local time becomes the moment the clocks change, and a repeated one the earlier instant. So on the days around a show whose Day 1 springs forward, the show clock stands at that moment for the hour the clocks skip, and whatever is on screen waits. The specification keeps this reading and says so in §8.2; shifting by the length of the gap was rejected because it adds one backward jump a day. |
| No orientation from the host | The gate is skipped (§5.1) and each entry plays its landscape file, else its portrait one; backings and demo branding resolve in landscape (ORIENTATION_WHEN_MISSING). A DemoStation’s picture-in-picture skips the gate too. |
| Day 1 clamp | A projection that lands outside Day 1 becomes Day 1’s start, as §8.2 now states. The alternative reading, its end for a late time, differed only for a Day 1 that is not a whole day, which Studio does not produce. |
| A board variant change (§5.15) | Cuts a session board on screen (variant.change) and continues the rotation after its cursors, as an orientation change does; it does not re-issue the same entry. A still or a video on screen plays on. The template a board renders with, its settings and the variant are on BoardContent (templateItemId, templateSettings, variant); the engine resolves them and renders nothing. |
TypeScript is the only development dependency.
cd engine
npm ci
npm run build # tsc: src/ → dist/ (commit dist/ with the source)
npm test # unit tests: the Loader's refusals and warnings, engine behaviour
npm run conformance # the 30 scenarios
npm run bench # performance against the targets below
CI builds dist/ and fails if it differs from what is committed.
npm run bench on an Apple M-series laptop with Node 24, and the same cartridges loaded in
Chromium 152 through sql.js 1.10.3:
| Node 24 | Chromium, sql.js | Target | |
|---|---|---|---|
| A tick with no transition | 0.02 µs in the event, 0.04 µs outside it (synthetic Day 1) | 0.01 µs | < 100 µs |
| Allocation by an idle tick | 0 bytes over 1,000,000 ticks beyond the host’s own call | — | none |
renderNext |
500 entries: median 9 µs, p99 15 µs | 1,004 entries: mean 8 µs | < 2 ms for 500 |
| Loading a ~12,000-row cartridge | median 14 ms (node:sqlite) | median 22 ms | < 500 ms in a browser |
During a hold the engine evaluates the playlist again every 2 s of show time. That pass builds no strings unless the hold is being recorded, but it runs too rarely for the JavaScript engine to optimize, so its arithmetic allocates boxed numbers: about 110 bytes per pass with the four-entry fixture (56 KB over 1,000,000 ticks, 500 passes). The ticks between passes allocate nothing.
Outside the event, the venue’s UTC offset is re-read from the platform once per quarter hour; every other tick is arithmetic on numbers already held.