Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>.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.<id>.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
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion docs/guides/regen-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
49 changes: 49 additions & 0 deletions examples/overlays/montage-insert.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
]
}
}
]
}
103 changes: 102 additions & 1 deletion packages/core/src/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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[];
Expand Down Expand Up @@ -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<string>) => {
Expand Down Expand Up @@ -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<string, TimelineIR & { children: TimelineIR[] }>();
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<string>) => {
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<string>();
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 {
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/montage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
}

/**
Expand Down
73 changes: 73 additions & 0 deletions packages/core/test/beat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
27 changes: 26 additions & 1 deletion packages/core/test/montage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/reframe-video/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading