Skip to content

Latest commit

 

History

History
260 lines (225 loc) · 91.6 KB

STATE.md

File metadata and controls

260 lines (225 loc) · 91.6 KB

State

What is actually real, as opposed to what is designed. Updated as part of the change that makes something real — not afterwards.

docs/ROADMAP.md is where we are going. This is where we are.

Last updated: 2026-10-12 · M0–M5 complete (template variants deferred) · M6 under way · MIT and public (ADR 0030).


The honest summary

It runs a service, in rehearsal. A designer builds a template in the browser with the same Rust the node renders with; an operator binds data, arranges Playlists into a Rundown, and takes; a node puts it on air over SDI or NDI at 1080p59.94 with zero late frames, keyed, with frame-synced audio. The seeded rehearsal (tools/rehearsal.sh) stands the whole thing up.

The foundational bets all came in. Native wgpu and browser WebGPU render bit-identically across the 106-figure parity corpus (S1) — on Metal; D3D12 and Vulkan are untested. The hand-written DeckLink shim held 1080p59.94 for ten minutes with zero late frames (S3, internal clock). WebGPU is comfortably available in the browser (S6). S2's text shudder was root-caused to quad truncation of the antialiasing tail and fixed (0.34 px → 0.0066 px).

The guarantees are tested, not asserted.

  • Autonomy. Kill the control server and the node keeps rendering. A new server with no memory adopts what is on air from the node's report.
  • Cues. A take does not report success until the node confirms it, and a late take is refused rather than fired. Superseding, dedupe and deadlines live at the node, so every path inherits them. The chaos harness put 1000+ takes through five injected faults with none fired twice.
  • Soak. A two-hour soak had zero late frames over 432,000. Audio drift is closed-loop against the card's buffer.

Built in M6: screens, timers, multi-destination Items, arrangements, a host sound-device output, outputs configured from the console, sign-in, and the two menu-bar apps. In October 2026 these became real as well:

  • Follow over a cue socket. Run at band speed against a real node, every cue was acked in 6–35 ms.
  • Fonts matched by family, weight and slant, with italic text (format 1.6).
  • Node-side YCbCr for SDI. The output's matrix and ranges are applied by the node. It is opt-in, because it has never run on a card.

What it does not have, plainly:

  • Template variants.
  • Export and import bundles.
  • A GPU display output (the crate is a placeholder).
  • GPI and MIDI listeners.
  • Signed and notarized installers.
  • Measurements of the things that need a scope or a reference generator: S9 colour on the wire, and genlock under a real reference.

The project became MIT-licensed and public in October 2026 (ADR 0030).

Component status

Component State
Documentation set ✅ 30 ADRs, 8 specs (with the OpenAPI document), 10 spike briefs, and docs/GETTING-STARTED.md for someone outside the building
Cargo workspace 🟢 20 crates (18 + two sys/); fmt, clippy -D warnings, and the wasm32 pure-set job green in both SDK and no-SDK configs
pmgctl --selftest 🟢 gates the format's load-bearing guarantees: canonical CBOR round-trip, stable template_rev, JSON-mirror equivalence, visible_if collapse
sys/decklink-sys 🟢 enumeration, probing, modes, reference, and scheduled output — 10 min at 1080p59.94, zero late, zero dropped
sys/ndi-sys 🟢 runtime loading, find, receive, send — frames verified in both directions. ABI asserted statically and at runtime
pmg-color::patterns 🟢 ramp, bars, soft-edge, alpha staircase, moving line — the diagnostic instruments from output-config.md §8
pmgctl ndi / pmgctl decklink 🟢 info, probe, send / list, modes, ref
pmgctl take 🟢 the M1 vertical slice — template to NDI, In/hold/Out
Control server 🟢 templates, revisions, items, CAS origin, PMG Link, cues, nodes. 39 tests
SQLite schema 🟢 migrated on boot: templates, revisions, items, assets, nodes, assignments. No on_air column, by design
server/cas.js 🟢 atomic writes, hash verified never trusted, GET /cas/:hash with Range and resume. blake3 agrees with the Rust crate, tested
server/nodeLink.js 🟢 handshake, assignment offer/ack, cues with deadlines, replay ring, StateReport adopted. An adopted node that is merely powered off now answers offline, not "there is no node by that name" — sessions are live-only, so without a table lookup the message sent an operator to Configure to fix a name that was correct
POST /api/cue 🟢 synchronous — 503 cue_not_acked when the node stays silent, never a silent success. Fans across destinations and aggregates the acks (ADR 0018): a take naming no node goes to every node the Item lives on, and a partial delivery is a 503 carrying result: "partial", a per-destination breakdown, and the sentence — "On air on part-a. part-offline did not confirm: it is offline." Deliberately not a 2xx: a client checking res.ok would take the button green on 207
Playout mode (ADR 0017) 🟢 the model, the operator behaviour, and the transport. playlists.mode (cue/follow), and the keyboard is a function of the focused playlist's mode: in cue the arrows move, in follow they move and take. Cursor and on-air stay separate marks in BOTH modes — Follow sets nothing on air locally. Follow rides the cue socket (/api/cue/socket, http-api.md §2.2, 2026-10-12): one WebSocket, any number of cues in flight, each acked by cue_id with exactly the status and body POST /api/cue would have answered — same dispatch, same permission check, identity per cue from a hello. A POST per keypress held one of the browser's six HTTP/1.1 connections until its ack, so a burst queued behind its own first keypress. Anything the socket cannot deliver goes by POST with the SAME cue_id, which the node's dedupe makes safe. Run at chorus speed against a real node (rehearsal, NDI, M3 Max MacBook Pro, server and node on one machine): 12 keypresses at ~9/s, every one acked in 25–35 ms; 10 more 3 ms apart, acked in 6–26 ms; zero POSTs, zero alerts, 22 log entries. Open: at sub-frame bursts several takes fire on ONE frame (frame 1523 four times) and all log applied — the node only supersedes cues that land in the same try_recv batch, so the log cannot yet say "four requests, three superseded"
CueResult::Superseded (ADR 0017) 🟢 a take supersedes an unfired take to the same layers, and is acked rather than dropped. Batched from what has already arrived — try_recv only, so it can never delay a cue waiting to see whether a newer one shows up. Distinct from a late rejection: one is the system working, the other is it refusing, and a three-lamp panel needs different lamps. A replay of a cue that already fired is still Deduped. Reaches the operator as a success since 2026-10-12 — the server had mapped it to 409 "the node refused that cue", so every superseded Follow keypress read as a failure
Several destinations (ADR 0018) 🟢 complete. One Item, N node/output/layer destinations, each with its own pinned template_rev and an auto/on_request mode. Migration 8 expands every existing Item into a list of one. Assignment fans per node; the node arms all destinations before placing any, so a fan-out is all-or-nothing within a node. auto fires with the take, blank clears its feed as an instruction, and on_request waits for take_also — the "Also full screen" action. 100 server tests
pmg-proto 🟢 the wire contract, with checked-in CBOR fixtures the JavaScript tests decode
Item destinations in Edit 🟢 ADR 0018 is authorable. An Item's destinations are a list: output and layer picked from what the node actually reports (a layer typed by hand is a cue the node will refuse, discovered at the take), a template per destination, and one control for the three things a destination can BE — fires with the take, only when asked for, or render nothing and clear this feed. The old single Template select is gone. (The form first showed only the FIRST destination's fields; since M6n it shows every destination's — row below.) Everything in the rehearsal was built by a script against the API before this
Design / Edit / Show 🟢 all three real. Design compiles and renders with the shared Rust; Show takes on air and does not lie
pmg-wasm 🟢 compile_template + DesignSession — the browser half of ADR 0001, pinned by a native test
Fonts 🟢 matched, not merely loaded (2026-10-12). Uploaded as CAS assets and fetched by hash; one rule in pmg_text::build_font_db, shared by the designer and the node: every face registers under its own name, weight and slant — every face of a collection, where only the first of a .ttc was read; a declaration that pins a file by hash gets that file under its declared family; a family nothing answers to gets every face at each face's OWN weight. Selection decides slant first — Regular and Italic at 400 used to tie and the first file loaded won — then weight by CSS Fonts §5.2 rather than load order. Italic text is format 1.6 (font.style), with an Italic control in Design that pins the italic file beside the upright so a node stages both; verified on the Design canvas with Arial and Arial Italic. Still not done: synthetic italic and bold for a family that lacks the face (it renders the nearest real face instead), font stretch, and a family picker that lists every face of a collection
Auth (ADR 0013, ADR 0025) 🟢 both halves. Server: stations, users, groups, permissions, sessions, audit, trusted stations — an unconfigured server is open and says so; the first user closes it. Browser: registers as a station on first load, carries the station header and the bearer token on every request, and signs in from a band of chrome. 21 tests
Adoption 🟢 an unadopted node is welcomed and given nothing — no templates, no items, no cues. Persisted, so it survives a restart
mDNS discovery 🟢 verified. A node with no --server finds the server and links; two servers are reported rather than guessed between. Needs macOS Local Network permission (see INTEGRATION-NOTES)
Configure 🟢 adopt a node, see its capabilities, edit its layer stack — pushed live
S8 chaos harness 🟢 gating. npm run chaos — 1000+ takes through five injections, zero fired twice
OSC in (M4, ADR 0014) 🟢 verified with the control server stopped. UDP on 8522, address → trigger from <state-dir>/surfaces.json, submitted on the same channel every other surface uses so it inherits dedupe, deadlines and superseding. A mapped address applied and reached the engine; the same address sent three times in a burst fired once (per-trigger debounce, 120 ms — a relay closing is a burst of edges and by the cue-id rule each is a distinct action); an unmapped address and an OSC bundle were both ignored. Bundles are refused rather than half-handled: a timetag is a scheduling promise this node does not make
RossTalk in (M4, ADR 0014) 🟢 verified with the control server stopped. Line-oriented TCP on 8524, always listening — a switcher pointed here as its fallback must not depend on a mapping file existing. CUSTOM take|out|layer|item is self-describing and needs no mapping; GPI <n> and CC <slot> resolve through the SAME surfaces.json the physical wires use, so a slot number means one thing rather than two that can disagree. Verb case-insensitive. Debounced on the line: four identical lines back to back fired once, two 200 ms apart fired twice — bounce is suppressed, deliberate repeats are not. Unmapped lines are logged at info, because an operator asking "did it arrive?" needs to see that it did
GPI / MIDI in 🟡 mapping and cue-building done and tested; no listener. They share surface.rs with OSC — the same Trigger, Debounce and cue_for — so what is left is the transport per device. Neither can be built honestly without the hardware: GPI needs a contact-closure box (a Quad 2 has no GPI pins), MIDI needs an interface and a crate decision. Deliberately not faked
Control API document (M4) 🟢 docs/specs/openapi.json — OpenAPI 3.1 for the surface an integrator writes against, both hosts. Checked against the routes, both directions: a documented path that does not exist fails, and a route that is neither documented nor on an explicit not-public list fails too. The second half is the one that earns its keep — a new route forces a decision about whether integrators may call it, rather than being an omission nobody notices. A stale exclusion also fails. JSON not YAML so the test needs no parser and the repo needs no dependency
Node API (M4, ADR 0014) 🟢 the emergency surface. :8520 — POST /cue taking the same body shape as the server's endpoint, GET /status, and a self-refreshing status page. Verified with the control server stopped: a cue applied and returned its frame number, a repeat deduped, a bad layer was rejected by name. A local cue goes on the SAME channel the link uses and is applied by the same loop — same dedupe, same deadline, same superseding — because no adapter gets its own path into the engine. No auth, deliberately: booth LAN, and a credential check against a server that is down is a lock with no key. Ops are a subset (take, take_out, clear_layer, clear_all, arm); update and timeline need a keyboard a GPI contact does not have
pmg-node 🟢 the playout binary. Link client, staging, durable assignment, layer engine, NDI out. Survives server loss; 10 cue-rule tests
pmg-cas 🟢 atomic writes, streaming put, verify. Agrees with server/cas.js
Colour model 🟢 601/709/2020 RGB→YCbCr with correct narrow spans, and a key path that provably bypasses the matrix. 6 tests
Colour config 🟡 applied on SDI, opt-in, not yet on a card (2026-10-12). An output with Encode YCbCr on the node set sends 10-bit 4:2:2 v210 the node encoded itself — the configured matrix and fill range, the key as Y = alpha in its own range with neutral chroma — from the linear composite, on the GPU (pmg_render::ycbcr), tested against the CPU reference (pmg_color::v210): fill within one code value, key exact. ~1.5–2.5 ms a frame on an M3 Max. Off by default because it has never run on a DeckLink and nothing has measured the wire (S9); without it the card converts 8-bit RGB with its own settings, exactly as before. NDI refuses the setting — it carries RGB. Configure's colour selects used to save the value from before each change; fixed. See INTEGRATION-NOTES
Test patterns 🟢 on demand, above the layer stack, so a configuration can be proved without clearing the show off air
Media ingest 🟢 probe, judge, transcode to all-intra. Long-GOP converted, ProRes passed through with alpha intact, non-media refused. Jobs, one at a time. 6 tests
pmg-media 🟢 stills and video. PNG/JPEG in process; clips via a child ffmpeg over a pipe, premultiplied at the boundary, VFR refused. Exact-rational A/V clock. 9 tests
Images on air 🟢 kind: "image" with fit: cover | contain | stretch, decoded once at assignment, soft alpha composites correctly over a key
M3 two-hour soak 🟢 passed. 894 cues all applied, zero late frames over 432,000, audio drift +2.3 ms with the whole excursion inside one frame, zero warnings. Recovery from a stall proved separately by injecting one
Live rebind (M3g) 🟢 the differentiator, working on SDI. Update morphs between two solved layouts — the box grows and shrinks to fit new text while on air. Rebind solves in 0.2–0.7 ms, zero frames over budget, verified on the card by eye. clip is now implemented, which it had not been since M1d
Video on air 🟢 kind: "video" with fit and loop. 21 s of keyed 1080p ProRes 4444 at 59.94: 0 held, 0 frames over budget, worst 15.17 ms of 16.68. The upload costs ~2.7 ms of headroom. A clip starts up to 120 ms after the take, holding its first frame — pre-arm is the fix and needs the preview slot
Video audio 🟢 frame-synced and self-correcting. A 2 h soak found a hiccup left audio +168 ms ahead permanently; the card's buffer level now drives the sample count. Verified by INJECTING a 1 s stall: settles to −0.6 ms of target, where it used to stay 164.8 ms out. Otherwise: Exact 801/801/801/801/800 cadence at 59.94; two hours drifts zero samples. SDI verified on a Quad 2 — flat card buffer over 2 min, and a deliberately naive cadence drains it, so the instrument is proven. NDI verified by send-and-probe — 801 samples, level unchanged at −12.0 dBFS
pmg-audio 🟢 the audio clock and layer→pair mixing. Sums and clips rather than averaging. 10 tests, two-hour non-drift in --selftest
Genlock status 🟡 read live from the card and surfaced in Configure; what present: true means with nothing plugged in is unverified — needs a reference generator
Multi-output 🟢 1–N outputs per node, a render thread per output GROUP (shared clock), independent content and resolutions per output. Two outputs at 59.94, zero late
pmg-preview 🟢 H.264 over a WebSocket, decoded with WebCodecs. Verified in a real browser: 103 frames on a canvas, joiner paints in 4 ms from the cached keyframe, 0.05 Mbit/s on static content. MJPEG kept behind --preview-mjpeg as the no-decoder fallback
pmg-out-decklink 🟢 SDI output. Fill and key on two ordinary ports, 90 s at 1080p59.94, zero late frames. The key picture is unverified on a scope
pmg-out-ndi 🟢 the shared sender — one unpremultiply loop for the node and the CLI
streamHub ✅ ported from ProdMesh-Control — refcounted topics, conflation, snapshot-on-join
cueLog ✅ the log-tail bridge from ADR 0008, with gap recovery. 6 tests
PMG Link 🟢 real — server/nodeLink.js and pmg-proto, rows above
CAS 🟢 real — server/cas.js and pmg-cas, rows above
Web app 🟢 the operator console (ADR 0020) — the design-system tokens and 20+ components transcribed to first-party TSX/CSS, fonts bundled (no CDN), a header/footer console shell over the four modes. Show: rundown rail, live H.264 program well, layer strip, section jump map, slide grid, armed inspector with per-destination readiness; Edit: playlists + rundown composition, section authoring, playout-mode control, multi-destination item inspector; Design: template browser + publish rail. Verified against the rehearsal service end to end — take → on air → two marks → take log. 31 web tests
pmg-scene 🟢 full document model, canonical CBOR + JSON mirror, template_rev, validation, manifest, total binding evaluation. 25 tests
pmg-layout 🟢 flexbox solver over TextMeasure (taffy, ADR 0016). Shrink-and-grow and visible_if removal verified as numbers. 12 tests
pmg-text 🟢 fonts from bytes, swash shaping, subpixel glyph atlas at ⅛ px, real TextMeasure, and line breaking — hard \n plus greedy wrapping to the solved box, lines cut out of one shaped run so kerning does not shift when a later word wraps. Whitespace only: not UAX #14, so no hyphenation or CJK — that still wants parley. 20 tests. R3's three hazards structurally answered; quality still needs S2
Colour pipeline 🟢 composites in linear, encodes once at output (ADR 0012, which said so since M0 and was not implemented until 2026-08-17). Scene draws into Rgba16Float; one full-screen pass encodes to Rgba8Unorm. Un-premultiplies before the transfer function and re-premultiplies after, because encode(a·c) ≠ a·encode(c) and the error is largest at the low alphas a soft edge is made of. Alpha is never converted — it is coverage, and the key must stay a monotone alpha signal. Encoding is per FRAME, not per layer
Coverage gamma 🟢 stem darkening in the rasterizer, γ = 1/1.45. Geometric coverage is right when composited in linear light and too dark when a downstream keyer blends the encoded values — a 50%-covered edge of white type lands at 128/255 and displays as 21% of the light it should carry. We cannot fix the switcher, so the compensation goes where the coverage is made, as FreeType and Skia both do. Approximate by nature: the exact correction depends on a background this process never sees. Measured lift 138.5 → 151.0 mean edge alpha with solid pixels and edge count unchanged
pmg-render 🟢 scene rendering — boxes with analytic-AA rounded corners, text from the coverage atlas, composed transforms, timeline animation. Plus the S1 parity corpus
Outputs are configuration (M6f) 🟢 the shell command stops being the interface (ADR 0024). A node's outputs were --output flags; the server could edit layers and colour and nothing else, so NDI → DeckLink meant editing a launch line and restarting. Now: the node owns them — outputs.json in its state dir, read at boot without asking anyone, because ADR 0009 says a node that boots with the server down must still drive its outputs. Flags are the SEED for a node that has no file; once one exists they are ignored, or the saved configuration would be a lie. Hardware and content stay separately owned: the file holds backend, device, key device, mode, audio, preview port and clock group; the assignment still holds layers and colour. SetOutputs is its own message rather than a field in the assignment, so fixing a typo in a lower third cannot rebuild an output backend. Applying is live and per GROUP — the runtime's one stop flag became one per group — so renaming the wall does not interrupt the key feed. An output with a graphic on it is refused by name and keeps running; force interrupts it deliberately. The node persists and reports what it APPLIED, never what it was asked for, so a refused change leaves nothing lurking for the next boot to surprise somebody with. Verified on an isolated server+node: seeded from flags, reconfigured from the browser, restarted with NO flags and came up with all three outputs including one that only ever existed because the server asked for it; a rename restarted exactly one group; an on-air output was refused and force pushed it through. Three bugs found by driving it. (1) An NDI sender was torn down because a DeckLink display-mode string differed — a frame of black for a field that output never reads; same_hardware now compares per backend. (2) The server learned a node's outputs only when it had NONE, so an output added by reconfiguration was never recorded and every destination naming it was silently filtered out of every cue — it now learns outputs it has never seen while never overwriting an operator's layer stack. (3) A take whose destinations resolved to nothing built a cue with output: undefined, which CBOR encodes as undefined and the node could not decode at all — the frame was dropped and the operator was told "undefined didn't confirm the take". A cue is never built with a hole in it now
Configure UX pass (M6e) 🟢 a rail, a node's own details, and the users page the footer has been promising since M2. (1) Sections behind a rail — Playout nodes · Screens · Users & stations — the pattern Edit and Show already use rather than a second row of tabs, and somewhere for audio routing and preferences to land. (2) Diagnostics per node, folded away because it is for when something is wrong: the node's ADDRESS (it existed only inside preview_url, so an operator debugging a node could not read its IP off the screen they were looking at), its id, assignment gen acked, last seen, scene-reader major, decoders, and per-output fps and LATE FRAMES — plus a link to the node's own status page, the surface that still works when the control server does not, which is exactly when someone wants it. The node now REPORTS its api and preview ports in Hello rather than the server assuming 8520/8521, so a node started elsewhere is still reachable and a switched-off preview does not get a link that 404s. (3) Users, groups and stations, which existed server-side in full (ADR 0013) with no UI at all — the footer said "add a user in Configure" and pointed at nothing. Found by building it: creating the first user turns enforcement on for good, and a first user with no group would have turned it on holding NOTHING — unable to create a group, grant a permission, or add anybody else, with no way back that does not involve a text editor and the database file. The first user now gets the wildcard, the response says granted_admin, and both the page and the button's answer say so out loud, because a permission nobody knows was granted is a permission nobody audits
Screens (M6d) 🟢 a named place in the building (ADR 0023) — the other half of M6's multi-screen line, and the first thing in Configure that is about the ROOM rather than a machine. An Item's destination may name a screen — "LED Wall" — instead of node/output/layer, and the wiring lives in one row an operator can edit. Two things decide the design. The node never hears about it: resolution happens on the control server, so the protocol is unchanged and a node running yesterday's build works with screens the day they exist. And a screen names a TEMPLATE, never a revision — the Item still pins at save time, or every publish would become a silent live change to every graphic going there, which is the exact failure pinning exists to prevent; a screen's template going stale reuses M5v's guided republish rather than inventing an answer. Resolution is ONE function called from both the assignment build and the cue path, because those two disagreeing means a graphic staged for one place and fired at another. Deleting a screen Items point at is refused and NAMES them (the fields editor's rule); a row orphaned by database surgery falls back to the triple stored beside it, so a graphic goes where it used to rather than nowhere. The raw triple stays legal — a fresh install must be able to address its own hardware before anyone has invented a name for it. Verified live: two screens named, an Item pointed at "LED Wall" (pinning the countdown template from the screen), taken to wall, then the screen moved to fs with no Item edited — the next take landed on fs, and the node's own report showed both. Edit shows "Goes to: LED Wall" with the wiring beside it as a readable line, and no operator meets a 26-character node id anywhere
Director and Playout, as apps (M6p, ADR 0029) 🟡 two menu-bar apps, built and driven; not yet notarized or documented. Everything ran from a shell script, which is right for development and is not something anybody outside this building can install. Now: ProdMesh Director (the server) and ProdMesh Playout (the node) as separate .app bundles — separate identifiers, separate data, either or both on a machine, because ADR 0009 says they fail independently. LSUIElement, so no Dock icon and no window: the browser console is the real interface and a second one would be two places that must agree about what is on air. The icon is a dot that is red only when something is on air (ADR 0020), which matters more here than anywhere — it sits beside the clock all service. One launcher binary configured by a Launcher.json per bundle, because two launchers would drift and only one of them would be the one that mattered. The app IS the supervisor rather than a LaunchAgent: the menu says whether the child is up, the log is one click away, quitting stops the child, and a crash restarts with a capped backoff. SIGTERM then SIGKILL, so quitting never leaves an orphan holding a DeckLink. Status is polled from the child's OWN endpoint, never inferred from the fact that it is running. Data lives in ~/Library/Application Support, never in the bundle, so replacing an app is dragging a new one in. The Node runtime travels with Director — a booth machine has no nvm. Verified end to end: both apps launched, Director serving /show, Playout supervising a node that linked and reported 8 DeckLink connectors and 19 audio devices, state in Application Support, and the supervisor recovering by itself when a port it needed was freed. Splash art shows on launch, sized from the artwork's own aspect so a redraw cannot letterbox it. Stopping the child covers all three ways an app ends — the quit handler, a signal source, and a pid on disk that the next launch reaps after a SIGKILL: measured, four pkills of the launcher had left four orphaned nodes holding their ports. Found by packaging, and only findable that way: server/index.js compared import.meta.url to file://${process.argv[1]}, and a path containing a space encodes as %20 in one and not the other — so inside "ProdMesh Director.app" the server started, listened to nothing, and exited 0 in silence
Text alignment, and a rename that renames (M6o) 🟢 centre actually centres, and vertical alignment exists. align was applied relative to the WIDEST LINE, on the theory that a text node is exactly as wide as its content and its parent has already centred it — true for a content-sized node, where the box and the widest line are the same number, and simply wrong for the ordinary case of a fixed-width line of type. Centre moved nothing at all there, which is exactly what it looked like. It is relative to the node's own box now, each line still aligned on its own so a centred lyric is not a centred block of left-aligned lines. Vertical alignment lands with it (format 1.5, content.vertical_align — the name the spec has documented since before it was implemented): top, middle or bottom of the box. Omitted when it is the default, so no existing template's template_rev moved — a format field that always serialised would have restated the address of every template in every library the day it landed, for a feature none of them use. And renaming a ${binding} now RENAMES its field rather than declaring a second one: the label, sample, type and required flag travel with it, and a label the designer never touched follows the key rather than staying behind naming something that no longer exists. Renaming onto a key that already exists is not a rename and is left alone. Proved on pixels: text-align-center-one-line puts its ink centred at x=256 of 512, and the vertical figures land where the arithmetic says
Every destination's fields (M6n) 🟢 the bug that made ADR 0018 unreachable. An Item goes to several destinations, each rendering it with its OWN template — and those templates legitimately want different fields: a lower third showing only the lyric, a wall showing the lyric and the song title. Two rules cancelled each other out. The editor showed only the FIRST destination's fields, so the wall's were invisible; and validateItemData refused any key the template did not declare, so even typed by hand they were rejected as typos. The feature could not be used from the screen meant to author it. Now the form shows the union across every non-blank destination, each field saying which templates want it when they disagree, and clashing TYPES for one key are named rather than silently resolved. On the server, a key a sibling destination declares is not a typo — only a key no destination knows at all still is, so the protection that names a misspelling survives intact. Also fixed on the way: FormGroup rendered a <span>, so every field in the product was nameless to a screen reader and clicking a caption did nothing; it is a real <label> wrapping its control now
Audio on a sound device (M6m, ADR 0028) 🟢 video on SDI, audio to Dante. Audio only ever left this system embedded in the video; a plant whose audio arrives over Dante had no way in, and the roadmap has carried the gap since M3. An output's audio now has a DESTINATION — embedded, or a named host device — over pmg-out-audio (cpal: CoreAudio, WASAPI, ALSA, ASIO behind a feature). One destination, never both: the per-frame sample count is corrected against exactly one buffer, so a second would be open loop, and open loop is measured at +168 ms over two hours. The video output is still the clock; what moved is where the ERROR is read — the DeckLink's queue, or the sink's ring — and AudioSync corrects the same way either side. The ring is lock-free because the consumer is a real-time callback that may not wait, and the device is primed before it drains: a DeckLink prerolls, cpal does not, and starting at a depth of nothing measured 268 underruns in two minutes. A device that will not open is loud and NOT fatal — the output runs silent and says why. pmgctl audio list / soak are the instruments, with --free-run to show what the open loop costs. Verified: 10 minutes against a real CoreAudio device at 59.94 with zero underruns and zero drops, and a live node driving NDI video with its audio on BlackHole, reporting buffer level, underruns, drops and corrections. Found while wiring it: the node never told the server what it was RUNNING, so Configure's editor opened on a stub for any node with real hardware — it now reports on connect and the server learns it once
A node reports its DeckLink (M6l) 🟢 the probe ADR 0024's editor was always reading from. pmg_node::capabilities() returned an empty output list behind a comment saying a real probe would land with pmg-out-decklink in M2 — and it never did, so Configure's Fill and Key pickers offered "no card reported" on a machine with a Quad 2 in it while pmgctl decklink list saw all eight connectors. pmg_out_decklink::probe() now enumerates them at boot, BEFORE the runtime opens its own outputs (reading a connector's modes means opening it, and one this node is already driving would refuse), degrading a connector that will not answer to "no modes" rather than losing the list. Reported per connector: name, key+fill, genlock presence and lock, reference format, audio pairs, and the display modes the card itself lists. paired_port stays None on purpose — the SDK does not report which connector pairs with which, and guessing would let Configure offer a fill+key pair spanning two cards, which is out of phase and looks fine. The mode picker now offers only what the selected connector reports, keeping a saved mode it does not offer visible and NAMED rather than silently dropping it. Verified on the real Quad 2: eight connectors, key+fill on [1] and [3], 16 modes each, genlock present and unlocked, 8 audio pairs
Named arrangements (M6k, ADR 0027) 🟢 Main owns the slides; an arrangement owns an order of GROUP NAMES. The 09:00 short version and the 11:00 full one, one song. An arrangement holds a list of section names — nothing else — which is what makes it follow Main by construction: add a line to the chorus and every arrangement using it grows one. An arrangement that stored SLIDES would be a second copy of the song, and the first typo would prove it. That is also why slides cannot be edited from inside one: there is nothing there to edit, and the browser says so. A group is the first consecutive run of a section name in Main — the first run, because a chorus sung twice in Main is the same slides twice (ADR 0026) and folding both runs together would make every arrangement sing an eight-slide chorus. Expansion mints an id per occurrence, so the cursor can still tell the second chorus from the first. The rundown pins the arrangement, not the playlist: the choice belongs to the service, and putting it on the song would make two services fight over one field. A song with no named groups gets no control and one line saying that naming them in Main is what unlocks it. Verified on the real "King Above It All": Main's seven groups read correctly (Verse 1 ×4, Chorus 1 ×4, …), a "09:00 short" of Verse 1 · Chorus 1 · Chorus 1 · Ending expanded to 13 slides with 13 distinct entry ids, one typo fixed in Main appeared at both chorus occurrences, and two rundowns of the same song came out 13 slides and 16
Edit: the library (M6j, ADR 0026) 🟢 an Item belongs to one playlist, and a playlist may repeat one. Ownership goes down, sharing moves inside. An Item was free-floating and joined many-to-many, so All items was the only complete view — and at a hundred songs of thirty slides that is three thousand rows, which is not a way to find anything. An Item now belongs to the playlist it was written for; copying into another playlist makes a SECOND Item, because two songs that both say "Verse 1" are two pieces of content and a shared instance is how a fix to tonight's set silently edits last week's. Inside one playlist the opposite holds: a chorus that comes round three times is one Item in three places, so a typo is fixed once — the ProPresenter behaviour, falling out of the model rather than bolted beside it. The cursor addresses an ENTRY, which is the part that could not have been patched later: "the cursor is on the chorus" is not an answer when the chorus is in the running order twice, and finding it by item id finds the first, so advancing past the second would jump backwards. next/previous stay item ids because every consumer of them fires a cue; next_entry/previous_entry sit beside them. All items is replaced by tags on the playlist (free strings, no tag table — a vocabulary nobody can add to is one nobody uses), a name filter, and a search over every Item's label and typed values, naming the playlist each result is in. Migration 16 gives every Item a home, duplicates any that were in several playlists, files the homeless into "Unfiled" rather than deleting content to satisfy a schema, and translates every cursor. Verified on a copy of the real rehearsal database: 46 items all housed, search found a lyric line and a sermon point by their text, one edit to a repeated chorus changed both places, and Show rendered five rows numbered 01–05 with the same slide at 03 and 05 and only 05 armed
Edit: rundowns and the inspector (M6i) 🟢 the first half of the Edit pass. The rundown rail rendered rundowns[0] and nothing else — one running order, created once, unnameable and unswitchable, with no way to start next week's without editing this week's. It is now a picker with rename, new, and delete; deleting takes the ORDER and leaves the playlists, because a rundown is an order over content and not the content. A playlist's name is editable where it is read, so a rail full of "Playlist 5" stops being the end state. Show gets the same picker once more than one rundown exists — remembered per browser rather than as a server-side "live rundown", because a booth screen and a wall display are different places and may honestly be on different shows. And the destination card stacks its controls one per line: three selects sharing a 360px inspector gave each about 100px, so "Fires with the take" rendered as "Fires with the tak" and the other options had to be guessed at. The remove control moved into the card's own header rather than competing with a select for width
Design UX pass (M6h) 🟢 the tab, gone over. (1) Templates export, not only import — Save to a file… writes the JSON mirror that Open a template file… reads, so the format stops being a one-way door: hand a template to somebody, keep one beside a service in version control, move one between servers. The mirror, not the compiled bytes — compiled output is addressed content for a node to stage, and a round trip that does not go through the compiler cannot silently stop matching it. Verified by exporting a document and feeding the exported file straight back through Open. (2) Assets have names. A hash is the right identity and a terrible label: 4a3b0d7b in a picker tells a designer nothing about which of four lower-third plates it is, and the file arrived with a perfectly good name the CAS threw away. An upload keeps its filename, the Media Browser renames in place, and every picker reads it. Renaming never touches the hash, a re-upload does not undo a rename, and a row that never had a name takes the next upload that does — so a library that predates names fills in as it is used. (3) The inspector, regrouped into Content · Layout · Style; it was one flat run of twenty labelled rows, where every control looked equally important and nothing marked where a concern ended. Each spacing edge is captioned rather than four bare boxes under the words "top right bottom left"; Width and Height stack, because two of them sharing a 360px pane left the number 42px wide beside its mode picker; the delete button is gone — a node is deleted from the menu or the Delete key. (4) The right pane's tabs fill the pane instead of tucking into its left corner, PUBLISHED moved out of the tab strip to sit beside the name it describes, and panels draw left and right hairlines only — a panel always sits between two chrome bands that draw their own edges, so its top and bottom borders landed beside those and read as 2px
Signing in (M6g, ADR 0025) 🟢 the client half of ADR 0013, which had none. The server implemented the whole model — scrypt PINs, eight-hour sessions, throttling, groups, 401 vs 403, an audit row per refusal — and the browser sent no station header, no token, and had nowhere to sign in. Harmless until somebody added the first user through the page built in M6e: permissions came on for good and the console was a lock with no keyhole. Now: the browser registers itself as a station on first load (automatic because ADR 0013's trusted unattended display can never sign in, so it must exist before anyone can trust it — named Chrome · macOS and renamed in Configure); signing in is a band of chrome under the header, not a modal and not a route, because a refusal can arrive mid-edit and both of those would unmount an unpublished Template to ask for a PIN; it opens from the header or by itself when the server refuses something, carrying the server's own sentence; somebody already signed in and refused is told they are short of an AUTHORITY, not a session. Anonymous stays read-only by design (ADR 0013's wall display) and the footer says so rather than leaving it to be discovered by pressing something. npm run reset-pin is the way back from a forgotten first PIN and npm run grant-admin from a server that holds no administrator at all — no HTTP route for either, physical access to the database is the authority — and the server REPORTS the second state (no_administrator) rather than leaving it to be discovered by being refused everywhere. Found by driving it: adding the first user turned permissions on and the shell did not know, so the footer of that very page went on saying the server was open; the "you are now an Administrator" sentence vanished into the refusal it had just caused; the Groups list showed Administrators as holding nothing because the query read the groups table and the permissions live in another one; Design's two uploads posted raw bytes with no headers, so once permissions were on they answered "the server answered 401" with no way to act; and the rehearsal account turned out to hold nothing — created by a server process started before createUser learned to grant the first user the wildcard, so signing in worked and every action was refused. createUser's guard only covers accounts made by a server that has it, and removing the last administrator from the group reaches the same state without any stale process
Timers, authored (M6c) 🟢 the round trip closes. Design's field editor grows a Kind picker — its own control rather than part of the label row, because the type is the contract every Item already bound against; apply_set_field_type stamps format 1.4 the way the editor that introduces a feature always declares it. Edit renders a timer as what it IS: a declaration, not a value — what it counts (down to a time, up from one, the time of day, or since the take), the instant, and how it reads — with a note when a countdown has no target, because without one it counts to now and sits at zero, which looks exactly like a graphic that has broken. Form state stays strings for every field; a timer rides as its own JSON and coerce parses it back, since String(v) on an object stores the literal text [object Object]. The server validates the declaration closely (kind, format, and that a countdown has something to count to) because it is the last place a person looks at it before Sunday, and toWireData maps it to {Timer: …} — the declaration on the wire, never a number. The closed sets are restated in server/manifest.js because the server never parses a SceneDoc, and a Rust test pins the JSON spelling so the two cannot drift silently. Verified live end to end on the rehearsal plant: a countdown template compiled at format 1.4 with no timer_needs_tnum warning, published, bound to an Item counting to five minutes out, taken to the LED wall over NDI — and the browser's program monitor showed six one-second samples, all six distinct, with the ink holding the same horizontal extent throughout (tabular figures doing their job). Found in the driving: the server's manifest validator had its own field-type allowlist, which refused timer at publish
Timers at playout (M6b) 🟢 the node runs the clock. DataValue::Timer carries the DECLARATION over the link and the number never does: the node formats it from its own clock, so a countdown keeps counting when the server dies. The engine holds the wall time (passed in, never read from the OS inside it — a countdown sourced from SystemTime::now() two calls deep cannot be tested, and a countdown nobody can test is one nobody should put on a wall); run_timers re-evaluates every live timer, compares the STRING, and rebinds only what changed, without a morph — the tween exists so a band grows into new text, and on a clock it would start a 400 ms animation every tick that the next tick interrupts. next_timer_due_ms shortens the runtime's socket wait to the exact boundary, so a clock changes ON the second instead of up to a poll interval after it. Measured: 0.087 ms mean / 0.168 ms worst per tick, and 0.0001 ms for a pass that changes nothing — about 1 % of one frame, once a second, and nothing at all for the 59 frames between or for any graphic without a timer. A template that vanishes under a live clock stops the NUMBER, not the graphic: taking a lower third off air mid-service because a clock could not re-solve is a far bigger failure than a stopped clock. Found by the integration test the unit tests agreed with: ms_until_change was off by one for countdowns and skipped a second at every boundary (0:06 → 0:04) — a whole-countdown frame walk caught what testing the formula against my own belief about it could not
Timers, rung 1 — the core (M6a) 🟢 M6's first rung, and the first data that changes with nobody touching it. ADR 0022: a timer is a pure function of the clock — format_at(spec, now, offset, taken_at), no I/O, no state, no failure mode — so the NODE computes it from its own clock and a countdown keeps counting when the server dies. Four kinds (clock, countdown, countup, elapsed) and seven ENUMERATED formats, because a strftime-shaped format string is a parser and a parser is a thing that fails at air time. A countdown floors at zero rather than going negative or blanking; minutes formats carry the hours (90 minutes is 90:00, not a lie that looks like a working clock); durations truncate. ms_until_change tells the node how long it may do nothing, landing exactly on the boundary so a clock does not drift a frame later every minute. Totality is a test, not a claim — it caught an i64 overflow on the first run, and everything is saturating now. Format 1.4: the timer field type, and — the half that outlives timers — the unknown-type escape FieldType should always have had: before it, adding a field type was a hard PARSE failure and an older reader could not load the document at all; now an unknown type is preserved, renders as whatever the Item stored, and marks the instance degraded, exactly as an unknown node kind does. The compiler warns timer_needs_tnum and NAMES the node, because proportional digits change width every tick. Two corpus figures at a pinned instant (108 total) rendered through the real formatter, chosen as 4:11 and 1:11 so every glyph after the first is identical between them — which makes the tabular-figure claim a MEASUREMENT (every following glyph within 1 px) rather than a picture two people could disagree about; an earlier draft compared ink extents and failed, because in a tabular face the advances are equal and the paint inside them is not
Timeline and canvas usability (M5ee) 🟢 five asks from real use, and a panic found on the way. (1) Right-click a key to delete it, or bare lane to add one THERE rather than at the playhead — the lane menu is the same ContextMenu the canvas uses, now its own module so the timeline can import it without a cycle. (2) The playhead snaps to keys within 8 SCREEN pixels — every key on every lane of the selected node, plus both ends, because the point is lining two properties up with each other; Shift, Control, or Alt turns the pull off (Alt because that is already the canvas's "no snapping" key). (3) Opacity inherits, folding down the tree the way transforms do — fading a band now fades the text on it, and a key on a container animates everything inside it. It is a per-node MULTIPLY, not a group composite: two overlapping children in a half-faded parent show through each other, which is pinned as its own corpus figure so the day a group pass lands, an image changes. A mask keeps its OWN opacity, since its container's fade already reaches the content. (4) A drag threshold of 4 screen pixels — a trackpad click drags the cursor a pixel or three on the way up, so every selection also nudged the node and the next act was Cmd+Z; under the threshold the press selects and nothing else. Found while verifying: a zero-width timeline (a narrow window collapses the centre column) divided by zero, scrubbed to NaN, and pmg_anim::eval panicked the wasm module — NaN compares false against both holds, fell into the interval search, and underflowed partition_point() - 1 to usize::MAX. Fixed at both ends: the UI cannot produce it, and eval is now total for any time and any key count, which matters because that function runs on the thread that draws frames. Also found: the Design test's session mock handed out a NEW ref object per render, so CanvasStage's useCallback([]) closure held a null session forever and every canvas pointer test had been passing vacuously. Verified live: a 2-pixel wobble click leaves margins at 0 while a 40-pixel drag sets one to 101; a scrub at 493 ms snaps to the key at 500 and holds 493 with Shift; right-click adds a key at 750 ms and deletes it again; and a child inside a 50 %% parent composites to alpha 191, which is 0.5 over 0.5
Masks and curve editing (M5dd) 🟢 the last two M5 features, both of which were format fields nothing honoured. Masks (format 1.3): kind: "mask" was reserved from the start and drew a box; it now masks its siblings and their descendants — the same rounded-box SDF, evaluated in the MASK's own space, so mask and content have independent transforms and a wipe reveal is the mask sliding while the plate and its type stay put. Radius shapes it, style.opacity fades it, a mask with no area reveals NOTHING (a wipe from scale 0 must start hidden), and visible_if on a mask masks nothing rather than everything. The compiler REFUSES a mask carrying fill, gradient, stroke, shadow, content, or children, and refuses nested masks — the first because a 1.2 reader draws a mask node as a plain box, so an unpainted one draws nothing and the old node shows the content unmasked (a survivable degradation) while a painted one would draw a rectangle across the graphic; the second because losing the outer mask reveals MORE than intended. min_reader stays 1; the manifest carries a mask_needs_1_3 warning. Design: Add a mask in the toolbar (a container swallows it, a leaf gets one beside it — a text node contains nothing), arriving at 100% of its parent so pressing the button changes nothing on screen; the inspector shows geometry, radius and opacity and NO paint, with one sentence saying why; scissors in the layer tree; a percent axis now reports percent instead of pretending to hug its contents. Curve editing: the format has carried arbitrary cubic Béziers since v1 and pmg-anim evaluated them, but the editor offered four names and showed everything else as "Custom" — a designer could pick a curve and never draw one. Now two draggable control points in CSS's convention, y free to leave [0,1] because the overshoot-and-settle IS the anticipation curve, x clamped because a control point off the time axis makes the curve a relation; committing on RELEASE (one undo entry per drag, not one per pixel), and the keyboard reaches the same handles. Verified live on the rehearsal server: a mask at opacity 0 took the canvas from 28 800 painted pixels to 0, width 120 over a 240-wide box cut it to exactly 14 400 with a clean edge at x=119, radius 40 took 630 more, undo walked every step back; and a curve dragged to cubic-bezier(0.12, 0.92, 0.72, 1.35) — the overshoot — committed on release with the preset select flipping to Custom. 11 mask figures joined the corpus (102 total), and the cross-target run found the FIRST non-bit-identical figure in the corpus's history: mask-transformed, 2 pixels of 147 456 at 1/255 in one channel, zero geometric displacement — a per-fragment matrix multiply is new here, likely multiply-add contraction, inside the S1 bar and now written down and watched
Golden corpus (M5cc) 🟢 M5's verification bar, met — the parity suite grows from 3 primitives to 92 figures. pmg_render::corpus holds 89 real SceneDocs covering fills, radii, opacity, strokes, shadows, 10 gradients, transforms, flexbox, clipping, images, timelines sampled MID-TWEEN, visible_if removal, theme tokens, the degraded-kind placeholder, and 22 text figures with wrapping, tracking, line height, outlines and shadows. Built in Rust rather than loaded from files, so both halves of a run construct byte-identical documents — fifty JSON files over HTTP would make parity a question about the web server. Armed through the SAME path a node runs (corpus::arm — solve, shape, pack, evaluate), and the images are deliberately NOT committed: a committed baseline needs a reproducible input and a GPU is not one. Native vs Chromium WebGPU, all 92: 0/255 max channel delta, 0.000000 px centroid shift — bit-identical, now including real shaped type (2026-08-20, M3 Max, Metal caveat unchanged). CI gained the corpus count as a gate (a PASS over 3 figures reads exactly like a PASS over 92) and a check that no text figure was skipped for want of a font. Font bytes are supplied, never bundled, and both sides print the face's blake3 so "we measured one typeface" is checked rather than assumed. Found by the growing, neither a parity failure: transform.skew is in the format and the renderer ignores it (now stated in the spec, pinned as transform-skew-ignored), and text align is relative to the widest LINE by design, so it is invisible on single-line text. Also found: clippy had been failing -D warnings on main — 5 errors in pmg-render that CI's quota outage hid; fixed, and the goldens byte-compared across the fix
Themes rung 2 (M5bb) 🟢 the theme manager, and the sweep — ADR 0021 complete for colours and fonts. The Themes dialog (Template tab) creates and edits themes in the browser: token rows (colour swatch / family+weight), add as colour or font, delete; Save hashes the canonical bytes in wasm and the server verifies the claim before storing. The mechanism underneath: theme_hashes, every hash a theme has EVER had — a template's manifest records the hash its values were resolved under and head moves on every save, so "which templates follow this theme" needs the trail, not the head (backfilled by migration, walked by GET /api/themes/:id/followers, which reports each follower current-or-stale WITH its mirror so the sweep can actually run). Apply is the sweep: per stale follower — mirror → migrate → rethemeDoc (a sessionless wasm static; arming every follower for a GPU it never paints with would be waste) → compile → publish with the new mirror → update-items — with per-template verdicts ("republished as 556d3e68 · 2 Items moved · X refused: …") and a follower without an editable copy REPORTED, never silently skipped. A swept template open in the editor reloads (dirty guard permitting), so the canvas shows what the server now holds. Verified live: edited Advent's brand.primary red→blue in the dialog, Save flipped the follower to "an older version", one Republish press — and the open Sermon quote's 28 800 red pixels were 28 800 blue when the dialog closed, the follower current, the new revision's manifest carrying the theme's new head. 3 new tests (58 web, 114 server)
Themes rung 1 (M5aa) 🟢 tokens in the format, format 1.2 — ADR 0021's first rung. Rgba and FontSpec carry optional token provenance and the document a theme stamp (the CAS address of the theme its values were resolved under); all additive with skip-serializing, so EXISTING revisions' bytes are untouched (goldens byte-identical, format-guarantees green). pmg_scene::theme holds the Theme type (colour + font tokens; assets are the seam) and resolve() — rewrite every tokened value from the theme, dangling token an ERROR not a fallback, idempotent, 4 tests. The migration chain grew its second real step (1.1→1.2 stamp, frozen fixture; the fixture tests now certify each STEP individually, exposed as public fns, since a chain test tied to "wherever the chain ends" would need fixtures edited — forbidden). Editors: bind/release colour slots and fonts to tokens (writing the token's current value in the same edit, so the doc always renders standalone), apply_theme (the sweep primitive), clear_theme (markers released, values kept). Themes live server-side as CAS-verified rows (POST /api/themes refuses bytes that do not hash to the claim, same as template publish; canonical bytes from the SAME Rust encoder via wasm themeHash or pmgctl scene theme-hash). Designer: Template tab grows a Theme select (apply / stale-stamp refresh note / detach), and Fill / Outline / Shadow / Font grow "Follows" token pickers — the value inputs DISABLE while bound, because the token is now the one place the colour comes from. Verified live end to end: seeded the Advent theme via pmgctl, picked it in Design, bound the box fill to brand.primary — 28 800 pixels turned Advent red the instant the token bound, the colour input locked — and the publish carried format_version {1,2} and the theme's hash in the manifest. Gradient-stop token pickers and the apply-sweep UI are rung 2
Design test suite (M5z) 🟢 the web gap closed. Design mode's chrome was verified live each rung but never PINNED — the one place "comprehensive testing" was an overstatement (an honest audit found Rust at 261 tests and the server at 111, but Design's React layer covered only by LayerTree and useHistory). Design.test.tsx now certifies the promises against a stub session speaking the wasm surface's language (the LayerTree.test pattern, with a mutable style state so a checkbox toggle ROUND-TRIPS through the generation bump): selection drives the panel tabs and a manual click sticks; the optional-style checkboxes add on enable and clear on disable with the controls appearing and collapsing; a text node has no Fill checkbox because its fill is the ink; the element menu opens with Paste honouring the clipboard, Duplicate pasting BESIDE, Cut copying then deleting, and the root refusing the destructive verbs; Cmd+C/X/V/D drive the same clipboard; a right-button press never begins a drag (the phantom-drag bug, now a regression test); and the revisions dialog shows the diff sentences and reports per-Item verdicts including the refusal by name. 9 tests, 55 web tests total — what the editors DO to a document remains Rust's job, tested in Rust
Context menus (M5y) 🟢 right-click grows up (a user request — "force of habit for many"). The canvas and the layer tree open the ELEMENT menu — Cut · Copy · Paste · Duplicate · Delete — at the pointer, right-click selecting what it hit first, the convention every design tool shares; Cmd/Ctrl+C, X, V, D do the same from the keyboard (text controls keep their native clipboard for words). The clipboard is in-app and its payload is DOCUMENT JSON — the format itself, so a payload pasted into next year's Design still parses. Paste resolution lives in Rust because it needs the tree: a container target swallows the paste, a leaf gets it as the next sibling, and beside forces sibling placement for Duplicate — a duplicated box must land next to itself, not swallow its own copy. Id policy is the interesting rule: every colliding id in the payload gets a fresh one, but a FREE id is KEPT — which makes cut-then-paste a MOVE whose timeline tracks keep addressing their nodes, while copy-paste re-ids the duplicate (which deliberately arrives without animations rather than half-addressed). Found and fixed underneath: right-click began a drag whose pointerup went to the browser's context menu — a phantom drag that edited the template on the next stray move (user-reported); the canvas now ignores non-primary buttons. Verified live with real right-clicks: menu at the pointer with Paste correctly greyed on an empty clipboard, Duplicate landing box-2 beside box-1, Cmd+X removing it, and paste from the empty-canvas menu bringing it back WITH its id
Gradients + the 1.1 migration (M5x) 🟢 a third kind of fill, and the format's first real version step. style.gradient — linear, CSS's angle dial (0° up, clockwise), 2–4 stops ascending, interpolated in LINEAR light (mixing encoded values grows grey through the middle), the gradient line spanning the rect's projection as CSS sizes one. Additive, so it is a MINOR bump: format 1.1, min_reader unchanged — a 1.0 reader ignores the key and renders the flat fill the editors keep equal to the first stop, which is the graceful degradation §9.2 promised. The shader walks 4 fixed stops with sequential mixes gated on a stop count of 0 meaning bit-for-bit the old fill path: goldens verified byte-identical. Direction comes from CPU sin/cos SNAPPED to a 2^-12 grid — libm's last bit is unspecified per platform, the same divergence class rule 1 banishes from shaders, arriving by the back door. And the version step made pmg_scene::migrate() REAL: the chain §9.3 promised now exists, with the frozen 1.0-to-1.1 fixture (compared as canonical bytes — what template_rev hashes), refusal of newer majors, and Design migrating on every open. Fill UI grows a Solid/Gradient type with angle, stop rows, and add-a-stop up to 4. Verified end to end: the designer box faded 756→588→300 luminance top-to-bottom, the published manifest declared {major:1, minor:1}, update-items moved all 22 lyric slides to the gradient revision, and the band ON THE PROGRAM OUTPUT faded green 111→64→21
Right-panel UX pass (M5w) 🟢 the inspector panel stops being a scroll of everything (a user request, all three asks). (1) Template · Inspector tabs: template-level facts — name, Used by, Revisions, sample data, fields — on one tab; the selected node on the other. Selection drives the tab (clicking a node is asking about it; deselecting makes the template the subject again) and a manual click sticks until the selection changes. (2) The rail's Media Browser section is gone — the toolbar button is the one door. (3) Optional styles are checkboxes, not add/remove button pairs: Fill (boxes — a text node's fill IS the ink and stays plain), Background image (boxes — an image NODE's picture is the point, not an option), Outline, and Shadow each head a section with a checkbox that applies the default when enabled and clears the style when disabled, controls shown only while on; the unfilled-box warning shows while OFF, when it is the consequence. Verified live: selecting the verse text auto-switched to Inspector showing ☐ Outline ☐ Shadow, checking Shadow expanded the section and changed 18 556 canvas pixels, unchecking cleared and collapsed it, and deselection returned the panel to Template
Revision UI (M5v) 🟢 update on-air Items, with a real diff. Design's Template & Publish panel grows a Revisions section: history with dates, the newest marked, and WHO IS PINNED WHERE — items counted by their own rev or any destination's. When Items render older revisions, one button opens the update dialog: per stale revision, the manifest-level diff as sentences (fields added — "required, so empty Items will be refused" — removed, changed; timelines as cue buttons appearing/going away; media staged; min_reader; rename), the affected Item labels, and the on-air caveat (what is on air keeps playing the revision it was taken with). POST /api/templates/:id/update-items moves ONLY this template's pins — an Item's destinations can pin DIFFERENT templates (the same words as lower third on lt, full screen on fs), and bumping every destination re-points the full-screen feed at the lower-third template; exactly that mistake was made BY HAND, twice, in rehearsal (found and repaired this rung), which is why the endpoint exists. Verdicts are per Item: the slide that cannot fill the new revision is refused with a sentence and stays pinned where it renders; failing all twenty for it would be an either/or nobody needs. 3 server tests incl. the cross-template guarantee; verified live twice — the endpoint moved all 21 stale Lyric slides (fs/wall dests untouched), and the dialog on Media test's real history showed a rich diff then returned BOTH verdicts in one press: "Moved 1 Item." and "Media sync test was not moved: lt · Graphics: reference is required." Known edge: script-published templates have no editable copy, so Design cannot open them to reach the section — the endpoint covers them
Text effects (M5u) 🟢 glyph outlines and drop shadows — the piece M5n deliberately split off. Style.stroke and Style.shadow now decorate text: the outline is a RING dilated OUTWARD from each glyph (an inside stroke eats thin letterforms, which is why every CG draws text outlines out; boxes keep their inside stroke — the asymmetry is now in the spec), and the shadow is the softened silhouette of the ink the viewer sees — the OUTLINED shape when there is an outline. All of it is arm-time CPU work in pmg_render::glyph_fx (disk dilation with the same linear-coverage AA rim the SDFs use; three exact-integer box passes for the blur; ring = dilated − source, so ring + fill reconstruct the silhouette with no seam — pinned by a unit test asserting exact reconstruction), landing in the SAME coverage atlas and drawn as three quad ranges per node — shadow, stroke, fill, all rings before any fills so tight kerning cannot put one glyph's outline across its neighbour's face. Two new fragment shaders that differ from fs_text only in which colour uniform they read; no knockout, matching CSS text-shadow where box shadows match CSS box-shadow. Effect-free text packs nothing extra: goldens verified byte-identical. Inspector outline/shadow sections now include text nodes. Verified on pixels in BOTH renderers (ADR 0001): designer canvas grew 17 846 white halo pixels on Add an outline, and the program output's white lyric carries 691 dark outline/shadow pixels it never had
New template in Design (M5t) 🟢 starting from nothing without a blank.json on disk. The Template Browser grows "New template…": a name, a canvas size (1080p · 720p · UHD · custom — an LED wall is rarely 1080p, so the size is asked, not defaulted), Create. The document is built by SceneDoc::blank in the FORMAT crate and crosses wasm as JSON — the browser authoring even an empty scene document would be the start of the second implementation ADR 0004 forbids. A canvas-sized transparent root and nothing else: no fonts (a boxes-only document arms without any, the M5a gate), no fields, no timelines — each arrives with the editor that introduces the thing needing it. Ids are browser-minted ULIDs (src/lib/ids.ts, the same fifteen lines as server/ids.js); publish already creates the template row for a fresh id, so the blank exists only in the session until the designer publishes it. Create is a SECONDARY button — the console's red means ON AIR, and this makes a document, not a broadcast. Pinned by a format test (a blank compiles with an empty manifest and no warnings) and verified live: named "Sermon quote", created, armed, a box added, published — revision listed, mirror stored, reopenable
Timeline cues in Show (M5s) 🟢 named timelines mean something at playout. The format reserved them in v1 (§6.1: In/Out have engine meaning, everything else "is played by an explicit cue") and the wire already carried the cue — but the node mapped every name to the In phase and Show had no button, so Update/Highlight/Loop were words in a spec. The engine now runs named timelines as RUNS layered over the base phase — start order, last writer per target wins per frame, the format's one blending rule — honouring loop (wraps), on_complete: hold (keeps asserting its final values, stops ticking), evict (the graphic authors its own exit), and goto (chains, In/Out included). Play restarts rather than stacks; Stop removes the run so the base shows through; a name the armed template lacks is refused as NoSuchTimeline so the operator learns WHICH button is wired to nothing; Out — taken or fired — clears the flourishes, because a held Highlight overriding Out's opacity track would pin the graphic on screen while it "leaves". Show: the layer card grows one chip per named timeline, from the PINNED revision's manifest (the document on air, not the head), lit cyan while the NODE reports it running — the light is the graphic moving, not the button hoping — and clicking a running chip stops it, the toggle a looping shimmer needs. Designer: a Loops checkbox on named timelines (refused on In/Out — a looping In never finishes arriving). 9 engine tests; verified live on program pixels: Shimmer swaying 2 014 px/600 ms until its chip stopped it to exactly 0, Highlight peaking 1 490 px then settling to 0 — Hold landing back on the base look — with the chip lighting and dimming itself from the node's own report
Media Browser as upload home (M5r) 🟢 one place media enters the system. The Media Browser grows to min(1040px, 94vw) with kind tabs (All · Fonts · Images · Videos) and per-section uploads: fonts and images straight to the CAS, videos through ingest — probed, long-GOP converted to all-intra, the job shown live in the dialog (probing… → converting… → landed) until it settles. Video rows carry the file's own name plus codec · dimensions · fps · duration from the probe. used_by now also names ITEMS whose data references a hash — "not used by any published template" was a lie for a field-driven clip that two Items play. Design's rail uploads collapsed to one Media Browser button; counts stay for diagnosis. Verified live: ingested a clip through the dialog (job row read "probing…", deduped to the existing hash), and the video row read rehearsal-bumper.mov · prores · 1280×720 · 30 fps · 4 s · used by Item "Test clip", Item "Test Video 124" · ON BOOTH-1
Monitor output picker (M5q) 🟢 watching is not operating. A take fans out to the ITEM's destinations (ADR 0018), so a full-screen fired from a lower-third rundown lands on another output — and a monitor pinned to one output made that success read as "I can't take it" (a user report, verbatim). Each output now reports its OWN preview port in OutputState (the node is the only honest source; the server advertising one global port was the lie), and the program monitor grows tabs — lt · fs · wall — that switch which feed the well shows, meters riding along per output. The tabs never change what a take does. Verified live: watching fs while taking the full-screen clip showed the pattern and fs's own −21.1 dBFS meters
Audio meters (M5p) 🟢 the program monitor meters what the plant hears. Every sixth frame the node takes this frame's peak per CHANNEL from the samples that just went to the wire and ships them as dBFS on the preview WebSocket — frame type 2, JSON, riding the same socket as the pictures they meter (one connection, one lifetime). The monitor overlays peak bars with 1.5 s peak-hold: channels 1/2 on the left edge, 3/4 on the right; a stereo output grows only the left pair. Zone colours are the instrument's own contract — green body, amber above −18, clip red in the top −6 — declared as --pm-meter-* tokens, deliberately a different hue than ON-AIR red. Found along the way: multi-output nodes had NO way to enable audio (--output had no audio key — the M3 soaks used the single-output flag); added, and the rehearsal outputs now carry audio=2. Verified live: the sine clip read −21.1 dBFS on both channels with peak-holds while playing, and "silent" with empty bars after the clip held its last frame
Media fields (M5o) 🟢 one template, every clip — image and video content can name a field, and the media then comes from the ITEM's data: a content address, validated (hasAsset) at save, carried as DataValue::Asset on the wire (which toWireData had already anticipated). resolve_media_fields turns the field into the concrete hash at ARM, on a copy, in every armer — browser, node, one implementation — and a key that IS a hash resolves directly in every ImageSource. The assignment now stages Item-data hashes alongside manifest assets, and the node sniffs and registers them at apply exactly like template assets. Designer: Add a video lands a full-canvas field-driven node (its clip field declared on the way in) with fit and loop/hold controls, drawn as a dark PLATE — the browser cannot decode broadcast codecs and an unchosen slot is not a degradation. Edit: image/video fields render as asset pickers valued by hash. Verified end to end: a generated 4 s ProRes clip ingested, a Video player template published from the designer, an Item choosing the clip by hash, booth-1 staging it (staged a clip at apply), a take applying, and the test pattern reading on the program output — 12 distinct hues where the plate showed one
Strokes, shadows, spacing (M5n) 🟢 the spec's reserved style fields become real. Stroke {color, width} draws INSIDE the edge (never grows layout) as an SDF band sharing the corner AA ramp; Shadow {color, dx, dy, blur} is a soft lobe of the node's own silhouette, drawn under it, its ramp linear in distance (rule 1: never fwidth) and knocked out under the silhouette — an unfilled outlined box shows no shadow through its hole, the same rule CSS settled on (found by the GPU test, not by luck). An unfilled box with a stroke now draws and is clickable. Uniform grew 96→144 bytes with alpha-0 as the absent encoding: goldens verified byte-identical, so effect-free content provably renders as before. Inspector: outline colour/width, shadow colour/opacity/offset/softness on boxes and images (text effects are their own rung), plus margin and padding typed numerically — margin being the fact a drag already edits, padding the room text needs off a band's edge. Verified on pixels: white outline at the edge, transparent middle, soft lobe below, and a typed margin moving the band
Clip at output scale (M5m) 🟢 a clipped node survives a scaled output — a 720p design on a 1080p output scales its geometry implicitly (the vertex shader divides by the canvas) but the scissor was set in raw design units and clamped to design size, so a clipped band in the lower half of the design drew entirely outside its own scissor and VANISHED on air, while the designer (1:1) showed it perfectly. render/render_with now take target_size as a required argument — assuming target==canvas is exactly the bug, so the compiler refuses the assumption — and the scissor crosses the design→pixel boundary explicitly. Pinned by a GPU test rendering a clipped bar onto a 2× target (present at the scaled position, still clipped past its scaled edge), and verified on the actual program output: the reference band on air at 1080p, pixels checked through the preview
Multi-font playout (M5l) 🟢 two fonts on air mean two fonts — the node's text_engine still ran the M1 shim (every face under every family), so two staged fonts collapsed to whichever registered first while the designer showed both: exactly the ADR-0001 disagreement, and it shipped because the rule lived in TWO places. Registration is now ONE function, pmg_text::build_font_db — faces under their own names first, the every-face shim only for families no face answers to (which is also how the --font stand-in keeps serving fontless rehearsals) — called by the designer session and the node alike. Guarded by a two-real-fonts test asserting two declared families select two different faces and an unmatched family still renders via the fallback
Guard, clip, fit-content, fields (M5k) 🟢 four asks from real use. Unpublished changes ask before they die: every edit marks Design dirty (publish or a load clears it); the mode tabs, opening a template, and opening a file all confirm first, and beforeunload covers the tab (src/lib/dirty.ts). Overflow: Hide — the format's clip exposed as a select on boxes; the mask a pop-out text animation needs. Fit contents per axis — Dimension::Content exposed beside the W/H inputs (Fixed / Fit contents each), so a band hugs its name field while its height holds; the number shows the solved size and is editable only when fixed. Fields editor in the right rail: add by key, edit label/sample/required, delete refused with the using node NAMED while anything references it; samples are live on the canvas (Design's shadow copy of samples removed — arming reads the document's). Also found and fixed: composing two session edits in one handler reads one base twice and the second discards the first — the keybar retime was silently DUPLICATING keys; now apply_retime_key, one document operation. All verified on pixels: band 499→192 hugging, →577 on a longer sample typed in the fields editor, 2 780 spilled text pixels masked to 0 by clip, refusal sentence naming name, retime leaving exactly one key, and the guard blocking then allowing a template swap
Keyboard audit + timeline playback (M5j) 🟢 space plays the animation, the habit every editor teaches: real-time rAF playback of the selected timeline (auto-picks In when nothing is chosen), pause on second press, restart from the end, scrubbing pauses, and a Play/Pause button in the timeline bar. Audit finding fixed in Show: the transport keys only worked while the rundown rail held focus — an operator who clicked the layer rack and hit space on the downbeat scrolled the page instead of taking. Show's keys (space/Enter take, arrows move-or-take by mode) now live on the window, with the guards both modes share: text controls keep their typing, focused buttons keep their native space-click, and handled keys always preventDefault so nothing scrolls. Verified live: space with body focus took an item on air in Show, and played/paused/restarted the In timeline in Design with the readout tracking wall time
Media Browser + real font names (M5i) 🟢 assets, and where they actually are. pmg_text::face_info reads a font file's OWN family and weight (typographic name preferred), so the inspector's family picker is a dropdown of what the files call themselves — picking one carries the file's hash onto the FontRef declaration, which is what a node stages. font_db registers faces under their real names first (the register-under-every-declared-family shim survives as fallback for older documents). The Media Browser dialog (Design toolbar) lists fonts by real name and images by thumbnail, each with used-by templates and a chip per adopted node — on it / copying / missing / not needed / no report yet — derived entirely from what nodes already report (GET /api/assets/sync: assignment's required hashes minus readiness misses, SyncProgress for the copy line; nothing new on the wire). Found and fixed underneath: assignments were only offered at connect and adopt — content changes now reofferAll() (digest short-circuited), so a new Item reaches a connected node immediately instead of at its next reconnect. Verified live against booth-1: Arial 400 required→staged→ON BOOTH-1, Arial 700 honestly "not needed"
Design images (M5h) 🟢 pictures on the canvas and behind shapes — image assets upload beside fonts (PNG/JPEG, carried by hash); Add an image lands an image node, and any box takes a background image that its radius clips and its children draw over. Fit modes: scale to fill (crops), scale to fit (letterboxes — implemented this rung: fit_uv now maps Contain as Cover's mirror, scale > 1, and fs_image fades the letterbox analytically with the same 0.5−d ramp as the corners; provably a no-op for cover/stretch pixels), stretch. Editors declare the AssetRef by hash — one hash, one key — and the session carries image bytes hash-keyed through an ImageSource decoding via pmg-media, arriving late without harm (degraded placeholder resolves when the CAS fetch lands). Verified on pixels: quadrant PNG through all three fits (letterbox transparent top+bottom), magenta background on the rounded band (corner clipped), pillarboxed contain on the wide band, remove restoring the fill, publish green, undo restoring the background
Design text nodes (M5g) 🟢 words on the canvas — Add text lands a Content-sized node saying "Text" at 48 px in the document's first family (a fontless document gets Default declared, and arming then asks for a font by name). The inspector edits content, family+weight (declaring a new FontRef when typed), size, and alignment; the fill IS the text colour. Typing ${field} declares the field — compile refuses undeclared references, so the editor that introduces one carries the declaration; verified through to the published manifest listing the field. Verified live on pixels: bound sample rendered in the flow, size 72 grew the ink block, clicking the glyphs on canvas selected the node, publish green, undo restored the exact 48 px bounds
Design layer tree (M5f) 🟢 structure on the left — the document's tree in the rail below the browser, selection shared with the canvas (one selection, two views). One Rust editor, apply_reparent(id, parent, index), is reorder, reparent-into, and un-parent at once (the index counts positions after the node leaves its old place; cycles and the root are refused with sentences). Drag with three drop zones — before/after as lines, INTO a container as a tint — plus Alt+arrows doing the same from the keyboard, which is also what jsdom tests can actually certify. Double-click renames via display_name; the id never changes, so timeline tracks keep addressing the node. Verified live: chip dragged into the band (canvas re-solved in agreement, pixels checked), inner dragged back out, rename, three undos restoring the exact original tree. 7 component tests against a stub session
Design keyframe timeline (M5e) 🟢 animation authoring — timelines created, renamed by convention (In, then Out, then timeline-N), retimed, and deleted from the panel; the selected node's six animatable properties as lanes with keys as diamonds on a shared ruler; a new key lands at the playhead carrying the value pmg-anim evaluates there (continuity by construction — adding a key never jolts the motion); a key inspector for time (delete-then-set, one undo step), value, and easing presets. Every edit is a pmg_render::interact doc editor returning a whole document, so undo covers animation with no second history. Verified on canvas pixels: two move x keys, linear mid-frame at 50% travel, ease-out mid-frame at ~93%, Cmd+Z reverting the ease with the scrubbed frame repainted. Found and fixed in the driving: rearm (&mut self) racing an in-flight renderFrame (&self held across the GPU readback await) — wasm-bindgen enforces Rust aliasing across the FFI boundary and panics with "recursive use of an object"; the re-arm now queues behind the frame in flight
Design undo/redo (M5d) 🟢 bounded whole-document history (100 snapshots) in useDocHistory — edits stack, loads start a new timeline, a new edit kills the future. Cmd/Ctrl+Z, Shift+Cmd+Z and Ctrl+Y (text controls keep their native undo), plus toolbar buttons that state their own reach. Whole snapshots rather than inverse ops, deliberately: every edit already arrives as a complete document from the Rust editors, and an inverse-op journal would be a second implementation of every edit. 4 hook tests; driven live through add → undo → redo with the canvas re-solving each step
Design add/delete (M5c) 🟢 Add a box (into the selection, else the root — fresh box-N id, arrives filled, fixed-size, in flow, and selected) and delete (inspector button and the Delete key; a container takes its contents; the root is refused with a sentence). Found and fixed in the driving: the selection-chrome effect was deselecting every freshly-added node — for one commit the new id does not exist in the old session, and a miss now only clears chrome; deselection requires a NEWER session to confirm the node is gone
Design properties inspector (M5b) 🟢 fill (colour + alpha, and REMOVE — an unfilled box is structural and says so), corner radius, node opacity, fixed size, and the placement pin (relative ↔ absolute, with the flexbox consequences stated). All edits go through pmg_render::interact doc editors and re-arm via DesignSession.rearm, which keeps the GPU device — an edit costs a solve, not an adapter request. Driven live: radius typed and rendered, fill recoloured, node pinned and dragged freely while its sibling held still
Design canvas interaction (M5a, ADR 0019) 🟢 the first authoring increment. pmg_render::interact — hit-testing with the shader's own rounded-box SDF, world transforms and clip rects SHARED with draw (one computation, not two), handles, drag with snap-to-edges/centres, and doc edits (move = margins, resize = fixed px). pmg-wasm exposes it on DesignSession; React draws the chrome at the coordinates Rust reports (SVG overlay in the canvas's own viewBox). Verified live: click → cyan selection → drag ghost → release → document edit → re-solve → chrome follows. 12 new Rust tests. Also fixed in passing: Design's canvas had rendered NOTHING since the linear-composite refactor (2026-08-17) — it drew into 8-bit while the pipelines target Rgba16Float; it now goes through LinearFrame + encode like the node. And the fontless gate moved into Rust: a boxes-only template arms with no fonts, which is how a designer starts from nothing
CI 🟢 GitHub Actions, public since 2026-10-10. Red from 2026-08-22 to 2026-10-12 without anyone seeing it: most runs never started (the private repo's spending limit), which hid three real failures behind them — cargo fmt, a jsdom EventSource race that only a slower runner loses, and the ALSA headers cpal needs on Linux. Green since
Rust toolchain on the dev box ✅ 1.90.0 + wasm32-unknown-unknown

What --selftest actually asserts today

Four crates carry real assertions, chosen because they guard the rules most likely to be broken by someone doing the obvious thing:

  • pmg-color — narrow/full range endpoints, and key chroma is neutral at every alpha in every range. That is the assertion that catches someone reusing the fill path for the key (ADR 0012 §3).
  • pmg-anim — exact rational frame time, timeline endpoints, holding outside the range, monotonicity, and that evaluating twice gives the same answer — which fails the moment something reaches for a clock.
  • pmg-scene — version gating in both directions; canonical CBOR round-trip and stable template_rev; JSON-mirror equivalence; and that every binding transform is total against a hostile input corpus (empty, whitespace, CJK, emoji, zero-width). A template must not be able to throw on air.
  • pmg-layout — the band contains its children, grows and shrinks with the name, collapses when the title is empty, and is deterministic across runs.

The remaining six suites return Ok(()). They are wired in so that adding real assertions is a one-line change rather than a question of where they go.


Spike results

Each brief in docs/spikes/ carries a results section, filled in with dated, hardware-attributed findings when it runs.

Spike Status Gates
S1 render parity PASSED on Metal 2026-08-14 — bit-identical; cross-backend untested M1
S3 DeckLink output internal-clock leg PASSED 2026-08-14; reference leg blocked on a generator M2
S6 WebGPU in browser largely answered 2026-08-14 — real adapter/device, 16384 texture limit, 244 KB bundle, 5 ms/figure. Not yet on the booth Macs M1
S2 text quality shudder ROOT-CAUSED AND FIXED 2026-08-18 — quad truncation of the AA tail; box 0.2049 → 0.0058 px, text 0.3412 → 0.0066 px. All measured criteria pass; see the brief's §A3 M5
S4, S5, S7, S8, S9 not started M3–M5

All three M0 spikes have now returned, and all three passed. ADR 0003's hand-written-shim approach sustains broadcast output with zero late frames; ADR 0001's shared renderer produces bit-identical output native and in-browser; and WebGPU is comfortably available in the browser.

None of the foundational ADRs is a bet any more — with one honest qualification: S1 measured Dawn-on-Metal against wgpu-on-Metal, so the cross-backend case (a Windows designer against a Mac node) is still untested.


Known gaps in the design itself

Places where the documents deliberately stop short, so nobody mistakes silence for a decision:

  • Cross-node frame-accurate takes are not designed, only anticipated. The at: field exists on Cue and is unused. ADR 0011.
  • Node redundancy is out of scope. A dead node is a dead output, and the accepted mitigation is a spare machine. ADR 0009.
  • SMPTE 2110 and PTP have no design beyond "do not foreclose them." There is no 2110 plant to develop against.
  • Dynamic data sources (the P2 input type) have no spec. Timers are P1 and land in M6.
  • The screen-content / ProPresenter-subsumption question is deliberately left open until M6 has real operating experience to answer it with.
  • Multi-user simultaneous editing of the same template is not designed. Current model is last-write-wins on a whole-template save, matching ProdMesh-Control. If two designers editing one template becomes a real need, it needs its own ADR.

What would change this page

The next three things that matter:

  1. Node-side YCbCr on a DeckLink, then S9 on a scope. The encode is built and tested against its reference, but it has not played on a card and nothing has measured the wire. Until both have happened it stays opt-in.
  2. Run the parity corpus on Windows (D3D12) and Linux (Vulkan), so ADR 0001's promise can be stated without the "measured on Metal" qualification. Now that the repo is public, this is the most useful thing a contributor with that hardware could do.
  3. S3's remaining legs at church, where there is a reference generator: the soak locked to blackburst or tri-level, and scope confirmation that a fill+key pair genuinely shares a clock.

M0's spikes were scheduled so the project would be allowed to fail early. It didn't. That is a good outcome and not a reason to stop writing down what is still untested.