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_if uses (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() saves chainSpec, chainVars and the decisions beside the resolved chain every older reader plays; importScenes restores both, so a reload can decide again. getChainBranches() reads them back.
  • Per-entry duration, transition, transitionDuration, cutSound, loop ride on a { sceneId } node and are validated like createChain’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.md in mcp-cloud): the director’s plan could carry if parts 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-server engine-surface.ts listing of setChainVars / 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.