diff --git a/AGENTS.md b/AGENTS.md index 1f9b1fc..ad6bf3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,8 +154,11 @@ no `Math.random()`/`Date` (use `wiggle` with a seed, or pass a `seed` knob). start, the address a clip `start`/anchored title resolves to). Because no shot references another, a shot is **structurally editable by overlay and survives regen**: reorder via the beat `order` patch, drop via `removeTimeline: ["shot-2"]` (its layer just stays invisible), - swap its image via a `nodes..src` patch — see `docs/guides/regen-contract.md` and - `examples/overlays/montage-restructure.json`. Stable addresses `${id}-${i}`; labels + insert via `insertNodes` + `insertTimeline {into:"montage"}` (the "montage" beat groups the + shots directly so its play order is addressable; the new shot's node+beat JSON is authored by + the consumer, not generated), swap its image via a `nodes..src` patch — see + `docs/guides/regen-contract.md`, `examples/overlays/montage-restructure.json`, and + `examples/overlays/montage-insert.json`. Stable addresses `${id}-${i}`; labels `shot-${i}` (beat = shot start), `shot-${i}-in`/`-kb` (fade-in / Ken Burns), `cross-${i}` (crossfade into shot i), `shot-${last}-out` (closing fade) — every generated tween is labelled, so the motion is fully addressable / lint-clean. Each layer uses the image diff --git a/CHANGELOG.md b/CHANGELOG.md index 98ecb7b..c64723b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ versions may change them. ## [Unreleased] +## [0.6.41] - 2026-06-21 + +### Added + +#### Structural insert — `insertNodes` + `insertTimeline` overlay verbs (Spine 2c) + +- Two positioned-insert compose verbs complete the structural-editing CRUD surface + (add / remove / reorder / **insert**): + - **`insertNodes`** — insert a node at a position (`before`/`after` a sibling root id, + or an `index`) instead of appending on top like `addNodes`, so a new layer can land + UNDER later nodes (e.g. a montage shot below the vignette/scrim grade). + - **`insertTimeline`** — splice a step/beat into a named beat (`{ into, before/after/index, + step }`). The step's tween/motionPath targets must exist; unknown `into`/`before`/`after` + or a missing target is reported as an orphan, never a silent drop. +- `photoMontage`/`videoMontage` now group the shot beats **directly** under the `"montage"` + beat (the redundant inner `seq` wrapper is gone — timing-equivalent), so the play order is + addressable: `insertTimeline { into: "montage", after: "shot-1", step }` splices a shot into + the sequence. Reorder (`order`) and `removeTimeline` are unaffected. +- Unlike reorder/remove (which patch existing addressable elements), insert *creates* + elements, so the overlay carries the full node + beat JSON — a consumer (reframe-studio) or + the author supplies it; reframe does not generate the montage shot payload. See + `examples/overlays/montage-insert.json` and `docs/guides/regen-contract.md`. + ## [0.6.40] - 2026-06-21 ### Added diff --git a/docs/guides/regen-contract.md b/docs/guides/regen-contract.md index cb118b9..4c9a1f5 100644 --- a/docs/guides/regen-contract.md +++ b/docs/guides/regen-contract.md @@ -41,6 +41,14 @@ timeline — and those edits survive regen the same way, keyed by stable labels: by label. The surrounding `seq` re-accumulates, so later steps ripple up and any label-anchored dependent (a clip `start`, an anchored title) follows. An unknown label is reported as an orphan, never a silent drop. +- **Insert** — `insertNodes` places a new node at a position (`before`/`after` a + sibling id, or an `index`) instead of appending on top like `addNodes`, and + `insertTimeline` splices a step/beat into a named beat (`{ into, after, step }`). + Together they add a whole new unit. Unlike reorder/remove (which patch existing + addressable elements), insert *creates* elements, so the overlay carries the full + node + beat JSON — a consumer (reframe-studio) or you author it; reframe does not + generate the shot payload. Unknown `into`/`before`/`after` or a step targeting a + missing node is an orphan. These ride the addressable surface a `photoMontage` already exposes — each shot is the named beat `shot-${i}`, so `removeTimeline: ["shot-3"]` drops a shot (its layer @@ -52,7 +60,8 @@ or post-compose validation rejects the dangling anchor. Reordering a shot to the first slot drops its opening fade-up, since the crossfade offset was baked for the original order — a cosmetic detail, not a break.) -See `examples/overlays/montage-restructure.json`. +See `examples/overlays/montage-restructure.json` (reorder + remove) and +`examples/overlays/montage-insert.json` (insert a hand-authored shot). ## Tooling diff --git a/examples/overlays/montage-insert.json b/examples/overlays/montage-insert.json new file mode 100644 index 0000000..39292c0 --- /dev/null +++ b/examples/overlays/montage-insert.json @@ -0,0 +1,49 @@ +{ + "reframeOverlay": 1, + "name": "montage-insert", + "target": "photo-montage", + "insertNodes": [ + { + "before": "shot-vignette", + "node": { + "type": "image", + "id": "shot-x", + "props": { + "src": "photo-montage/03.jpg", + "x": 960, + "y": 540, + "width": 1920, + "height": 1080, + "anchor": "center", + "fit": "cover", + "scale": 1, + "opacity": 0 + } + } + } + ], + "insertTimeline": [ + { + "into": "montage", + "after": "shot-2", + "step": { + "kind": "beat", + "name": "shot-x", + "parallel": true, + "gap": -0.7, + "nodes": ["shot-x"], + "children": [ + { "kind": "tween", "target": "shot-x", "props": { "opacity": 1 }, "duration": 0.7, "ease": "linear", "label": "shot-x-in" }, + { "kind": "tween", "target": "shot-x", "props": { "scale": 1.14, "x": 1010 }, "duration": 3.4, "ease": "easeInOutQuad", "label": "shot-x-kb" }, + { + "kind": "seq", + "children": [ + { "kind": "wait", "duration": 2.7 }, + { "kind": "tween", "target": "shot-x", "props": { "opacity": 0 }, "duration": 0.7, "ease": "linear", "label": "cross-x" } + ] + } + ] + } + } + ] +} diff --git a/packages/core/src/compose.ts b/packages/core/src/compose.ts index 237f3a9..ec08554 100644 --- a/packages/core/src/compose.ts +++ b/packages/core/src/compose.ts @@ -81,6 +81,25 @@ export interface OverlayDoc { * to a number — or post-compose validation rejects the dangling anchor. */ removeTimeline?: string[]; + /** + * Insert complete nodes at a POSITION (vs `addNodes`, which only appends at the + * root end / paints on top). Owned by this overlay. Position is `before`/`after` + * a sibling root-node id, or a numeric `index`; absent = append. Lets an inserted + * layer land UNDER later nodes — e.g. a new montage shot below the vignette/scrim + * grade (`{ node, before: "shot-vignette" }`). An unknown `before`/`after` id is an + * orphan; a duplicate id surfaces via post-compose validation. + */ + insertNodes?: { node: NodeIR; before?: string; after?: string; index?: number }[]; + /** + * Insert a timeline step/beat at a POSITION inside a named beat (vs `addTimeline`, + * which appends a fragment in `par`). `into` = a beat NAME (the only addressable + * timeline container); position is `before`/`after` a child's label/beat-name, or a + * numeric `index`; absent = append. The STRUCTURAL insert complement of + * `removeTimeline` — e.g. splice a hand-authored shot beat into a montage: + * `{ into: "montage", after: "shot-1", step }`. The step's tween/motionPath targets + * must exist (orphan otherwise), as must `into` and any `before`/`after`. + */ + insertTimeline?: { into: string; before?: string; after?: string; index?: number; step: TimelineIR }[]; } export interface ComposeReport { @@ -95,7 +114,9 @@ export interface ComposeReport { | "behavior-set" | "behavior-remove" | "add-timeline" - | "remove-timeline"; + | "remove-timeline" + | "insert-node" + | "insert-timeline"; }[]; orphans: { layer: string; address: string; reason: string }[]; warnings: string[]; @@ -393,6 +414,29 @@ function applyOverlay( applied(`removeNodes.${id}`, "remove-node"); } + // --- inserted nodes: positioned at root (vs addNodes which only appends on top) --- + for (const spec of overlay.insertNodes ?? []) { + const node = spec.node; + let at = ir.nodes.length; // default = append + if (spec.before !== undefined || spec.after !== undefined) { + const refId = spec.before ?? spec.after!; + const refIdx = ir.nodes.findIndex((n) => n.id === refId); + if (refIdx < 0) { + orphan( + `insertNodes.${node.id}`, + `unknown ${spec.before !== undefined ? "before" : "after"} node "${refId}" — known root ids: ${ir.nodes.map((n) => n.id).join(", ") || "(none)"}`, + ); + continue; + } + at = spec.before !== undefined ? refIdx : refIdx + 1; + } else if (spec.index !== undefined) { + at = Math.max(0, Math.min(ir.nodes.length, spec.index)); + } + ir.nodes.splice(at, 0, structuredClone(node)); + nodeById.set(node.id, node); + applied(`insertNodes.${node.id}`, "insert-node"); + } + // --- added timeline fragments (motion ops): appended in par with the base --- if (overlay.addTimeline && overlay.addTimeline.length > 0) { const collectTargets = (tl: TimelineIR, out: Set) => { @@ -421,6 +465,63 @@ function applyOverlay( ir.duration = compileScene(ir).duration; } } + + // --- inserted timeline steps/beats: positioned inside a named beat --- + if (overlay.insertTimeline && overlay.insertTimeline.length > 0) { + // beats are the only addressable timeline containers (seq/par have no name) + const beatByName = new Map(); + const walkBeats = (tl: TimelineIR) => { + if (!("children" in tl)) return; + if (tl.kind === "beat") beatByName.set(tl.name, tl as TimelineIR & { children: TimelineIR[] }); + tl.children.forEach(walkBeats); + }; + if (ir.timeline) walkBeats(ir.timeline); + // a child's stable handle: a beat by name, any other step by its label + const childKey = (c: TimelineIR): string | undefined => + c.kind === "beat" ? c.name : "label" in c && c.label !== undefined ? c.label : undefined; + const collectTargets = (tl: TimelineIR, out: Set) => { + if (tl.kind === "tween" || tl.kind === "motionPath") out.add(tl.target); + if ("children" in tl) tl.children.forEach((c) => collectTargets(c, out)); + }; + + let inserted = false; + overlay.insertTimeline.forEach((spec, i) => { + const parent = beatByName.get(spec.into); + if (!parent) { + orphan(`insertTimeline[${i}]`, `unknown beat "${spec.into}" — known beats: ${[...beatByName.keys()].join(", ") || "(none)"}`); + return; + } + const targets = new Set(); + collectTargets(spec.step, targets); + const missing = [...targets].filter((id) => !nodeById.has(id)); + if (missing.length > 0) { + orphan(`insertTimeline[${i}]`, `step targets unknown node(s) ${missing.join(", ")} — known ids: ${knownIds()}`); + return; + } + let at = parent.children.length; // default = append + if (spec.before !== undefined || spec.after !== undefined) { + const refKey = spec.before ?? spec.after!; + const refIdx = parent.children.findIndex((c) => childKey(c) === refKey); + if (refIdx < 0) { + orphan( + `insertTimeline[${i}]`, + `unknown ${spec.before !== undefined ? "before" : "after"} step "${refKey}" in beat "${spec.into}" — children: ${parent.children.map(childKey).filter(Boolean).join(", ") || "(none)"}`, + ); + return; + } + at = spec.before !== undefined ? refIdx : refIdx + 1; + } else if (spec.index !== undefined) { + at = Math.max(0, Math.min(parent.children.length, spec.index)); + } + parent.children.splice(at, 0, structuredClone(spec.step)); + applied(`insertTimeline[${i}]`, "insert-timeline"); + inserted = true; + }); + if (inserted && overlay.scene?.duration === undefined) { + delete ir.duration; + ir.duration = compileScene(ir).duration; + } + } } export function formatComposeReport(report: ComposeReport): string { diff --git a/packages/core/src/montage.ts b/packages/core/src/montage.ts index eacfb3e..56f46b0 100644 --- a/packages/core/src/montage.ts +++ b/packages/core/src/montage.ts @@ -224,7 +224,11 @@ export function photoMontage(images: MontageImage[], opts: MontageOpts = {}): Mo ); } - return { nodes, timeline: beat("montage", { nodes: nodes.map((n) => n.id) }, [seq(...shots)]) }; + // shots are the DIRECT children of the "montage" beat (a beat groups its children as a + // seq), so the play-order list is addressable as the beat "montage" — an overlay can + // `insertTimeline { into: "montage", ... }` a shot, and reorder/removeTimeline operate + // on these same shot beats. (Equivalent to wrapping them in an explicit seq.) + return { nodes, timeline: beat("montage", { nodes: nodes.map((n) => n.id) }, shots) }; } /** diff --git a/packages/core/test/beat.test.ts b/packages/core/test/beat.test.ts index 9051f55..99a2b73 100644 --- a/packages/core/test/beat.test.ts +++ b/packages/core/test/beat.test.ts @@ -122,6 +122,79 @@ describe("beats", () => { expect(compileScene(ir).labelTimes.get("La")?.t0).toBe(0); // untouched }); + it("insertTimeline splices a beat into a named parent at a position", () => { + const base = scene({ + id: "demo", + size, + nodes: [card()], + timeline: beat("group", {}, [ + beat("a", {}, [tween("card", { opacity: 1 }, { duration: 0.5, label: "La" })]), + beat("c", {}, [tween("card", { opacity: 0.2 }, { duration: 0.5, label: "Lc" })]), + ]), + }); + const { ir, report } = composeScene(base, { + reframeOverlay: 1, + name: "ins", + target: "demo", + insertTimeline: [ + { + into: "group", + after: "a", + step: beat("b", {}, [tween("card", { opacity: 0.5 }, { duration: 0.5, label: "Lb" })]), + }, + ], + }); + const c = compileScene(ir); + expect(report.orphans).toHaveLength(0); + expect(report.applied.some((x) => x.action === "insert-timeline")).toBe(true); + expect(c.labelTimes.get("La")?.t0).toBe(0); // play order is now a, b, c + expect(c.labelTimes.get("Lb")?.t0).toBe(0.5); + expect(c.labelTimes.get("Lc")?.t0).toBe(1.0); + }); + + it("insertNodes inserts a node before a sibling (vs addNodes which appends)", () => { + const base = scene({ + id: "demo", + size, + nodes: [card(), rect({ id: "top", x: 0, y: 0, width: 10, height: 10, fill: "#FFFFFF" })], + }); + const { ir, report } = composeScene(base, { + reframeOverlay: 1, + name: "ins", + target: "demo", + insertNodes: [{ node: rect({ id: "mid", x: 0, y: 0, width: 10, height: 10, fill: "#00FF00" }), before: "top" }], + }); + expect(report.orphans).toHaveLength(0); + expect(report.applied.some((x) => x.action === "insert-node")).toBe(true); + expect(ir.nodes.map((n) => n.id)).toEqual(["card", "mid", "top"]); // landed under "top" + }); + + it("insert verbs orphan unknown into/before/target, leaving the scene intact", () => { + const base = scene({ + id: "demo", + size, + nodes: [card()], + timeline: beat("group", {}, [beat("a", {}, [tween("card", { opacity: 1 }, { duration: 0.5 })])]), + }); + const unknownInto = composeScene(base, { + reframeOverlay: 1, name: "x", target: "demo", + insertTimeline: [{ into: "nope", step: beat("b", {}, [tween("card", { opacity: 0.5 }, { duration: 0.5 })]) }], + }); + expect(unknownInto.report.orphans).toHaveLength(1); + + const unknownBefore = composeScene(base, { + reframeOverlay: 1, name: "y", target: "demo", + insertNodes: [{ node: rect({ id: "z", x: 0, y: 0, width: 10, height: 10, fill: "#FFFFFF" }), before: "ghost" }], + }); + expect(unknownBefore.report.orphans).toHaveLength(1); + + const missingTarget = composeScene(base, { + reframeOverlay: 1, name: "z", target: "demo", + insertTimeline: [{ into: "group", step: beat("b", {}, [tween("ghost", { opacity: 0.5 }, { duration: 0.5 })]) }], + }); + expect(missingTarget.report.orphans).toHaveLength(1); + }); + it("scale stretches the interior proportionally", () => { const c = compileScene( scene({ diff --git a/packages/core/test/montage.test.ts b/packages/core/test/montage.test.ts index a9e4dc6..148cd60 100644 --- a/packages/core/test/montage.test.ts +++ b/packages/core/test/montage.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { compileScene } from "../src/compile.js"; import { composeScene } from "../src/compose.js"; import { lintScene } from "../src/manifest.js"; -import { image, scene, video } from "../src/dsl.js"; +import { beat, image, scene, tween, video } from "../src/dsl.js"; import { evaluate } from "../src/evaluate.js"; import { photoMontage, videoMontage } from "../src/montage.js"; import { SceneValidationError } from "../src/validate.js"; @@ -114,6 +114,31 @@ describe("structural editing (regen-surviving)", () => { expect(b).toBeLessThan(d); }); + it("insert: a hand-authored shot lands in play order, under the grade (0 orphans)", () => { + const m = photoMontage(imgs, { hold: 2 }); // grade on → has shot-vignette/shot-scrim + // the "manual / studio payload": a new layer + its self-contained shot beat + const layer = image({ id: "shot-x", src: "x.jpg", x: 960, y: 540, width: 1920, height: 1080, anchor: "center", fit: "cover", scale: 1, opacity: 0 }); + const shotBeat = beat("shot-x", { nodes: ["shot-x"], parallel: true, gap: -0.6 }, [ + tween("shot-x", { opacity: 1 }, { duration: 0.6, ease: "linear", label: "shot-x-in" }), + tween("shot-x", { scale: 1.12 }, { duration: 2, ease: "easeInOutQuad", label: "shot-x-kb" }), + ]); + const { ir, report } = composeScene(scene({ id: "m", size, nodes: m.nodes, timeline: m.timeline }), { + reframeOverlay: 1, + name: "insert-shot", + target: "m", + insertNodes: [{ node: layer, before: "shot-vignette" }], // paints under the grade + insertTimeline: [{ into: "montage", after: "shot-1", step: shotBeat }], // plays between shot-1 and shot-2 + }); + const c = compileScene(ir); + expect(report.orphans).toHaveLength(0); + // node landed before the grade overlays + expect(ir.nodes.findIndex((n) => n.id === "shot-x")).toBeLessThan(ir.nodes.findIndex((n) => n.id === "shot-vignette")); + // beat plays in order: shot-1 < shot-x < shot-2 + const t = (l: string) => c.beatTimes.get(l)!.t0; + expect(t("shot-1")).toBeLessThan(t("shot-x")); + expect(t("shot-x")).toBeLessThan(t("shot-2")); + }); + it("remove: removeTimeline drops a shot; survivors ripple and its layer goes invisible", () => { const m = mont(); const before = compileScene(sc(m)).duration; diff --git a/packages/reframe-video/package.json b/packages/reframe-video/package.json index 31cfcfc..1362214 100644 --- a/packages/reframe-video/package.json +++ b/packages/reframe-video/package.json @@ -1,6 +1,6 @@ { "name": "reframe-video", - "version": "0.6.40", + "version": "0.6.41", "description": "Declarative motion graphics that AI can write and humans can tweak — human edits survive AI regeneration. Deterministic mp4 renders from a plain-data scene format.", "keywords": [ "motion-graphics",