reframe-video 0.6.12 → 0.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -2706,6 +2706,7 @@ var ROOT2 = PACKAGED ? resolve6(HERE2, "..") : resolve6(HERE2, "..", "..", "..")
2706
2706
  var USER_CWD = process.env.INIT_CWD ?? process.cwd();
2707
2707
  var RENDER_CLI = PACKAGED ? join9(ROOT2, "dist", "cli.js") : join9(ROOT2, "packages", "render-cli", "src", "cli.ts");
2708
2708
  var LABELS = PACKAGED ? join9(ROOT2, "dist", "labels.js") : join9(ROOT2, "packages", "render-cli", "src", "labels.ts");
2709
+ var DIFF = PACKAGED ? join9(ROOT2, "dist", "diff.js") : join9(ROOT2, "packages", "render-cli", "src", "diff.ts");
2709
2710
  var PLAYER = PACKAGED ? join9(ROOT2, "dist", "player.js") : join9(ROOT2, "packages", "render-cli", "src", "player.ts");
2710
2711
  var ANALYZE = PACKAGED ? join9(ROOT2, "dist", "analyze.js") : join9(ROOT2, "benchmark", "harness", "motion", "analyze.ts");
2711
2712
  var TRACE = PACKAGED ? join9(ROOT2, "dist", "trace-cli.js") : join9(ROOT2, "benchmark", "harness", "motion", "trace-cli.ts");
@@ -2725,7 +2726,8 @@ usage:
2725
2726
  ${CMD} labels <scene.ts|.json> print the event clock (label \u2192 exact seconds; for sound design / timing)
2726
2727
  ${CMD} motion <mp4|framesDir> motion-profile a rendered clip
2727
2728
  ${CMD} trace <ref.mp4> [--apply scene.ts] extract a video's motion structure \u2192 MotionSketch / timeline
2728
- ${CMD} guide [--regen] print the scene-authoring guide (for you or your AI)
2729
+ ${CMD} diff <ref-image> [<scene.ts>] [--t S] [--mode side|blend|diff|grid] compare/measure a render against a reference image
2730
+ ${CMD} guide [--regen|--directing] print a guide (--regen: stable-address contract; --directing: high-end workflow)
2729
2731
  ${CMD} demo run the edit-survival demo (3 mp4s into out/)
2730
2732
  `;
2731
2733
  var userPath = (p) => isAbsolute5(p) ? p : resolve6(USER_CWD, p);
@@ -3024,8 +3026,30 @@ ${results.length - failed} rendered (${orphaned} with orphans), ${failed} failed
3024
3026
  await (PACKAGED ? run2(process.execPath, [TRACE, userPath(input), ...args]) : run2("npx", ["tsx", TRACE, userPath(input), ...args]))
3025
3027
  );
3026
3028
  }
3029
+ case "diff": {
3030
+ const input = rest[0];
3031
+ if (!input || input.startsWith("-")) {
3032
+ fail(`usage: ${CMD} diff <ref-image> [<scene.ts>] [--t <sec>] [--mode side|blend|diff|grid] [-o out.png]`);
3033
+ }
3034
+ let seenScene = false;
3035
+ const args = rest.map((a, i) => {
3036
+ if (i === 0) return userPath(a);
3037
+ if (rest[i - 1] === "-o") return userPath(a);
3038
+ if (!a.startsWith("-") && rest[i - 1] !== "--t" && rest[i - 1] !== "--mode" && !seenScene) {
3039
+ seenScene = true;
3040
+ return userPath(a);
3041
+ }
3042
+ return a;
3043
+ });
3044
+ process.exit(
3045
+ await (PACKAGED ? run2(process.execPath, [DIFF, ...args]) : run2("npx", ["tsx", DIFF, ...args]))
3046
+ );
3047
+ }
3027
3048
  case "guide": {
3028
- const file = rest.includes("--regen") ? PACKAGED ? join9(ROOT2, "guides", "regen-contract.md") : join9(ROOT2, "docs", "regen-contract.md") : PACKAGED ? join9(ROOT2, "guides", "edsl-guide.md") : join9(ROOT2, "benchmark", "guides", "edsl-guide.md");
3049
+ const which = rest.includes("--regen") ? "regen" : rest.includes("--directing") ? "directing" : "edsl";
3050
+ const repoFile = { regen: join9(ROOT2, "docs", "regen-contract.md"), directing: join9(ROOT2, "benchmark", "guides", "directing-guide.md"), edsl: join9(ROOT2, "benchmark", "guides", "edsl-guide.md") };
3051
+ const pkgFile = { regen: join9(ROOT2, "guides", "regen-contract.md"), directing: join9(ROOT2, "guides", "directing-guide.md"), edsl: join9(ROOT2, "guides", "edsl-guide.md") };
3052
+ const file = (PACKAGED ? pkgFile : repoFile)[which];
3029
3053
  const { readFile: readFile7 } = await import("node:fs/promises");
3030
3054
  process.stdout.write(await readFile7(file, "utf8"));
3031
3055
  return;
package/dist/diff.js ADDED
@@ -0,0 +1,1188 @@
1
+ #!/usr/bin/env tsx
2
+
3
+ // ../render-cli/src/diff.ts
4
+ import { readFile as readFile5, writeFile } from "node:fs/promises";
5
+ import { dirname as dirname4, extname as extname3, resolve as resolve4 } from "node:path";
6
+ import { pathToFileURL as pathToFileURL2 } from "node:url";
7
+ import { chromium as chromium2 } from "playwright";
8
+
9
+ // ../render-cli/src/loadScene.ts
10
+ import { build } from "esbuild";
11
+ import { readFile } from "node:fs/promises";
12
+ import { dirname, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ // ../core/src/ir.ts
16
+ var DEFAULT_TO_DURATION = 0.5;
17
+ var DEFAULT_TWEEN_DURATION = 0.5;
18
+ var DEFAULT_MOTIONPATH_DURATION = 1;
19
+
20
+ // ../core/src/path.ts
21
+ function locate(segCount, u) {
22
+ if (segCount <= 0) return { i: 0, t: 0 };
23
+ const clamped = Math.max(0, Math.min(1, u));
24
+ const scaled = clamped * segCount;
25
+ let i = Math.floor(scaled);
26
+ if (i >= segCount) i = segCount - 1;
27
+ return { i, t: scaled - i };
28
+ }
29
+ function controls(points, closed, i) {
30
+ const n = points.length;
31
+ const at = (k) => {
32
+ if (closed) return points[(k % n + n) % n];
33
+ return points[Math.max(0, Math.min(n - 1, k))];
34
+ };
35
+ return [at(i - 1), at(i), at(i + 1), at(i + 2)];
36
+ }
37
+ function segCountOf(points, closed) {
38
+ const n = points.length;
39
+ if (n < 2) return 0;
40
+ return closed ? n : n - 1;
41
+ }
42
+ function pathPoint(points, closed, u, curviness = 1) {
43
+ const n = points.length;
44
+ if (n === 0) return [0, 0];
45
+ if (n === 1) return [points[0][0], points[0][1]];
46
+ const segs = segCountOf(points, closed);
47
+ const { i, t } = locate(segs, u);
48
+ const [p0, p1, p2, p3] = controls(points, closed, i);
49
+ const t2 = t * t;
50
+ const t3 = t2 * t;
51
+ if (curviness === 1) {
52
+ const f = (a, b, c, d) => 0.5 * (2 * b + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3);
53
+ return [f(p0[0], p1[0], p2[0], p3[0]), f(p0[1], p1[1], p2[1], p3[1])];
54
+ }
55
+ const h00 = 2 * t3 - 3 * t2 + 1;
56
+ const h10 = t3 - 2 * t2 + t;
57
+ const h01 = -2 * t3 + 3 * t2;
58
+ const h11 = t3 - t2;
59
+ const k = curviness * 0.5;
60
+ const H = (a, b, c, d) => h00 * b + h10 * k * (c - a) + h01 * c + h11 * k * (d - b);
61
+ return [H(p0[0], p1[0], p2[0], p3[0]), H(p0[1], p1[1], p2[1], p3[1])];
62
+ }
63
+ function pathTangentAngle(points, closed, u, curviness = 1) {
64
+ const n = points.length;
65
+ if (n < 2) return 0;
66
+ const segs = segCountOf(points, closed);
67
+ const { i, t } = locate(segs, u);
68
+ const [p0, p1, p2, p3] = controls(points, closed, i);
69
+ const t2 = t * t;
70
+ let dx;
71
+ let dy;
72
+ if (curviness === 1) {
73
+ const d = (a, b, c, e) => 0.5 * (-a + c + 2 * (2 * a - 5 * b + 4 * c - e) * t + 3 * (-a + 3 * b - 3 * c + e) * t2);
74
+ dx = d(p0[0], p1[0], p2[0], p3[0]);
75
+ dy = d(p0[1], p1[1], p2[1], p3[1]);
76
+ } else {
77
+ const g00 = 6 * t2 - 6 * t;
78
+ const g10 = 3 * t2 - 4 * t + 1;
79
+ const g01 = -6 * t2 + 6 * t;
80
+ const g11 = 3 * t2 - 2 * t;
81
+ const k = curviness * 0.5;
82
+ const D = (a, b, c, e) => g00 * b + g10 * k * (c - a) + g01 * c + g11 * k * (e - b);
83
+ dx = D(p0[0], p1[0], p2[0], p3[0]);
84
+ dy = D(p0[1], p1[1], p2[1], p3[1]);
85
+ }
86
+ if (dx === 0 && dy === 0) return 0;
87
+ return Math.atan2(dy, dx) * 180 / Math.PI;
88
+ }
89
+
90
+ // ../core/src/compile.ts
91
+ var key = (target, prop) => `${target}.${prop}`;
92
+ function scaleTimeline(tl, k) {
93
+ switch (tl.kind) {
94
+ case "seq":
95
+ case "par":
96
+ return { ...tl, children: tl.children.map((c) => scaleTimeline(c, k)) };
97
+ case "stagger":
98
+ return { ...tl, interval: tl.interval * k, children: tl.children.map((c) => scaleTimeline(c, k)) };
99
+ case "wait":
100
+ return { ...tl, duration: tl.duration * k };
101
+ case "tween":
102
+ return { ...tl, duration: (tl.duration ?? DEFAULT_TWEEN_DURATION) * k };
103
+ case "motionPath":
104
+ return { ...tl, duration: (tl.duration ?? DEFAULT_MOTIONPATH_DURATION) * k };
105
+ case "to":
106
+ return {
107
+ ...tl,
108
+ duration: (tl.duration ?? DEFAULT_TO_DURATION) * k,
109
+ ...tl.stagger !== void 0 && { stagger: tl.stagger * k }
110
+ };
111
+ case "beat":
112
+ return {
113
+ ...tl,
114
+ children: tl.children.map((c) => scaleTimeline(c, k)),
115
+ ...tl.gap !== void 0 && { gap: tl.gap * k }
116
+ };
117
+ }
118
+ }
119
+ function orderBeats(children) {
120
+ return children.map((c, i) => ({ c, i, key: c.kind === "beat" && c.order !== void 0 ? c.order : i })).sort((a, b) => a.key - b.key || a.i - b.i).map((x) => x.c);
121
+ }
122
+ function compileScene(ir) {
123
+ const nodeById = /* @__PURE__ */ new Map();
124
+ const nodeOrder = [];
125
+ const collect = (nodes) => {
126
+ for (const node of nodes) {
127
+ nodeById.set(node.id, node);
128
+ nodeOrder.push(node.id);
129
+ if (node.type === "group") collect(node.children);
130
+ }
131
+ };
132
+ collect(ir.nodes);
133
+ const initialValues = /* @__PURE__ */ new Map();
134
+ for (const [id, node] of nodeById) {
135
+ for (const [prop, value] of Object.entries(node.props)) {
136
+ if (typeof value === "number" || typeof value === "string") {
137
+ initialValues.set(key(id, prop), value);
138
+ }
139
+ }
140
+ }
141
+ if (ir.initial !== void 0) {
142
+ const override = ir.states?.[ir.initial] ?? {};
143
+ for (const [id, props] of Object.entries(override)) {
144
+ for (const [prop, value] of Object.entries(props)) {
145
+ initialValues.set(key(id, prop), value);
146
+ }
147
+ }
148
+ }
149
+ const cameraIsNode = nodeById.has("camera");
150
+ if (!cameraIsNode) {
151
+ const cam = ir.camera ?? {};
152
+ initialValues.set(key("camera", "x"), cam.x ?? ir.size.width / 2);
153
+ initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
154
+ initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
155
+ initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
156
+ if (cam.perspective !== void 0) initialValues.set(key("camera", "perspective"), cam.perspective);
157
+ }
158
+ const segments = /* @__PURE__ */ new Map();
159
+ const motionPaths = /* @__PURE__ */ new Map();
160
+ const current = new Map(initialValues);
161
+ const pushSegment = (seg) => {
162
+ const k = key(seg.target, seg.prop);
163
+ let list = segments.get(k);
164
+ if (!list) segments.set(k, list = []);
165
+ list.push(seg);
166
+ current.set(k, seg.to);
167
+ };
168
+ const currentValue = (target, prop) => {
169
+ const v = current.get(key(target, prop));
170
+ if (v !== void 0) return v;
171
+ if (prop === "opacity" || prop === "scale" || prop === "progress" || prop === "scaleX" || prop === "scaleY") return 1;
172
+ if (prop === "rotation" || prop === "skewX" || prop === "skewY") return 0;
173
+ throw new Error(`cannot animate "${prop}" of "${target}": no base value to start from`);
174
+ };
175
+ const labelTimes = /* @__PURE__ */ new Map();
176
+ const beatTimes = /* @__PURE__ */ new Map();
177
+ const durationOf = (tl, start) => {
178
+ switch (tl.kind) {
179
+ case "seq": {
180
+ let t = start;
181
+ for (const child of orderBeats(tl.children)) t = durationOf(child, t);
182
+ return t;
183
+ }
184
+ case "par": {
185
+ let end = start;
186
+ for (const child of tl.children) end = Math.max(end, durationOf(child, start));
187
+ return end;
188
+ }
189
+ case "stagger": {
190
+ let end = start;
191
+ tl.children.forEach((child, i) => {
192
+ end = Math.max(end, durationOf(child, start + i * tl.interval));
193
+ });
194
+ return end;
195
+ }
196
+ case "wait":
197
+ return start + tl.duration;
198
+ case "tween":
199
+ return start + (tl.duration ?? DEFAULT_TWEEN_DURATION);
200
+ case "motionPath":
201
+ return start + (tl.duration ?? DEFAULT_MOTIONPATH_DURATION);
202
+ case "to": {
203
+ const override = ir.states?.[tl.state] ?? {};
204
+ const duration = tl.duration ?? DEFAULT_TO_DURATION;
205
+ const si = tl.stagger ?? 0;
206
+ const targets = nodeOrder.filter(
207
+ (id) => id in override && (tl.filter === void 0 || tl.filter.includes(id))
208
+ );
209
+ return start + duration + Math.max(0, targets.length - 1) * si;
210
+ }
211
+ case "beat": {
212
+ const grouping = { kind: tl.parallel ? "par" : "seq", children: tl.children };
213
+ const natural = durationOf(grouping, 0);
214
+ const k = tl.scale ?? (tl.duration !== void 0 ? tl.duration / Math.max(1e-9, natural) : 1);
215
+ const beatStart = tl.at ?? start + (tl.gap ?? 0);
216
+ return beatStart + k * natural;
217
+ }
218
+ }
219
+ };
220
+ const walk = (tl, start) => {
221
+ const end = walkInner(tl, start);
222
+ if ("label" in tl && tl.label !== void 0) labelTimes.set(tl.label, { t0: start, t1: end });
223
+ return end;
224
+ };
225
+ const walkInner = (tl, start) => {
226
+ switch (tl.kind) {
227
+ case "seq": {
228
+ let t = start;
229
+ for (const child of orderBeats(tl.children)) t = walk(child, t);
230
+ return t;
231
+ }
232
+ case "beat": {
233
+ const grouping = { kind: tl.parallel ? "par" : "seq", children: tl.children };
234
+ const k = tl.scale ?? (tl.duration !== void 0 ? tl.duration / Math.max(1e-9, durationOf(grouping, 0)) : 1);
235
+ const inner = k === 1 ? grouping : scaleTimeline(grouping, k);
236
+ const beatStart = tl.at ?? start + (tl.gap ?? 0);
237
+ const end = walk(inner, beatStart);
238
+ beatTimes.set(tl.name, { t0: beatStart, t1: end });
239
+ labelTimes.set(tl.name, { t0: beatStart, t1: end });
240
+ return end;
241
+ }
242
+ case "par": {
243
+ let end = start;
244
+ for (const child of tl.children) end = Math.max(end, walk(child, start));
245
+ return end;
246
+ }
247
+ case "stagger": {
248
+ let end = start;
249
+ tl.children.forEach((child, i) => {
250
+ end = Math.max(end, walk(child, start + i * tl.interval));
251
+ });
252
+ return end;
253
+ }
254
+ case "wait":
255
+ return start + tl.duration;
256
+ case "tween": {
257
+ const duration = tl.duration ?? DEFAULT_TWEEN_DURATION;
258
+ for (const [prop, toValue] of Object.entries(tl.props)) {
259
+ pushSegment({
260
+ target: tl.target,
261
+ prop,
262
+ t0: start,
263
+ t1: start + duration,
264
+ from: currentValue(tl.target, prop),
265
+ to: toValue,
266
+ ...tl.ease !== void 0 && { ease: tl.ease }
267
+ });
268
+ }
269
+ return start + duration;
270
+ }
271
+ case "motionPath": {
272
+ const duration = tl.duration ?? DEFAULT_MOTIONPATH_DURATION;
273
+ const points = tl.points;
274
+ const closed = tl.closed ?? false;
275
+ const curviness = tl.curviness ?? 1;
276
+ const autoRotate = tl.autoRotate ?? false;
277
+ const rotateOffset = tl.rotateOffset ?? 0;
278
+ let list = motionPaths.get(tl.target);
279
+ if (!list) motionPaths.set(tl.target, list = []);
280
+ list.push({ t0: start, t1: start + duration, points, closed, curviness, autoRotate, rotateOffset, ...tl.ease !== void 0 && { ease: tl.ease } });
281
+ if (points.length > 0) {
282
+ const [ex, ey] = pathPoint(points, closed, 1, curviness);
283
+ current.set(key(tl.target, "x"), ex);
284
+ current.set(key(tl.target, "y"), ey);
285
+ if (autoRotate) current.set(key(tl.target, "rotation"), pathTangentAngle(points, closed, 1, curviness) + rotateOffset);
286
+ }
287
+ return start + duration;
288
+ }
289
+ case "to": {
290
+ const override = ir.states?.[tl.state] ?? {};
291
+ const duration = tl.duration ?? DEFAULT_TO_DURATION;
292
+ const staggerInterval = tl.stagger ?? 0;
293
+ const targets = nodeOrder.filter(
294
+ (id) => id in override && (tl.filter === void 0 || tl.filter.includes(id))
295
+ );
296
+ targets.forEach((id, i) => {
297
+ const t0 = start + i * staggerInterval;
298
+ for (const [prop, toValue] of Object.entries(override[id])) {
299
+ pushSegment({
300
+ target: id,
301
+ prop,
302
+ t0,
303
+ t1: t0 + duration,
304
+ from: currentValue(id, prop),
305
+ to: toValue,
306
+ ...tl.ease !== void 0 && { ease: tl.ease }
307
+ });
308
+ }
309
+ });
310
+ const last = Math.max(0, targets.length - 1);
311
+ return start + duration + last * staggerInterval;
312
+ }
313
+ }
314
+ };
315
+ const inferredEnd = ir.timeline ? walk(ir.timeline, 0) : 0;
316
+ for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
317
+ for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
318
+ const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
319
+ const hasPerspective = !cameraIsNode && (ir.camera?.perspective !== void 0 || segments.has("camera.perspective"));
320
+ return {
321
+ ir,
322
+ duration: ir.duration ?? inferredEnd,
323
+ segments,
324
+ motionPaths,
325
+ initialValues,
326
+ nodeById,
327
+ nodeOrder,
328
+ labelTimes,
329
+ beatTimes,
330
+ hasCamera,
331
+ hasPerspective
332
+ };
333
+ }
334
+
335
+ // ../core/src/validate.ts
336
+ var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY", "blend"];
337
+ var BLEND_MODES = /* @__PURE__ */ new Set([
338
+ "normal",
339
+ "multiply",
340
+ "screen",
341
+ "overlay",
342
+ "lighten",
343
+ "darken",
344
+ "add",
345
+ "color-dodge",
346
+ "soft-light",
347
+ "hard-light",
348
+ "difference"
349
+ ]);
350
+ var IMAGE_FITS = /* @__PURE__ */ new Set(["fill", "cover"]);
351
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "z", "rotateX", "rotateY", "anchor", "fixed", ...FX_PROPS];
352
+ var CAMERA_PROPS = ["x", "y", "zoom", "rotation", "perspective"];
353
+ var PROPS_BY_TYPE = {
354
+ rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
355
+ ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
356
+ line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
357
+ text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "prefix", "suffix", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
358
+ image: [...COMMON_PROPS, "src", "width", "height", "fit"],
359
+ video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume"],
360
+ path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
361
+ group: COMMON_PROPS
362
+ };
363
+ var SceneValidationError = class extends Error {
364
+ constructor(problems) {
365
+ super(`Scene validation failed:
366
+ ${problems.map((p) => ` - ${p}`).join("\n")}`);
367
+ this.problems = problems;
368
+ this.name = "SceneValidationError";
369
+ }
370
+ problems;
371
+ };
372
+ function validateScene(ir) {
373
+ const problems = [];
374
+ const nodeById = /* @__PURE__ */ new Map();
375
+ const checkPaint = (where, value) => {
376
+ if (typeof value !== "object" || value === null) return;
377
+ const g = value;
378
+ if (g.kind !== "linear" && g.kind !== "radial" && g.kind !== "conic") {
379
+ problems.push(`${where}: a paint object must be a gradient with kind "linear" / "radial" / "conic"`);
380
+ return;
381
+ }
382
+ if (!Array.isArray(g.stops) || g.stops.length === 0) {
383
+ problems.push(`${where}: gradient "${g.kind}" needs at least one color stop`);
384
+ return;
385
+ }
386
+ g.stops.forEach((s, i) => {
387
+ const st = s;
388
+ if (typeof st?.color !== "string") problems.push(`${where}: gradient stop ${i} needs a color string`);
389
+ if (typeof st?.offset !== "number" || st.offset < 0 || st.offset > 1) {
390
+ problems.push(`${where}: gradient stop ${i} "offset" must be a number in 0..1`);
391
+ }
392
+ });
393
+ };
394
+ const collect = (nodes) => {
395
+ for (const node of nodes) {
396
+ if (nodeById.has(node.id)) {
397
+ problems.push(`duplicate node id "${node.id}" \u2014 every node id must be unique`);
398
+ }
399
+ nodeById.set(node.id, node);
400
+ const props = node.props;
401
+ checkPaint(`node "${node.id}" fill`, props.fill);
402
+ checkPaint(`node "${node.id}" stroke`, props.stroke);
403
+ if (typeof props.blur === "number" && props.blur < 0) problems.push(`node "${node.id}": blur must be >= 0`);
404
+ if (typeof props.shadowBlur === "number" && props.shadowBlur < 0) problems.push(`node "${node.id}": shadowBlur must be >= 0`);
405
+ if (typeof props.blend === "string" && !BLEND_MODES.has(props.blend)) problems.push(`node "${node.id}": unknown blend "${props.blend}" \u2014 use ${[...BLEND_MODES].join(", ")}`);
406
+ if (typeof props.fit === "string" && !IMAGE_FITS.has(props.fit)) problems.push(`node "${node.id}": unknown fit "${props.fit}" \u2014 use ${[...IMAGE_FITS].join(", ")}`);
407
+ if (node.type === "group") {
408
+ const clip = node.props.clip;
409
+ if (clip) {
410
+ if (clip.kind !== "rect" && clip.kind !== "ellipse") {
411
+ problems.push(`group "${node.id}" clip: unknown kind "${clip.kind}" \u2014 use "rect" or "ellipse"`);
412
+ }
413
+ if (!(clip.width > 0) || !(clip.height > 0)) {
414
+ problems.push(`group "${node.id}" clip: width and height must be > 0`);
415
+ }
416
+ }
417
+ const matte = node.props.matte;
418
+ if (matte !== void 0) {
419
+ if (matte !== "alpha" && matte !== "luma") {
420
+ problems.push(`group "${node.id}" matte: unknown mode "${String(matte)}" \u2014 use "alpha" or "luma"`);
421
+ } else if (node.children.length < 2) {
422
+ problems.push(`group "${node.id}" matte: needs \u22652 children (first masks the rest)`);
423
+ }
424
+ }
425
+ collect(node.children);
426
+ }
427
+ }
428
+ };
429
+ collect(ir.nodes);
430
+ const checkProps = (where, nodeId, props) => {
431
+ if (nodeId === "camera" && !nodeById.has("camera")) {
432
+ for (const key2 of Object.keys(props)) {
433
+ if (!CAMERA_PROPS.includes(key2)) {
434
+ problems.push(`${where}: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
435
+ }
436
+ }
437
+ return;
438
+ }
439
+ const node = nodeById.get(nodeId);
440
+ if (!node) {
441
+ problems.push(
442
+ `${where} targets unknown node "${nodeId}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
443
+ );
444
+ return;
445
+ }
446
+ const allowed = PROPS_BY_TYPE[node.type];
447
+ for (const key2 of Object.keys(props)) {
448
+ if (!allowed.includes(key2)) {
449
+ problems.push(
450
+ `${where}: "${key2}" is not a prop of ${node.type} "${nodeId}" \u2014 valid props: ${allowed.join(", ")}`
451
+ );
452
+ }
453
+ }
454
+ };
455
+ const states = ir.states ?? {};
456
+ for (const [stateName, overrides] of Object.entries(states)) {
457
+ for (const [nodeId, props] of Object.entries(overrides)) {
458
+ checkProps(`state "${stateName}"`, nodeId, props);
459
+ }
460
+ }
461
+ if (ir.initial !== void 0 && !(ir.initial in states)) {
462
+ problems.push(
463
+ `initial state "${ir.initial}" is not defined \u2014 defined states: ${Object.keys(states).join(", ") || "(none)"}`
464
+ );
465
+ }
466
+ const labels = /* @__PURE__ */ new Set();
467
+ const checkTimeline = (tl, path2) => {
468
+ if ("label" in tl && tl.label !== void 0) {
469
+ if (labels.has(tl.label)) {
470
+ problems.push(
471
+ `${path2}: duplicate timeline label "${tl.label}" \u2014 labels are overlay addresses and must be unique`
472
+ );
473
+ }
474
+ labels.add(tl.label);
475
+ }
476
+ switch (tl.kind) {
477
+ case "seq":
478
+ case "par":
479
+ tl.children.forEach((c, i) => checkTimeline(c, `${path2}.${tl.kind}[${i}]`));
480
+ break;
481
+ case "stagger":
482
+ if (tl.interval < 0) problems.push(`${path2}: stagger interval must be >= 0`);
483
+ tl.children.forEach((c, i) => checkTimeline(c, `${path2}.stagger[${i}]`));
484
+ break;
485
+ case "to":
486
+ if (!(tl.state in states)) {
487
+ problems.push(
488
+ `${path2}: to("${tl.state}") references an undefined state \u2014 defined states: ${Object.keys(states).join(", ") || "(none)"}`
489
+ );
490
+ }
491
+ if (tl.duration !== void 0 && tl.duration <= 0) {
492
+ problems.push(`${path2}: to("${tl.state}") duration must be > 0`);
493
+ }
494
+ for (const id of tl.filter ?? []) {
495
+ if (!nodeById.has(id)) problems.push(`${path2}: filter contains unknown node "${id}"`);
496
+ }
497
+ break;
498
+ case "tween":
499
+ checkProps(path2, tl.target, tl.props);
500
+ if (tl.duration !== void 0 && tl.duration <= 0) {
501
+ problems.push(`${path2}: tween duration must be > 0`);
502
+ }
503
+ break;
504
+ case "motionPath": {
505
+ const node = nodeById.get(tl.target);
506
+ const isSceneCamera = tl.target === "camera" && !node;
507
+ if (!isSceneCamera) {
508
+ if (!node) {
509
+ problems.push(
510
+ `${path2}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
511
+ );
512
+ } else if (node.type === "line") {
513
+ problems.push(`${path2}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
514
+ }
515
+ }
516
+ if (tl.points.length < 1) problems.push(`${path2}: motionPath "${tl.target}" needs at least 1 point`);
517
+ if (tl.duration !== void 0 && tl.duration <= 0) {
518
+ problems.push(`${path2}: motionPath "${tl.target}" duration must be > 0`);
519
+ }
520
+ if (tl.curviness !== void 0 && tl.curviness < 0) {
521
+ problems.push(`${path2}: motionPath "${tl.target}" curviness must be >= 0`);
522
+ }
523
+ break;
524
+ }
525
+ case "wait":
526
+ if (tl.duration < 0) problems.push(`${path2}: wait duration must be >= 0`);
527
+ break;
528
+ case "beat":
529
+ if (labels.has(tl.name)) {
530
+ problems.push(
531
+ `${path2}: duplicate timeline label "${tl.name}" (beat name) \u2014 labels are overlay addresses and must be unique`
532
+ );
533
+ }
534
+ labels.add(tl.name);
535
+ if (tl.duration !== void 0 && tl.duration <= 0) {
536
+ problems.push(`${path2}: beat "${tl.name}" duration must be > 0`);
537
+ }
538
+ if (tl.scale !== void 0 && tl.scale <= 0) {
539
+ problems.push(`${path2}: beat "${tl.name}" scale must be > 0`);
540
+ }
541
+ for (const id of tl.nodes ?? []) {
542
+ if (!nodeById.has(id)) {
543
+ problems.push(
544
+ `${path2}: beat "${tl.name}" owns unknown node "${id}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
545
+ );
546
+ }
547
+ }
548
+ tl.children.forEach((c, i) => checkTimeline(c, `${path2}.beat(${tl.name})[${i}]`));
549
+ break;
550
+ }
551
+ };
552
+ if (ir.timeline) checkTimeline(ir.timeline, "timeline");
553
+ for (const [i, b] of (ir.behaviors ?? []).entries()) {
554
+ checkProps(`behaviors[${i}]`, b.target, { [b.prop]: 0 });
555
+ }
556
+ if (ir.duration !== void 0 && ir.duration <= 0) {
557
+ problems.push("scene duration must be > 0");
558
+ }
559
+ if (ir.camera) {
560
+ if (nodeById.has("camera")) {
561
+ problems.push(`camera: a node is already named "camera" \u2014 rename that node or drop the scene camera (the id "camera" can't be both)`);
562
+ }
563
+ for (const [key2, value] of Object.entries(ir.camera)) {
564
+ if (!CAMERA_PROPS.includes(key2)) {
565
+ problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
566
+ } else if (typeof value !== "number") {
567
+ problems.push(`camera.${key2} must be a number`);
568
+ } else if (key2 === "perspective" && value <= 0) {
569
+ problems.push(`camera.perspective must be > 0 (focal distance in px) \u2014 drop it to disable perspective`);
570
+ }
571
+ }
572
+ }
573
+ const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
574
+ for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
575
+ if (typeof cue.at === "string" && !labels.has(cue.at)) {
576
+ problems.push(
577
+ `audio.cues[${i}]: unknown timeline label "${cue.at}" \u2014 known labels: ${[...labels].join(", ") || "(none)"}`
578
+ );
579
+ }
580
+ if (typeof cue.at === "number" && cue.at < 0) {
581
+ problems.push(`audio.cues[${i}]: "at" must be >= 0`);
582
+ }
583
+ if (cue.sfx === void 0 === (cue.file === void 0)) {
584
+ problems.push(`audio.cues[${i}]: exactly one of "sfx" or "file" is required`);
585
+ }
586
+ if (cue.sfx !== void 0 && !SFX_NAMES.includes(cue.sfx)) {
587
+ problems.push(`audio.cues[${i}]: unknown sfx "${cue.sfx}" \u2014 valid: ${SFX_NAMES.join(", ")}`);
588
+ }
589
+ if (cue.gain !== void 0 && cue.gain < 0) {
590
+ problems.push(`audio.cues[${i}]: gain must be >= 0`);
591
+ }
592
+ }
593
+ const duck = ir.audio?.bgm?.duck;
594
+ if (typeof duck === "object" && duck !== null && duck.depth !== void 0 && (duck.depth < 0 || duck.depth > 1)) {
595
+ problems.push("audio.bgm.duck.depth must be in [0, 1]");
596
+ }
597
+ if (ir.audio?.bgm?.file !== void 0 && ir.audio.bgm.synth !== void 0) {
598
+ problems.push('audio.bgm: use either "file" or "synth", not both');
599
+ }
600
+ if (problems.length > 0) throw new SceneValidationError(problems);
601
+ }
602
+ var TRANSITIONS = ["cut", "crossfade"];
603
+ function validateComposition(comp) {
604
+ const problems = [];
605
+ if (comp.scenes.length === 0) problems.push("composition has no scenes");
606
+ const seen = /* @__PURE__ */ new Set();
607
+ for (const [i, entry] of comp.scenes.entries()) {
608
+ const where = `scenes[${i}]`;
609
+ try {
610
+ validateScene(entry.scene);
611
+ } catch (err) {
612
+ if (err instanceof SceneValidationError) {
613
+ for (const p of err.problems) problems.push(`${where} (scene "${entry.scene.id}"): ${p}`);
614
+ } else throw err;
615
+ }
616
+ if (seen.has(entry.scene.id)) {
617
+ problems.push(`${where}: duplicate scene id "${entry.scene.id}" \u2014 scene ids must be unique in a composition`);
618
+ }
619
+ seen.add(entry.scene.id);
620
+ if (entry.transition !== void 0 && !TRANSITIONS.includes(entry.transition)) {
621
+ problems.push(`${where}: unknown transition "${entry.transition}" \u2014 valid: ${TRANSITIONS.join(", ")}`);
622
+ }
623
+ if (typeof entry.at === "string" && Number.isNaN(Number(entry.at))) {
624
+ problems.push(`${where}: "at" string "${entry.at}" is not a number (use "-0.5"/"+0.5" or a number)`);
625
+ }
626
+ if (typeof entry.at === "number" && entry.at < 0) {
627
+ problems.push(`${where}: absolute "at" must be >= 0`);
628
+ }
629
+ }
630
+ if (problems.length > 0) throw new SceneValidationError(problems);
631
+ }
632
+
633
+ // ../core/src/presets.ts
634
+ var SET = 1 / 120;
635
+
636
+ // ../core/src/interpolate.ts
637
+ var BACK_C1 = 1.70158;
638
+ var BACK_C2 = BACK_C1 * 1.525;
639
+ var BACK_C3 = BACK_C1 + 1;
640
+ var ELASTIC_C4 = 2 * Math.PI / 3;
641
+ var ELASTIC_C5 = 2 * Math.PI / 4.5;
642
+ function easeOutBounce(u) {
643
+ const n1 = 7.5625;
644
+ const d1 = 2.75;
645
+ if (u < 1 / d1) return n1 * u * u;
646
+ if (u < 2 / d1) return n1 * (u -= 1.5 / d1) * u + 0.75;
647
+ if (u < 2.5 / d1) return n1 * (u -= 2.25 / d1) * u + 0.9375;
648
+ return n1 * (u -= 2.625 / d1) * u + 0.984375;
649
+ }
650
+ var EASE_TABLE = {
651
+ linear: (u) => u,
652
+ easeInQuad: (u) => u * u,
653
+ easeOutQuad: (u) => 1 - (1 - u) * (1 - u),
654
+ easeInOutQuad: (u) => u < 0.5 ? 2 * u * u : 1 - (-2 * u + 2) ** 2 / 2,
655
+ easeInCubic: (u) => u ** 3,
656
+ easeOutCubic: (u) => 1 - (1 - u) ** 3,
657
+ easeInOutCubic: (u) => u < 0.5 ? 4 * u ** 3 : 1 - (-2 * u + 2) ** 3 / 2,
658
+ easeInQuart: (u) => u ** 4,
659
+ easeOutQuart: (u) => 1 - (1 - u) ** 4,
660
+ easeInOutQuart: (u) => u < 0.5 ? 8 * u ** 4 : 1 - (-2 * u + 2) ** 4 / 2,
661
+ easeInExpo: (u) => u === 0 ? 0 : 2 ** (10 * u - 10),
662
+ easeOutExpo: (u) => u === 1 ? 1 : 1 - 2 ** (-10 * u),
663
+ easeInOutExpo: (u) => u === 0 ? 0 : u === 1 ? 1 : u < 0.5 ? 2 ** (20 * u - 10) / 2 : (2 - 2 ** (-20 * u + 10)) / 2,
664
+ // --- expressive eases (GSAP's signature feel) — standard Penner equations ---
665
+ // back: overshoots past the target then settles (pop / snap)
666
+ easeInBack: (u) => BACK_C3 * u ** 3 - BACK_C1 * u * u,
667
+ easeOutBack: (u) => 1 + BACK_C3 * (u - 1) ** 3 + BACK_C1 * (u - 1) ** 2,
668
+ easeInOutBack: (u) => u < 0.5 ? (2 * u) ** 2 * ((BACK_C2 + 1) * 2 * u - BACK_C2) / 2 : ((2 * u - 2) ** 2 * ((BACK_C2 + 1) * (2 * u - 2) + BACK_C2) + 2) / 2,
669
+ // elastic: rings around the target before settling (playful spring)
670
+ easeInElastic: (u) => u === 0 ? 0 : u === 1 ? 1 : -(2 ** (10 * u - 10)) * Math.sin((u * 10 - 10.75) * ELASTIC_C4),
671
+ easeOutElastic: (u) => u === 0 ? 0 : u === 1 ? 1 : 2 ** (-10 * u) * Math.sin((u * 10 - 0.75) * ELASTIC_C4) + 1,
672
+ easeInOutElastic: (u) => u === 0 ? 0 : u === 1 ? 1 : u < 0.5 ? -(2 ** (20 * u - 10) * Math.sin((20 * u - 11.125) * ELASTIC_C5)) / 2 : 2 ** (-20 * u + 10) * Math.sin((20 * u - 11.125) * ELASTIC_C5) / 2 + 1,
673
+ // bounce: drops and bounces to rest (lands without overshoot)
674
+ easeInBounce: (u) => 1 - easeOutBounce(1 - u),
675
+ easeOutBounce,
676
+ easeInOutBounce: (u) => u < 0.5 ? (1 - easeOutBounce(1 - 2 * u)) / 2 : (1 + easeOutBounce(2 * u - 1)) / 2
677
+ };
678
+ var EASE_NAMES = Object.keys(EASE_TABLE);
679
+
680
+ // ../core/src/evaluate.ts
681
+ var DEG = Math.PI / 180;
682
+
683
+ // ../core/src/assets.ts
684
+ function collectSrcs(ir, type) {
685
+ const srcs = /* @__PURE__ */ new Set();
686
+ const ids = /* @__PURE__ */ new Set();
687
+ const walkNodes = (nodes) => {
688
+ for (const node of nodes) {
689
+ if (node.type === type) {
690
+ ids.add(node.id);
691
+ srcs.add(node.props.src);
692
+ }
693
+ if (node.type === "group") walkNodes(node.children);
694
+ }
695
+ };
696
+ walkNodes(ir.nodes);
697
+ for (const overrides of Object.values(ir.states ?? {})) {
698
+ for (const [nodeId, props] of Object.entries(overrides)) {
699
+ if (ids.has(nodeId) && typeof props.src === "string") srcs.add(props.src);
700
+ }
701
+ }
702
+ const walkTimeline = (step) => {
703
+ if (!step) return;
704
+ if (step.kind === "seq" || step.kind === "par" || step.kind === "stagger") {
705
+ for (const child of step.children) walkTimeline(child);
706
+ } else if (step.kind === "tween" && ids.has(step.target)) {
707
+ const src = step.props.src;
708
+ if (typeof src === "string") srcs.add(src);
709
+ }
710
+ };
711
+ walkTimeline(ir.timeline);
712
+ return [...srcs];
713
+ }
714
+ function collectImageSrcs(ir) {
715
+ return collectSrcs(ir, "image");
716
+ }
717
+ function collectVideoSrcs(ir) {
718
+ return collectSrcs(ir, "video");
719
+ }
720
+
721
+ // ../render-cli/src/loadScene.ts
722
+ var HERE = dirname(fileURLToPath(import.meta.url));
723
+ var CORE_ENTRY = true ? resolve(HERE, "index.js") : resolve(HERE, "..", "..", "core", "src", "index.ts");
724
+ async function loadDefault(path2) {
725
+ if (path2.endsWith(".json")) return JSON.parse(await readFile(path2, "utf8"));
726
+ let code;
727
+ try {
728
+ const out = await build({
729
+ entryPoints: [path2],
730
+ bundle: true,
731
+ format: "esm",
732
+ platform: "neutral",
733
+ write: false,
734
+ logLevel: "silent",
735
+ sourcemap: "inline",
736
+ // both specifiers accepted: the guide's canonical "@reframe/core" and
737
+ // the published package name
738
+ alias: { "@reframe/core": CORE_ENTRY, "reframe-video": CORE_ENTRY }
739
+ });
740
+ code = out.outputFiles[0].text;
741
+ } catch (err) {
742
+ throw new Error(`failed to bundle ${path2}:
743
+ ${err instanceof Error ? err.message : String(err)}`);
744
+ }
745
+ const mod = await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`);
746
+ if (mod.default === void 0) throw new Error(`${path2} must default-export a scene or composition`);
747
+ return mod.default;
748
+ }
749
+ function isComposition(def) {
750
+ return typeof def === "object" && def !== null && Array.isArray(def.scenes);
751
+ }
752
+ async function loadModule(path2) {
753
+ const def = await loadDefault(path2);
754
+ if (isComposition(def)) {
755
+ validateComposition(def);
756
+ return { kind: "composition", ir: def };
757
+ }
758
+ validateScene(def);
759
+ return { kind: "scene", ir: def };
760
+ }
761
+
762
+ // ../render-cli/src/frameLoop.ts
763
+ import { join as join3, dirname as dirname3 } from "node:path";
764
+ import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
765
+ import { build as build2 } from "esbuild";
766
+ import { chromium } from "playwright";
767
+
768
+ // ../render-cli/src/fonts.ts
769
+ import { readFile as readFile2 } from "node:fs/promises";
770
+ import { dirname as dirname2, join } from "node:path";
771
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
772
+ var FONTS_DIR = true ? join(dirname2(fileURLToPath2(import.meta.url)), "..", "assets", "fonts") : join(dirname2(fileURLToPath2(import.meta.url)), "..", "..", "..", "assets", "fonts");
773
+ var WEIGHTS = [400, 700, 800];
774
+ var cssCache = null;
775
+ async function fontFaceCss() {
776
+ if (cssCache) return cssCache;
777
+ const rules = await Promise.all(
778
+ WEIGHTS.map(async (weight) => {
779
+ const data = await readFile2(join(FONTS_DIR, `inter-${weight}.woff2`));
780
+ return `@font-face {
781
+ font-family: "Inter";
782
+ font-style: normal;
783
+ font-weight: ${weight};
784
+ src: url(data:font/woff2;base64,${data.toString("base64")}) format("woff2");
785
+ }`;
786
+ })
787
+ );
788
+ cssCache = rules.join("\n");
789
+ return cssCache;
790
+ }
791
+
792
+ // ../render-cli/src/images.ts
793
+ import { readFile as readFile3 } from "node:fs/promises";
794
+ import { existsSync } from "node:fs";
795
+ import { extname, isAbsolute, resolve as resolve2 } from "node:path";
796
+ var MIME = {
797
+ ".png": "image/png",
798
+ ".jpg": "image/jpeg",
799
+ ".jpeg": "image/jpeg",
800
+ ".webp": "image/webp"
801
+ };
802
+ async function buildImageAssets(ir, sceneDir) {
803
+ const assets = {};
804
+ for (const src of collectImageSrcs(ir)) {
805
+ const mime = MIME[extname(src).toLowerCase()];
806
+ if (!mime) {
807
+ throw new Error(
808
+ `image "${src}": unsupported format "${extname(src)}" \u2014 supported: ${Object.keys(MIME).join(" ")}`
809
+ );
810
+ }
811
+ const candidates = [isAbsolute(src) ? src : null, resolve2(sceneDir, src)].filter(
812
+ (c) => c !== null
813
+ );
814
+ const found = candidates.find((c) => existsSync(c));
815
+ if (!found) {
816
+ throw new Error(`image "${src}" not found (tried: ${candidates.join(", ")})`);
817
+ }
818
+ const data = await readFile3(found);
819
+ assets[src] = `data:${mime};base64,${data.toString("base64")}`;
820
+ }
821
+ return assets;
822
+ }
823
+
824
+ // ../render-cli/src/videos.ts
825
+ import { spawn } from "node:child_process";
826
+ import { mkdtemp, readFile as readFile4, readdir, rm } from "node:fs/promises";
827
+ import { existsSync as existsSync2 } from "node:fs";
828
+ import { tmpdir } from "node:os";
829
+ import { extname as extname2, isAbsolute as isAbsolute2, join as join2, resolve as resolve3 } from "node:path";
830
+ var VIDEO_EXT = /* @__PURE__ */ new Set([".mp4", ".mov", ".webm", ".m4v", ".mkv"]);
831
+ function runFfmpeg(args) {
832
+ return new Promise((resolve5, reject) => {
833
+ const proc = spawn("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
834
+ let stderr = "";
835
+ proc.stderr.on("data", (d) => stderr += d.toString());
836
+ proc.on(
837
+ "close",
838
+ (code) => code === 0 ? resolve5() : reject(new Error(`ffmpeg exited ${code}:
839
+ ${stderr.slice(-2e3)}`))
840
+ );
841
+ proc.on("error", reject);
842
+ });
843
+ }
844
+ function neededSeconds(node, duration) {
845
+ const start = node.props.start ?? 0;
846
+ const rate = node.props.rate ?? 1;
847
+ const clipStart = node.props.clipStart ?? 0;
848
+ return clipStart + Math.max(0, duration - start) * Math.max(0, rate) + 1 / 30;
849
+ }
850
+ function videoNodes(ir) {
851
+ const out = [];
852
+ const walk = (nodes) => {
853
+ for (const n of nodes) {
854
+ if (n.type === "video") out.push(n);
855
+ if (n.type === "group") walk(n.children);
856
+ }
857
+ };
858
+ walk(ir.nodes);
859
+ return out;
860
+ }
861
+ async function buildVideoFrameAssets(ir, sceneDir, fps, duration) {
862
+ const srcs = collectVideoSrcs(ir);
863
+ if (srcs.length === 0) return {};
864
+ const nodes = videoNodes(ir);
865
+ const reachBySrc = /* @__PURE__ */ new Map();
866
+ for (const n of nodes) {
867
+ const reach = neededSeconds(n, duration);
868
+ reachBySrc.set(n.props.src, Math.max(reachBySrc.get(n.props.src) ?? 0, reach));
869
+ }
870
+ const assets = {};
871
+ for (const src of srcs) {
872
+ if (!VIDEO_EXT.has(extname2(src).toLowerCase())) {
873
+ throw new Error(
874
+ `video "${src}": unsupported format "${extname2(src)}" \u2014 supported: ${[...VIDEO_EXT].join(" ")}`
875
+ );
876
+ }
877
+ const candidates = [isAbsolute2(src) ? src : null, resolve3(sceneDir, src)].filter(
878
+ (c) => c !== null
879
+ );
880
+ const found = candidates.find((c) => existsSync2(c));
881
+ if (!found) throw new Error(`video "${src}" not found (tried: ${candidates.join(", ")})`);
882
+ const dir = await mkdtemp(join2(tmpdir(), "reframe-vframes-"));
883
+ try {
884
+ const seconds = Math.max(1 / fps, reachBySrc.get(src) ?? duration);
885
+ await runFfmpeg([
886
+ "-y",
887
+ "-i",
888
+ found,
889
+ "-t",
890
+ seconds.toFixed(3),
891
+ "-vf",
892
+ `fps=${fps},scale='min(iw,1280)':-2`,
893
+ "-q:v",
894
+ "4",
895
+ join2(dir, "%05d.jpg")
896
+ ]);
897
+ const files = (await readdir(dir)).filter((f) => f.endsWith(".jpg")).sort();
898
+ assets[src] = await Promise.all(
899
+ files.map(async (f) => `data:image/jpeg;base64,${(await readFile4(join2(dir, f))).toString("base64")}`)
900
+ );
901
+ if (assets[src].length === 0) throw new Error(`video "${src}": ffmpeg extracted no frames`);
902
+ } finally {
903
+ await rm(dir, { recursive: true, force: true });
904
+ }
905
+ }
906
+ return assets;
907
+ }
908
+ function resolveTiming(ir, opts) {
909
+ const fps = opts.fps ?? ir.fps ?? 30;
910
+ const duration = opts.duration ?? compileScene(ir).duration;
911
+ return { fps, duration };
912
+ }
913
+
914
+ // ../render-cli/src/vclock.ts
915
+ var VCLOCK_SOURCE = String.raw`
916
+ (() => {
917
+ let now = 0;
918
+ let nextId = 1;
919
+ let rafQueue = [];
920
+ const timers = [];
921
+
922
+ Date.now = () => now;
923
+ performance.now = () => now;
924
+
925
+ window.requestAnimationFrame = (cb) => {
926
+ const id = nextId++;
927
+ rafQueue.push({ id, cb });
928
+ return id;
929
+ };
930
+ window.cancelAnimationFrame = (id) => {
931
+ rafQueue = rafQueue.filter((r) => r.id !== id);
932
+ };
933
+
934
+ const addTimer = (cb, delay, args, interval) => {
935
+ const id = nextId++;
936
+ timers.push({
937
+ id,
938
+ cb: () => cb(...args),
939
+ due: now + Math.max(Number(delay) || 0, 0),
940
+ interval: interval ? Math.max(Number(delay) || 0, 1) : undefined,
941
+ });
942
+ return id;
943
+ };
944
+ const removeTimer = (id) => {
945
+ const i = timers.findIndex((t) => t.id === id);
946
+ if (i >= 0) timers.splice(i, 1);
947
+ };
948
+ window.setTimeout = (cb, delay = 0, ...args) =>
949
+ typeof cb === "function" ? addTimer(cb, delay, args, false) : 0;
950
+ window.setInterval = (cb, delay = 0, ...args) =>
951
+ typeof cb === "function" ? addTimer(cb, delay, args, true) : 0;
952
+ window.clearTimeout = removeTimer;
953
+ window.clearInterval = removeTimer;
954
+
955
+ window.__vclock = {
956
+ now: () => now,
957
+ advanceTo(targetMs) {
958
+ // Fire due timers in order, letting fired callbacks schedule new ones.
959
+ for (;;) {
960
+ timers.sort((a, b) => a.due - b.due);
961
+ const next = timers[0];
962
+ if (!next || next.due > targetMs) break;
963
+ now = next.due;
964
+ if (next.interval !== undefined) next.due += next.interval;
965
+ else timers.shift();
966
+ next.cb();
967
+ }
968
+ now = targetMs;
969
+ // One rAF batch per frame; callbacks registered during the batch run
970
+ // on the next advanceTo (matching real browser semantics).
971
+ const batch = rafQueue;
972
+ rafQueue = [];
973
+ for (const { cb } of batch) cb(now);
974
+ },
975
+ };
976
+ })();
977
+ `;
978
+
979
+ // ../render-cli/src/frameLoop.ts
980
+ async function injectFonts(page) {
981
+ await page.addStyleTag({ content: await fontFaceCss() });
982
+ await page.evaluate(async () => {
983
+ await Promise.all([...document.fonts].map((f) => f.load()));
984
+ await document.fonts.ready;
985
+ });
986
+ }
987
+ async function withPage(size, fn) {
988
+ const browser = await chromium.launch({
989
+ args: ["--force-color-profile=srgb", "--font-render-hinting=none"]
990
+ });
991
+ try {
992
+ const page = await browser.newPage({ viewport: size, deviceScaleFactor: 1 });
993
+ return await fn(page);
994
+ } finally {
995
+ await browser.close();
996
+ }
997
+ }
998
+ var bundleCache = null;
999
+ async function browserBundle() {
1000
+ if (bundleCache) return bundleCache;
1001
+ if (true) {
1002
+ const { readFile: readFile6 } = await import("node:fs/promises");
1003
+ bundleCache = await readFile6(
1004
+ join3(dirname3(fileURLToPath3(import.meta.url)), "browserEntry.js"),
1005
+ "utf8"
1006
+ );
1007
+ return bundleCache;
1008
+ }
1009
+ const entry = join3(dirname3(fileURLToPath3(import.meta.url)), "browserEntry.ts");
1010
+ const result = await build2({
1011
+ entryPoints: [entry],
1012
+ bundle: true,
1013
+ write: false,
1014
+ format: "iife",
1015
+ target: "es2022"
1016
+ });
1017
+ bundleCache = result.outputFiles[0].text;
1018
+ return bundleCache;
1019
+ }
1020
+ async function renderFrameAt(ir, t, opts = {}) {
1021
+ const sceneDir = opts.sceneDir ?? process.cwd();
1022
+ const assets = await buildImageAssets(ir, sceneDir);
1023
+ const { fps, duration } = resolveTiming(ir, {});
1024
+ const videoAssets = await buildVideoFrameAssets(ir, sceneDir, fps, duration);
1025
+ const bundle = await browserBundle();
1026
+ return withPage(ir.size, async (page) => {
1027
+ await page.setContent(`<!DOCTYPE html><html><body style="margin:0;background:#000"></body></html>`);
1028
+ await injectFonts(page);
1029
+ await page.addScriptTag({ content: bundle });
1030
+ await page.evaluate(
1031
+ ([sceneIr, imageAssets, vAssets]) => window.__reframe.init(sceneIr, imageAssets, vAssets),
1032
+ [ir, assets, videoAssets]
1033
+ );
1034
+ const dataUrl2 = await page.evaluate((tt) => window.__reframe.renderFrame(tt), t);
1035
+ return Buffer.from(dataUrl2.slice(22), "base64");
1036
+ });
1037
+ }
1038
+
1039
+ // ../render-cli/src/diff.ts
1040
+ var MODES = ["side", "blend", "diff", "grid"];
1041
+ var dataUrl = (buf, ext) => `data:image/${ext === ".jpg" || ext === ".jpeg" ? "jpeg" : ext === ".webp" ? "webp" : "png"};base64,${buf.toString("base64")}`;
1042
+ async function composite(refUrl, renderUrl, mode, outPath) {
1043
+ const browser = await chromium2.launch({ args: ["--force-color-profile=srgb"] });
1044
+ try {
1045
+ const page = await browser.newPage({ deviceScaleFactor: 1 });
1046
+ await page.evaluate("globalThis.__name = globalThis.__name || ((f) => f)");
1047
+ const png = await page.evaluate(
1048
+ async ([ref, render, m]) => {
1049
+ const load = (src) => new Promise((res, rej) => {
1050
+ const im = new Image();
1051
+ im.onload = () => res(im);
1052
+ im.onerror = rej;
1053
+ im.src = src;
1054
+ });
1055
+ const refImg = await load(ref);
1056
+ const canvas = document.createElement("canvas");
1057
+ const ctx = canvas.getContext("2d");
1058
+ const tag = (s, x, y) => {
1059
+ ctx.save();
1060
+ ctx.font = "600 18px sans-serif";
1061
+ const w = ctx.measureText(s).width + 16;
1062
+ ctx.fillStyle = "rgba(0,0,0,0.6)";
1063
+ ctx.fillRect(x, y, w, 28);
1064
+ ctx.fillStyle = "#fff";
1065
+ ctx.fillText(s, x + 8, y + 20);
1066
+ ctx.restore();
1067
+ };
1068
+ if (m === "grid") {
1069
+ canvas.width = refImg.width;
1070
+ canvas.height = refImg.height;
1071
+ ctx.drawImage(refImg, 0, 0);
1072
+ ctx.save();
1073
+ ctx.font = "14px sans-serif";
1074
+ ctx.lineWidth = 1;
1075
+ for (let x = 0; x <= canvas.width; x += 100) {
1076
+ ctx.strokeStyle = x % 500 === 0 ? "rgba(255,90,90,0.85)" : "rgba(255,90,90,0.4)";
1077
+ ctx.beginPath();
1078
+ ctx.moveTo(x, 0);
1079
+ ctx.lineTo(x, canvas.height);
1080
+ ctx.stroke();
1081
+ ctx.fillStyle = "rgba(255,150,150,0.95)";
1082
+ ctx.fillText(String(x), x + 3, 15);
1083
+ }
1084
+ for (let y = 0; y <= canvas.height; y += 100) {
1085
+ ctx.strokeStyle = y % 500 === 0 ? "rgba(255,90,90,0.85)" : "rgba(255,90,90,0.4)";
1086
+ ctx.beginPath();
1087
+ ctx.moveTo(0, y);
1088
+ ctx.lineTo(canvas.width, y);
1089
+ ctx.stroke();
1090
+ ctx.fillStyle = "rgba(255,150,150,0.95)";
1091
+ ctx.fillText(String(y), 3, y + 15);
1092
+ }
1093
+ ctx.restore();
1094
+ } else {
1095
+ const rImg = render ? await load(render) : null;
1096
+ if (m === "side") {
1097
+ const H = Math.min(refImg.height, rImg ? rImg.height : refImg.height, 1e3);
1098
+ const rw = refImg.width * (H / refImg.height);
1099
+ const rrw = rImg ? rImg.width * (H / rImg.height) : 0;
1100
+ const gap = rImg ? 24 : 0;
1101
+ canvas.width = Math.round(rw + (rImg ? gap + rrw : 0));
1102
+ canvas.height = Math.round(H);
1103
+ ctx.fillStyle = "#0a0a0a";
1104
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
1105
+ ctx.drawImage(refImg, 0, 0, rw, H);
1106
+ if (rImg) ctx.drawImage(rImg, rw + gap, 0, rrw, H);
1107
+ tag("reference", 10, 10);
1108
+ if (rImg) tag("render", Math.round(rw + gap) + 10, 10);
1109
+ } else {
1110
+ canvas.width = refImg.width;
1111
+ canvas.height = refImg.height;
1112
+ ctx.drawImage(refImg, 0, 0);
1113
+ if (rImg) {
1114
+ ctx.globalAlpha = m === "blend" ? 0.5 : 1;
1115
+ ctx.globalCompositeOperation = m === "diff" ? "difference" : "source-over";
1116
+ ctx.drawImage(rImg, 0, 0, canvas.width, canvas.height);
1117
+ ctx.globalAlpha = 1;
1118
+ ctx.globalCompositeOperation = "source-over";
1119
+ }
1120
+ tag(m, 10, 10);
1121
+ }
1122
+ }
1123
+ return canvas.toDataURL("image/png");
1124
+ },
1125
+ [refUrl, renderUrl, mode]
1126
+ );
1127
+ await writeFile(outPath, Buffer.from(png.slice(22), "base64"));
1128
+ } finally {
1129
+ await browser.close();
1130
+ }
1131
+ }
1132
+ async function main() {
1133
+ const argv = process.argv.slice(2);
1134
+ const ref = argv[0];
1135
+ if (!ref || ref.startsWith("-")) {
1136
+ console.error("usage: reframe diff <ref-image> [<scene.ts>] [--t <sec>] [--mode side|blend|diff|grid] [-o out.png]");
1137
+ process.exit(2);
1138
+ }
1139
+ let scene;
1140
+ let t = 0;
1141
+ let mode = "side";
1142
+ let out = "";
1143
+ for (let i = 1; i < argv.length; i++) {
1144
+ const a = argv[i];
1145
+ if (a === "--t") t = Number(argv[++i]);
1146
+ else if (a === "--mode") mode = argv[++i];
1147
+ else if (a === "-o") out = argv[++i];
1148
+ else if (!a.startsWith("-") && !scene) scene = a;
1149
+ else {
1150
+ console.error(`unknown argument: ${a}`);
1151
+ process.exit(2);
1152
+ }
1153
+ }
1154
+ if (!MODES.includes(mode)) {
1155
+ console.error(`unknown --mode "${mode}" \u2014 use ${MODES.join(", ")}`);
1156
+ process.exit(2);
1157
+ }
1158
+ const refPath = resolve4(ref);
1159
+ const refUrl = dataUrl(await readFile5(refPath), extname3(refPath).toLowerCase());
1160
+ let renderUrl = null;
1161
+ if (mode !== "grid") {
1162
+ if (!scene) {
1163
+ console.error(`--mode ${mode} needs a scene file (only --mode grid works on the reference alone)`);
1164
+ process.exit(2);
1165
+ }
1166
+ const scenePath = resolve4(scene);
1167
+ const loaded = await loadModule(scenePath);
1168
+ if (loaded.kind !== "scene") {
1169
+ console.error("diff needs a single scene (not a composition)");
1170
+ process.exit(2);
1171
+ }
1172
+ const buf = await renderFrameAt(loaded.ir, t, { sceneDir: dirname4(scenePath) });
1173
+ renderUrl = dataUrl(buf, ".png");
1174
+ }
1175
+ const outPath = out ? resolve4(out) : resolve4(`diff-${mode}.png`);
1176
+ await composite(refUrl, renderUrl, mode, outPath);
1177
+ console.log(outPath);
1178
+ }
1179
+ if (process.argv[1] && import.meta.url === pathToFileURL2(process.argv[1]).href) {
1180
+ main().catch((e) => {
1181
+ console.error(e instanceof Error ? e.message : String(e));
1182
+ process.exit(1);
1183
+ });
1184
+ }
1185
+ export {
1186
+ composite,
1187
+ dataUrl
1188
+ };
@@ -0,0 +1,96 @@
1
+ # reframe directing guide — high-end, reference-heavy pieces
2
+
3
+ Read this (after the syntax guide, `reframe guide`) when the ask is a CINEMATIC or
4
+ REFERENCE-FAITHFUL piece — a product teaser, a UI/session reproduction, a title
5
+ sequence, a data story — not a simple lower-third or KPI card (those you just write).
6
+ Simple jobs render first-try; the ceiling needs a process. This is that process.
7
+
8
+ ## What to get from the user (before writing anything)
9
+
10
+ Ask for / confirm these — vague prompts are why these pieces take many rounds:
11
+
12
+ - **Concept** in one line ("a faithful Claude Code session that builds a logo", "an app
13
+ that goes viral, everywhere").
14
+ - **References** — screenshots / a reference video / pasted real content (terminal output,
15
+ copy, data). For fidelity work, the reference IS the spec. Save them to disk so you can
16
+ `diff` against them.
17
+ - **Brand** — exact colors (hex), the wordmark, the font feel.
18
+ - **Format** — length (~10–20s is a good ceiling clip), aspect (16:9 / 9:16), with or
19
+ without sound.
20
+ - **Tone** — "Apple teaser" (slow, premium, lots of negative space) vs "faithful UI sim"
21
+ (exact, dense) vs "kinetic/energetic". This sets pacing and camera.
22
+
23
+ ## The loop
24
+
25
+ ### 1. Storyboard the beats FIRST (structure, not a flat timeline)
26
+
27
+ Name the acts with `beat("...", {}, [ ... ])` before animating. A beat is a labeled,
28
+ retimable narrative unit; its label anchors audio and lets you restructure whole sections.
29
+ A reliable arc: **setup → inciting beat → rising → climax → resolution.** Decide what each
30
+ beat shows and how long, THEN fill in motion. (See `device-hero.ts`: `beat("ki"/"seung"/
31
+ "jeon"/"gyeol", …)` — entrance → it-takes-off → everywhere → resolve.)
32
+
33
+ ### 2. Match references with `diff` (stop eyeballing)
34
+
35
+ Reproducing a screenshot pixel-faithfully is the hardest part. Use the tool:
36
+
37
+ ```
38
+ reframe diff ref.png --mode grid # labelled 100px grid over the screenshot → read coords, place nodes
39
+ reframe diff ref.png scene.ts --mode side # reference | your render, side by side
40
+ reframe diff ref.png scene.ts --mode diff # absolute difference — bright where you're off
41
+ reframe diff ref.png scene.ts --mode blend # 50% overlay — spot drift
42
+ ```
43
+
44
+ Loop: `--mode grid` to measure → write the node tree → `--mode side`/`diff` to compare →
45
+ fix coordinates/sizes/colors → repeat until faithful. Pick the frame with `--t <sec>`.
46
+
47
+ ### 3. Apply the cinematic-craft checklist
48
+
49
+ These are what make a piece read as premium, not a slideshow. Patterns proven in the
50
+ flagship scenes — reuse the technique, vary the content:
51
+
52
+ - **Camera moves with the story.** Push in on each beat: a `cameraTo(...)` running in `par`
53
+ with the beat's content. Frame the detail that matters, pull back to resolve. (See
54
+ `terminal-claude.ts` helpers `cam()`/`scroll()`/`show()` — focus + scroll + reveal as
55
+ parameterized eased moves.)
56
+ - **Curved entrances, not straight slides.** A hero enters on a `motionPath` arc with
57
+ `easeOutBack` (overshoot, then settle). (`device-hero.ts` `motionPath("phone-cam", [[…]],
58
+ { ease: "easeOutBack" })`.)
59
+ - **Fake depth.** Layer a backdrop of many faint concentric ellipses (a smooth glow, no hard
60
+ edge) + a spotlight + a cast shadow that tracks the hero + an impact ring on landing.
61
+ (`device-hero.ts` backdrop/spot/shadow/ring rig.) Or use real depth: `camera.perspective`
62
+ + per-node `z` (see the syntax guide's "Depth & perspective").
63
+ - **Layered idle motion.** Nothing should sit perfectly still. `oscillate` a few nodes at
64
+ DIFFERENT frequencies (slow float, slower tilt, a fast accent) for life during holds.
65
+ - **Sound on the beats.** `scene.audio` cues anchor to your beat/timeline labels, so they
66
+ survive retiming: `{ at: "land", file: "bong_001.ogg" }`, `{ at: "viral", offset: 0.4,
67
+ sfx: "pop" }`. An `ambient-pad` bgm with `duck` under the hits. Quote `reframe labels` to
68
+ see exact seconds.
69
+
70
+ ### 4. Verify objectively (don't argue about "more dynamic")
71
+
72
+ - `reframe labels scene.ts` — every label → exact seconds. The timing source for audio + a
73
+ sanity check that beats land when you think.
74
+ - `reframe motion out.mp4` — speeds, static fraction, oscillation rhythm, spikes. A vague
75
+ note like "make it punchier" becomes measurable: compare `meanSpeed`/`peakSpeed` before
76
+ and after; `staticFraction` too high = it drags.
77
+ - `reframe trace ref.mp4 --apply scene.ts` — when you have a reference VIDEO (not image),
78
+ extract its timing/easing and re-apply it onto YOUR node ids. Borrow the motion, keep your
79
+ assets.
80
+
81
+ ### 5. Hand-tune via preview → overlay
82
+
83
+ `reframe preview` to scrub, drag motionPath waypoints, and retime steps; export the overlay
84
+ JSON and render with `--overlay`. Those nudges survive a later regeneration (stable
85
+ addresses), so the human's polish isn't lost when you redo the base.
86
+
87
+ ## Pitfalls
88
+
89
+ - Don't animate before the structure is right — fixing pacing after everything is keyframed
90
+ is painful. Beats first.
91
+ - Reference fidelity is coordinates + color + type, mostly STATIC layout; get the held frame
92
+ matching with `diff` before adding motion.
93
+ - Keep `id`s/labels stable across rewrites (see `reframe guide --regen`) so the user's
94
+ overlay edits survive.
95
+ - It's still iterative. The tools cut the rounds; they don't remove the loop. Render, look,
96
+ adjust — the agent should render frames and read them, not guess.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reframe-video",
3
- "version": "0.6.12",
3
+ "version": "0.6.13",
4
4
  "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.",
5
5
  "keywords": [
6
6
  "motion-graphics",