Relations for long-form chaining (#29)
Status: plan for owner approval. §7 (conditional branching) is built (owner, 2026-10-26: “add the conditional branching to the #29 plan … implement it”). §1–§6 are not built.
A long piece is a chain of parts. Today an agent writes it as absolute seconds and enumerated keys, and the cost grows with the length (Muse’s architectural review, 2026-10-25: a 60 s chunk was 2,002 keyframes, ~76× the JS route). The relations below let the piece be declared instead: what follows what, what repeats over a set, what reacts to what, and which way the piece goes.
The tester’s list (staged queue 2026-10-14, in the tester’s order)
| § | Relation | What it replaces | Status |
|---|---|---|---|
| 1 | Sequence then / after (+offset) |
absolute seconds; a 14 s rhythm-cut edit took ~246 native steps | not built |
| 2 | Collection relations with per-member phase (wave phase = index × Δ, ring forming, flock), declared once over a set | one track per member | not built |
| 3 | A working parametric follow (spring_follow loads; declared lag/stiffness; a follower never leads at t=0) |
hand keys behind a leader | not built |
| 4 | Declared event triggers on_complete / at(beat); one hook clock across choreograph, keyframe, morph and rig tracks |
timers in render hooks | not built |
| 5 | Honest moves_along_path: duration honoured, per-second units, both forms |
per-frame speed | not built |
| 6 | Shot and cue relations: a camera tracking/looking at an item; soundtrack cues anchored to an item’s beat; cue gain honoured | camera keys; cue times | not built |
| 7 | Conditional branching: which scene or part comes next | one chain per variant | built |
Director integration (mcp-cloud): the director writes absolute seconds today. Once §1 exists it should emit relations, and absolute seconds keep working (today’s films and tests depend on them).
§7 Conditional branching (built 2026-10-26)
A chain is no longer only a straight line. SceneManager.createChain(spec, { vars }) takes:
sm.createChain([
'intro',
{ if: { var: 'brand', eq: 'acme' }, then: ['acme_logo'], else: ['generic_logo'] },
{ sceneId: 'price', when: { var: 'price', gt: 0 }, duration: 4000 },
'outro',
], { vars: { brand: 'acme', price: 12 } });
// → { ok: true, sceneIds: ['intro','acme_logo','price','outro'],
// branches: [{ at: 'chain[1]', took: 'then' }, { at: 'chain[2]', sceneId: 'price', took: 'played' }] }
- Conditions are the engine’s ExpressionIR predicate subset, the same language
on_event_fire_ifuses (const,param,cmp,logic), plus a hand-writable shorthand compiled to it:{ var, eq|neq|gt|gte|lt|lte },{ var }(truthy),{ all: [...] },{ any: [...] },{ not }. Branches nest. - Decided when the chain is built, from
vars. A rendered video plays one fixed path, so the decision is repeatable: the same spec and vars give the same chain, and the exporter (SceneChainTimeline) plays exactly the branch taken.setChainVars(vars)decides the same declaration again. - The declaration is kept, not only its outcome (review AR-2):
exportScenes()saveschainSpec,chainVarsand the decisions beside the resolvedchainevery older reader plays;importScenesrestores both, so a reload can decide again.getChainBranches()reads them back. - Per-entry
duration,transition,transitionDuration,cutSound,loopride on a{ sceneId }node and are validated likecreateChain’s options.
The rules (review AR-6: “a rule, not a discovery”)
| Case | Rule |
|---|---|
| Condition true | the then scenes play, in place |
Condition false, with else |
the else scenes play, in place |
Condition false, no else |
nothing is inserted: the chain closes up (the scene before cuts to the scene after). Not hold, not a default |
when false on a scene |
that scene is skipped, the same way |
| Every decision | reported in branches (then / else / played / skipped, with its path), in the result and in getChainBranches() |
A variable the condition reads but vars lacks |
false, named in that decision’s missing, and a chain-branch-missing-var warning; never a silent else |
| Unknown op, malformed node, nesting > 32, bad transition or cut sound | refused before anything changes, with the reason; the previous chain stays |
A condition reading scene data (graph) |
refused: the scenes are not loaded when the chain is built. Pass the value as a var |
| Every branch left the chain empty | refused, naming each decision |
Item-level branching already exists: on_event_fire_if fires event when its predicate holds, else else. With no
else the false arm fires nothing. It does not yet report which arm it took; that belongs with AR-1 (receipts).
Proved by
__tests__/ChainBranching.test.js (12): branch taken and not taken; no else; missing var named; nesting with
per-entry fields; the shorthand and the IR decide alike; each refusal’s reason; determinism; on the real
SceneManager: the resolved chain, what the exporter shows second by second, setChainVars, a refusal that leaves
the old chain, a plain chain unchanged, and export/import keeping the declaration.
Not in §7 (next phases, need approval)
- Viewer-chosen branches in an interactive widget (a quiz or “choose your path”): decided at play time by a click or an event, so it needs the widget runtime to carry chains. Separate design.
- Branches in the cloud’s long-form films (
docs/plans/LONG_FORM_FILMS.mdin mcp-cloud): the director’s plan could carryifparts decided from a template’s values. Server-side; after the engine form is settled. - The MCP surface: an agent reaches it today through engine code (
app.sceneManager.createChain). A declared tool, and the mcp-serverengine-surface.tslisting ofsetChainVars/getChainBranches, come with the next mcp-server release.
The architectural review (Muse, 2026-10-25)
The review’s eight asks (receipts everywhere, unexpanded declarations as the stored form, /graph:query, semantic
ids, generated schemas, composition rules, export receipts, pattern coverage) overlap this plan in two places, both
answered for §7 above: AR-2 (store the declaration) and AR-6 (publish the if-then rules, including the false
branch). The rest is a separate plan for the owner to prioritise.