Skip to content

feat(scene)!: the studio publishes a composition - #146

Draft
kixelated wants to merge 10 commits into
mainfrom
quest/m2/compose/layout
Draft

kixelated wants to merge 10 commits into
mainfrom
quest/m2/compose/layout

Conversation

@kixelated

@kixelated kixelated commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Advances the quest quest/m2/compose/layout.md, rewritten here to what remains: moving the composition into its broadcast's hang catalog once moq's timed catalogs are pinned.

Problem

The provisional live.kixel.studio.layers track named tracks of the program's own broadcast, so a guest published independently had nowhere to go: a guest's broadcast keeps its own timeline, and hang's include (moq-dev/moq#7943) requires an included broadcast to share the listing catalog's clock. The maintainer settled on 2026-11-12 that the studio publishes a composition, a catalog of catalogs: its own broadcast, with the program and every independently published source, local or guest, as a broadcast of its own, in a versioned format the studio prototypes.

Approach

  • moq_scene::composition (replaces layout): Composition v2 with canvas, start, program (a relative broadcast ref and its instance), sources (each child's relative broadcast ref, instance, signed microsecond offset, sound, priority, toggle), and pictures. Publisher writes it as a JSON snapshot track live.kixel.studio.composition on a broadcast at <--broadcast>/composition, whose hang catalog's media.clock is the program's.
  • Local published sources (publish.rs): each becomes a broadcast at <--broadcast>/source/<name> with its own hang catalog on the program's clock, replacing the renamed tracks on the program's broadcast. A local published source's name must be one path segment.
  • Guests: a broadcast source can now be published independently (Source::independent()). The studio publishes nothing more of it; the composition names it where the studio reads it, relative to the composition (../guests/alice), with the offset where the studio's playout put its timestamps on the program's clock (Feed::offset, from the newest shown picture or the playhead). A new composition follows a drift past DRIFT (10 ms), and a new instance starts afresh.
  • moq-studio --watch: now takes the relay URL plus --broadcast, since the composition's children are siblings of the program and must resolve under the connection's root. Each child plays as a Source::Layer on its own broadcast, its timestamps shifted by the offset onto the program's clock; a new offset updates in place without fetching again. A child whose instance another stream replaced shows nothing until a composition names it, and a composition naming another program instance waits for it (relay-minted instances are not compared).
  • README.md: "Published independently", "Composition" (was "Layer layout"), and "Watching" rewritten.
  • Quests: links to the deleted quest removed (server.md, hang-layers.md, compose README.md, m0/README.md); controls.md updated to the per-source priority and toggle this carries.

Decisions

Settled by the maintainer on 2026-11-12 (/quest-complete):

  1. ✅ A broadcast source that goes offline clears its picture, in the studio, for every broadcast source, not only published ones, and the composition drops the guest until they return (rejected: keep holding the frozen picture and qualify the README; merge with the findings open). Done here: follow clears what a stream showed once it ends or another replaces it, Feed::lands lost its offset memory, a layer keeps its strip and fetches sound only while a mix routes it (so a sound change no longer reopens it), and a still is never stalled.
  2. ✅ Keep this shape: child entries carry sound, priority, and toggle, and placements live in pictures naming a child, so one broadcast can show in several places (rejected: one placement per child, as moq's spec quest said). moq's quest/m3/layers/spec.md now matches (moq-dev/moq#8592).
  3. ✅ Wait for hang's timed catalog rather than ship the interim JSON snapshot track (rejected: live.kixel.studio.composition as an interim track). The composition belongs in the composition broadcast's hang catalog, applied at each update's timestamp (moq's catalog-at-timestamp, moq's composition plan). The code is not restructured onto the catalog here; the quest is rewritten to that remaining work, requiring a new pin quest.

Settled by the maintainer on 2026-11-12 (/quest-complete), second round:

  1. ✅ Fix published sources' audio here, settling quest/m2/compose/hear.md as its Plan suggested (deleted here): Listen and guests' return feeds hear published sources, while the encoded program, published scenes, and recordings still leave them out (rejected: keep it a follow-up and accept the gap; don't offer publishing guests yet). Done: Sources::listened is a second mix for each audio scene reaching a published source, read by the monitor, each return feed (and its mix-minus), and the audio scenes' meters; Sources::mix stays the encoded one.
  2. ✅ This PR stays a draft until hang's timed catalog is pinned and the composition moves into the catalog (rejected: landing now with the interim track).

Choices made without asking (for review)

The quest's "Recommended, not decided" items, and other judgment calls:

  • The composition's clock is the program's, so the program has offset 0 and local sources are stamped on it at 0. (Recommended.)
  • Per-child fields, but placement stays in pictures: a child entry carries sound, priority, and toggle; placement, crop, filters, visibility, and keyframes stay per picture, because one source can be shown by several items (clones, the top layer). So controls.md's toggle/priority are per subscription, as it needs. (Recommended item, adjusted.)
  • Each change is a new snapshot group stamped on the program's clock, as the layers track was. (Recommended.)
  • Format version 2; a player refuses any other version loudly and plays the program alone.
  • DRIFT = 10 ms, so the playout clock's jitter does not publish a group every frame, while staying well inside the ~45 ms sound-before-picture detectability threshold (ITU-R BT.1359).
  • Paths: the composition at <--broadcast>/composition, local sources at <--broadcast>/source/<name>. A published scene named composition is refused, since it would publish at the composition's path.
  • The composition is always published beside the program, even with no published sources, so a player can tell "nothing over the program" from "no composition".
  • Instances: the program's and each guest's instance are listed so a player never applies an offset to a stream it was not measured for; relay-minted (~) instances are left out, since another relay mints its own.
  • Any broadcast source can be published, not only a room guest's; the relay must let viewers read it where the studio does.
  • A guest the studio shows nothing of (offline, no offset measured) is left out with its pictures, rather than listed with a guessed or remembered offset.
  • A guest's sound stall: the watcher's layers play a guest's pictures on through a stall of the guest's own sound, as the maintainer's 2026-11-12 decision in quest/m0/pin-sync.md has the studio do once that quest lands; until then the studio holds a guest's picture through such a stall and a viewer does not.
  • No moq change is needed: everything builds on moq-net's relative paths and instances and moq-mux's catalog and JSON snapshot at the current pin.

Impact

Breaking, on the wire and for users:

  • The live.kixel.studio.layers track and the program broadcast's renamed per-source tracks are gone. Viewers read <--broadcast>/composition and each listed broadcast instead. Migration: a player of layout v1 must read the composition (v2); there is no other player but --watch.
  • --watch https://relay/anon/studio becomes --watch https://relay/anon --broadcast studio.
  • A local published source's name must now be a single path segment; a scene file with one named a/b fails validation and must rename it.
  • A plain player of the program sees no change: it plays <--broadcast> as before.

Public API and wire impact

  • moq_scene::layout → moq_scene::composition (Composition, Program, Child, Version, Publisher, TRACK, PATH, SOURCES, DRIFT).
  • Source::Layer now { clock, path, instance, offset } (was a track of the program's broadcast), always with a strip, fetching its sound only while a mix routes it; Feed::offset becomes Feed::lands returning Landing { instance, offset }, None while the source shows nothing of a stream; a Source::Broadcast without video shows an image still; new Source::independent() and Source::plays_alike() (an offset-only layer change keeps its feed and picture).
  • New Sources::listened(scene): the mix people in the studio hear, published sources included; Sources::mix is the encoded one.
  • publish::Publish::forward takes the origin and base path to publish each source's broadcast.
  • Wire: new broadcast <b>/composition with track live.kixel.studio.composition (JSON snapshot, v2); new broadcasts <b>/source/<name>; the layers track and renamed tracks removed.

Alternatives

  • Placement on each child entry (as recommended) would duplicate a source for each picture, and toggle/priority would no longer be one subscription each; kept per picture.
  • Offsets measured from catalog clocks alone (media.clock wall times) would ignore the studio's playout delay, so viewers would show guests earlier than the studio did; the offset is where the studio actually played it.
  • Following a specific instance in --watch (follow(path, Some(instance))) never matches relay-minted instances, so the watcher follows the path and skips a non-matching unminted instance instead.

Validation

  • just ci passes (fmt, clippy with every feature, 312 tests).
  • just render-sw lines for moq-scene compositor and moq-studio engine/watch on lavapipe pass (55 + 8), including the new end-to-end watch::tests::plays_a_guest_where_the_studio_did, added to render-sw: a relay, a guest broadcast on its own clock started 1.5 s before the program's, and the studio publishing it independently. Read off the relay, the composition lists ../guests/alice once, and its offset is checked against a model: offset - (guest wall clock - program wall clock) must equal the studio's measured playout delay for alice, within one frame plus DRIFT. A watcher built from that composition plays layer/alice through the relay to Live.
  • Real binaries under headless sway (pixman renderer, lavapipe): moq-relay (moq's localhost.toml), a second moq-studio publishing a green program at guests/alice, the studio (blue background, alice and a local red badge published independently) at studio, and moq-studio --watch http://localhost:14443/ --broadcast studio, all on moq-lite-07-wip. The watcher's window showed the blue program with the green guest at (40,40) 480x270 and the red badge at (1000,560) 200x100 scaled to the window, matching the studio's Program panel, and opened two H.264 decoders (the program and the guest; the badge is a still).
  • a_replacement_track_keeps_its_delay also checks a video-only broadcast keeps its landing while a replacement video track has shown nothing. The offset-change wake (offsets.changed() in follow_video/follow_audio) has no deterministic test: a paused-clock test cannot fill the decode queue reliably, since decoding runs off the runtime.
  • just ci (315 tests) and the lavapipe compositor (55) and engine/watch (7) lines pass on da69368.
  • source::tests::a_published_source_is_heard_but_not_encoded: a published source leaves the encoded mix, stays in the listened one, leaves that on restart, and the second mix goes once nothing is published.
  • source::tests::a_broadcast_shows_a_still (and never stalls), a_layer_fetches_only_the_sound_it_plays (the audio track's subscriber demand comes and goes as the layer enters and leaves the audio scene, with no reopen), and a_broadcast_lands_on_a_timeline (offline, the feed clears its picture and lands nowhere).
  • compositor::tests::a_layer_moved_on_its_clock_keeps_its_picture (lavapipe, in render-sw): an offset-only change keeps the layer's feed and its picture on the next render; it fails with the previous equality check.
  • Unit tests cover the format round-trip and version refusal, the publisher's dedup and drift hysteresis, offset measurement (positive, negative, and read later), timestamp shifting, layer follow across instance and path changes, an offset change keeping the feed, collection building and refusals in the watcher, and waiting compositions.

Follow-ups

Waiting

Caused by

None: new work replacing a provisional format.

Models

Size: L
Implementer: Claude Code, claude-opus-5-5, unknown effort
Implementer: Claude Code, claude-opus-5-5, low effort (iterate, M)

🤖 Generated with Claude Code

(Written by Opus 5.5)

Review

Rewritten by quest review after each round.

  • Head: 1a0107c, named in full once no reviewer reports a finding

  • Base: origin/main at 5b6c2c8

  • Reviewer: Codex, gpt-6.1-sol, xhigh effort

  • Session: 01a1d1a0-0006-76d1-994e-8b9f8af2a769, round 3

  • Score: 10/10, Code review found no new blockers in the incremental audio routing changes; builds and tests were not run, as instructed.

  • Findings: none

  • Reviewer: opencode, opencode-go/deepseek-v4.1-flash, high effort

  • Session: ses_e2e5ff872ffebc3XwH7XgtIRU7, round 3

  • Score: 8/10, The earlier monitoring gap is fixed: the monitor and guest return feeds now read a second mix that routes published sources while the program, recordings, and published scenes keep the encoded mix, but the mixer's scene meters and waveforms still read the encoded mix, so they disagree with what Listen plays.

  • Findings, open until the next round settles them:

    • Low, rs/moq-studio/src/ui/levels.rs:80: Audio-scene meters still read the encoded mix while Listen plays the listened mix
  • The last round's findings, at 6c47d61:

    • Fixed: high, rs/moq-scene/src/source.rs:447: Publishing a guest makes the studio stop hearing that guest; Sources now keeps a second listened mix for each audio scene reaching a published source (source.rs Sources::listened and the sync routing), the monitor picks it at engine.rs:1314, the return feed uses it at feed.rs:103, while the program, recording, and published-scene audio still use Sources::mix, and a_published_source_is_heard_but_not_encoded covers routing, leaving on restart, and dropping the second mix when nothing is published.

Round 3's one low finding (opencode, rs/moq-studio/src/ui/levels.rs:80, audio-scene meters read the encoded mix while Listen plays the listened one) is fixed on da69368: the meters read Sources::listened, and the README's mixer paragraph says so.

@kixelated kixelated changed the title The studio publishes a composition feat(scene)!: the studio publishes a composition Nov 12, 2026
kixelated and others added 2 commits November 12, 2026 10:55
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… fetches only the sound it plays

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…omposition waits for timed catalogs

A broadcast source clears what a stream showed once it ends or another
replaces it, so the composition drops an offline guest instead of
remembering its offset. A layer keeps its strip and fetches its sound only
while a mix routes it, so a sound change no longer reopens it, and a still
never reads as stalled.

The quest is rewritten to moving the composition into its broadcast's hang
catalog, which requires the new pin-timed-catalog quest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Contributor Author

Design change: after the round-3 design checkpoint, the maintainer chose (2026-11-12, /quest-complete) that a broadcast source going offline clears its picture, in the studio, for every broadcast source, and the composition drops the guest until they return (rejected: holding the frozen picture and qualifying the README; merging with the findings open). On b2c6598:

  • Driver::follow clears what a stream showed once it ends, fails, or another stream replaces it, and Feed::lands no longer remembers a past landing, so an offline guest leaves the composition (finding 4, README late joiners, goes with it).
  • A layer always has a strip and fetches its sound only while a mix routes it; Source::Layer lost sound, and plays_alike ignores it, so a sound change never reopens a layer (findings 1 and 3).
  • A broadcast or layer showing a still is never stalled (finding 2).

Also settled: the composition keeps #146's shape (child entries carry sound, priority, toggle; placements in pictures), and it moves into the composition broadcast's hang catalog once moq's timed catalogs are pinned, rather than shipping the interim track. The quest is rewritten to that work and requires the new quest/m2/compose/pin-timed-catalog.md. The review restarts with quest review --new.

(Written by Opus 5.5 with low effort)

…eps its landing

A layer's offset is a watch, so playback finds its moments again when the
composition moves it, even with its queue full. A broadcast source keeps
where its stream last landed while a replacement video track has shown
nothing, so a video-only guest stays in the composition. The README's
example offset is a realistic one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated and others added 2 commits November 12, 2026 14:45
Each audio scene that reaches a published source gets a second mix, which
Listen and guests' return feeds read, with that source in at its gain,
mute, pan, and delay; the program, recordings, and published scenes still
encode the mix without it. This settles and deletes quest/m2/compose/hear.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated added a commit that referenced this pull request Nov 13, 2026
… the pin moves

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated added a commit that referenced this pull request Nov 13, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant