reframe-video 0.6.33 → 0.6.39

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reframe",
3
- "version": "0.1.5",
3
+ "version": "0.1.8",
4
4
  "description": "Create and iterate motion-graphics videos as addressable data: deterministic mp4 renders, human edits that survive AI regeneration, label-anchored audio, data-driven batch rendering.",
5
5
  "author": {
6
6
  "name": "Kiyeon Jeon",
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env tsx
2
+
3
+ // ../render-cli/src/assemble.ts
4
+ import { writeFile } from "node:fs/promises";
5
+ import { existsSync } from "node:fs";
6
+ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
7
+
8
+ // ../render-cli/src/media/probe.ts
9
+ import { spawn } from "node:child_process";
10
+ var VIDEO_EXT = /\.(mp4|mov|webm|m4v|mkv)$/i;
11
+ function run(cmd, args) {
12
+ return new Promise((res, reject) => {
13
+ const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
14
+ let stdout = "", stderr = "";
15
+ proc.stdout.on("data", (d) => stdout += d.toString());
16
+ proc.stderr.on("data", (d) => stderr += d.toString());
17
+ proc.on("close", (code) => res({ code: code ?? 1, stdout, stderr }));
18
+ proc.on("error", reject);
19
+ });
20
+ }
21
+ async function probeMedia(file) {
22
+ const isVideo = VIDEO_EXT.test(file);
23
+ let out;
24
+ try {
25
+ out = await run("ffprobe", [
26
+ "-v",
27
+ "error",
28
+ "-show_entries",
29
+ "format=duration:stream=width,height",
30
+ "-of",
31
+ "json",
32
+ file
33
+ ]);
34
+ } catch {
35
+ throw new Error("ffprobe not found on PATH \u2014 install ffmpeg (macOS: brew install ffmpeg, debian: apt install ffmpeg)");
36
+ }
37
+ if (out.code !== 0) return { isVideo };
38
+ try {
39
+ const j = JSON.parse(out.stdout);
40
+ const d = j.format?.duration ? Number(j.format.duration) : NaN;
41
+ const stream = (j.streams ?? []).find((s) => s.width && s.height);
42
+ return {
43
+ isVideo,
44
+ ...Number.isFinite(d) && d > 0 ? { duration: d } : {},
45
+ ...stream ? { width: Number(stream.width), height: Number(stream.height) } : {}
46
+ };
47
+ } catch {
48
+ return { isVideo };
49
+ }
50
+ }
51
+
52
+ // ../render-cli/src/assemble.ts
53
+ var BGM_SYNTHS = ["ambient-pad", "lofi", "pulse", "tension", "uplift"];
54
+ function parseArgs(argv) {
55
+ const media = [];
56
+ const a = { media, hold: 3.5, seed: 0 };
57
+ for (let i = 0; i < argv.length; i++) {
58
+ const arg = argv[i];
59
+ const next = () => argv[++i] ?? fail(`${arg} needs a value`);
60
+ if (arg === "-o" || arg === "--out") a.out = next();
61
+ else if (arg === "--title") a.title = next();
62
+ else if (arg === "--bgm") a.bgm = next();
63
+ else if (arg === "--hold") a.hold = Number(next());
64
+ else if (arg === "--seed") a.seed = Number(next());
65
+ else if (arg.startsWith("-")) fail(`unknown flag "${arg}"`);
66
+ else media.push(arg);
67
+ }
68
+ return a;
69
+ }
70
+ function fail(msg) {
71
+ console.error(`error: ${msg}`);
72
+ process.exit(1);
73
+ }
74
+ var CWD = process.env.INIT_CWD ?? process.cwd();
75
+ var userPath = (p) => isAbsolute(p) ? p : resolve(CWD, p);
76
+ async function main() {
77
+ const args = parseArgs(process.argv.slice(2));
78
+ if (args.media.length === 0) {
79
+ fail('assemble needs at least one media file\nusage: reframe assemble <media...> [-o name] [--title "\u2026"] [--bgm <synth>] [--hold s] [--seed N]');
80
+ }
81
+ if (args.bgm && !BGM_SYNTHS.includes(args.bgm)) fail(`unknown --bgm "${args.bgm}" \u2014 valid: ${BGM_SYNTHS.join(", ")}`);
82
+ const outArg = (args.out ?? "media-story").replace(/\.ts$/, "");
83
+ const outPath = userPath(`${outArg}.ts`);
84
+ const name = basename(outPath, ".ts");
85
+ if (existsSync(outPath)) fail(`${outArg}.ts already exists`);
86
+ const outDir = dirname(outPath);
87
+ const shots = [];
88
+ for (const m of args.media) {
89
+ const abs = userPath(m);
90
+ if (!existsSync(abs)) fail(`no such file: ${abs}`);
91
+ const info = await probeMedia(abs);
92
+ const rel = relative(outDir, abs) || basename(abs);
93
+ const src = rel.split("\\").join("/");
94
+ const hold = info.isVideo && info.duration ? Math.min(10, Math.max(0.5, info.duration)) : args.hold;
95
+ const portrait = !!(info.width && info.height && info.height > info.width);
96
+ shots.push({ src, hold: Number(hold.toFixed(3)), isVideo: info.isVideo, ...portrait ? { ken: "pan" } : {} });
97
+ }
98
+ await writeFile(outPath, sceneSource(name, shots, args));
99
+ const vids = shots.filter((s) => s.isVideo).length;
100
+ console.log(`created ${name}.ts \u2014 ${shots.length} shots (${vids} video, ${shots.length - vids} image)`);
101
+ console.log(` next: reframe render ${name}.ts`);
102
+ }
103
+ function sceneSource(name, shots, args) {
104
+ const imports = ["scene", "photoMontage", "seq", "par"];
105
+ if (args.title) imports.push("title");
106
+ const shotLines = shots.map((s) => ` { src: ${JSON.stringify(s.src)}, hold: ${s.hold}${s.ken ? `, ken: ${JSON.stringify(s.ken)}` : ""} },`).join("\n");
107
+ const titleBlock = args.title ? `
108
+ const ttl = title({ text: ${JSON.stringify(args.title)}, id: "ttl", x: W / 2, y: H / 2, fontSize: 110, entrance: "cascade", exit: "dissolve", hold: 1.6 });
109
+ ` : "";
110
+ const nodes = args.title ? "[...m.nodes, ...ttl.nodes]" : "[...m.nodes]";
111
+ const timeline = args.title ? "par(m.timeline, ttl.timeline)" : "seq(m.timeline)";
112
+ const audio = args.bgm ? `
113
+ audio: { bgm: { synth: ${JSON.stringify(args.bgm)}, gain: 0.18, fadeIn: 1.2, fadeOut: 2 } },` : "";
114
+ return `import { ${imports.join(", ")} } from "@reframe/core";
115
+
116
+ // Assembled by \`reframe assemble\`. Clip holds were probed from the media (baked
117
+ // in, so the render is deterministic). Edit freely \u2014 change order/holds, the
118
+ // title, swap a src; node ids \`shot-0\`, \`shot-1\`\u2026 and \`ttl-*\` are stable.
119
+ const W = 1920, H = 1080;
120
+
121
+ const m = photoMontage([
122
+ ${shotLines}
123
+ ], { id: "shot", size: { width: W, height: H }, transition: 0.6, seed: ${args.seed} });
124
+ ${titleBlock}
125
+ export default scene({
126
+ id: ${JSON.stringify(name)},
127
+ size: { width: W, height: H },
128
+ fps: 30,
129
+ background: "#000000",
130
+ nodes: ${nodes},
131
+ timeline: ${timeline},${audio}
132
+ });
133
+ `;
134
+ }
135
+ main().catch((err) => {
136
+ console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
137
+ process.exit(1);
138
+ });
package/dist/bin.js CHANGED
@@ -258,11 +258,68 @@ function compileScene(ir) {
258
258
  const grouping = { kind: tl.parallel ? "par" : "seq", children: tl.children };
259
259
  const natural = durationOf(grouping, 0);
260
260
  const k = tl.scale ?? (tl.duration !== void 0 ? tl.duration / Math.max(1e-9, natural) : 1);
261
- const beatStart = tl.at ?? start + (tl.gap ?? 0);
261
+ const at = typeof tl.at === "number" ? tl.at : void 0;
262
+ const beatStart = at ?? start + (tl.gap ?? 0);
262
263
  return beatStart + k * natural;
263
264
  }
264
265
  }
265
266
  };
267
+ let labelClock;
268
+ const anyAnchor = (tl) => tl.kind === "beat" && typeof tl.at === "string" || "children" in tl && tl.children.some(anyAnchor);
269
+ if (ir.timeline && anyAnchor(ir.timeline)) {
270
+ const clock = /* @__PURE__ */ new Map();
271
+ const clockWalk = (tl, start) => {
272
+ let end = start;
273
+ switch (tl.kind) {
274
+ case "seq": {
275
+ let t = start;
276
+ for (const c of orderBeats(tl.children)) t = clockWalk(c, t);
277
+ end = t;
278
+ break;
279
+ }
280
+ case "par": {
281
+ for (const c of tl.children) end = Math.max(end, clockWalk(c, start));
282
+ break;
283
+ }
284
+ case "stagger": {
285
+ tl.children.forEach((c, i) => {
286
+ end = Math.max(end, clockWalk(c, start + i * tl.interval));
287
+ });
288
+ break;
289
+ }
290
+ case "wait":
291
+ end = start + tl.duration;
292
+ break;
293
+ case "tween":
294
+ end = start + (tl.duration ?? DEFAULT_TWEEN_DURATION);
295
+ break;
296
+ case "motionPath":
297
+ end = start + (tl.duration ?? DEFAULT_MOTIONPATH_DURATION);
298
+ break;
299
+ case "to": {
300
+ const override = ir.states?.[tl.state] ?? {};
301
+ const si = tl.stagger ?? 0;
302
+ const targets = nodeOrder.filter((id) => id in override && (tl.filter === void 0 || tl.filter.includes(id)));
303
+ end = start + (tl.duration ?? DEFAULT_TO_DURATION) + Math.max(0, targets.length - 1) * si;
304
+ break;
305
+ }
306
+ case "beat": {
307
+ const grouping = { kind: tl.parallel ? "par" : "seq", children: tl.children };
308
+ const k = tl.scale ?? (tl.duration !== void 0 ? tl.duration / Math.max(1e-9, durationOf(grouping, 0)) : 1);
309
+ const inner = k === 1 ? grouping : scaleTimeline(grouping, k);
310
+ const at = typeof tl.at === "number" ? tl.at : void 0;
311
+ const beatStart = at ?? start + (tl.gap ?? 0);
312
+ end = clockWalk(inner, beatStart);
313
+ clock.set(tl.name, { t0: beatStart, t1: end });
314
+ break;
315
+ }
316
+ }
317
+ if ("label" in tl && tl.label !== void 0) clock.set(tl.label, { t0: start, t1: end });
318
+ return end;
319
+ };
320
+ clockWalk(ir.timeline, 0);
321
+ labelClock = clock;
322
+ }
266
323
  const walk = (tl, start) => {
267
324
  const end = walkInner(tl, start);
268
325
  if ("label" in tl && tl.label !== void 0) labelTimes.set(tl.label, { t0: start, t1: end });
@@ -279,7 +336,8 @@ function compileScene(ir) {
279
336
  const grouping = { kind: tl.parallel ? "par" : "seq", children: tl.children };
280
337
  const k = tl.scale ?? (tl.duration !== void 0 ? tl.duration / Math.max(1e-9, durationOf(grouping, 0)) : 1);
281
338
  const inner = k === 1 ? grouping : scaleTimeline(grouping, k);
282
- const beatStart = tl.at ?? start + (tl.gap ?? 0);
339
+ const anchored = typeof tl.at === "string" ? labelClock?.get(tl.at)?.t0 : tl.at;
340
+ const beatStart = anchored !== void 0 ? anchored + (typeof tl.at === "string" ? tl.gap ?? 0 : 0) : start + (tl.gap ?? 0);
283
341
  const end = walk(inner, beatStart);
284
342
  beatTimes.set(tl.name, { t0: beatStart, t1: end });
285
343
  labelTimes.set(tl.name, { t0: beatStart, t1: end });
@@ -577,6 +635,7 @@ var init_interpolate = __esm({
577
635
  function validateScene(ir) {
578
636
  const problems = [];
579
637
  const nodeById = /* @__PURE__ */ new Map();
638
+ const startAnchors = [];
580
639
  const checkPaint = (where, value) => {
581
640
  if (typeof value !== "object" || value === null) return;
582
641
  const g = value;
@@ -609,6 +668,7 @@ function validateScene(ir) {
609
668
  if (typeof props.shadowBlur === "number" && props.shadowBlur < 0) problems.push(`node "${node.id}": shadowBlur must be >= 0`);
610
669
  if (typeof props.blend === "string" && !BLEND_MODES.has(props.blend)) problems.push(`node "${node.id}": unknown blend "${props.blend}" \u2014 use ${[...BLEND_MODES].join(", ")}`);
611
670
  if (typeof props.fit === "string" && !IMAGE_FITS.has(props.fit)) problems.push(`node "${node.id}": unknown fit "${props.fit}" \u2014 use ${[...IMAGE_FITS].join(", ")}`);
671
+ if (node.type === "video" && typeof node.props.start === "string") startAnchors.push({ id: node.id, at: node.props.start });
612
672
  if (node.type === "group") {
613
673
  const clip = node.props.clip;
614
674
  if (clip) {
@@ -669,6 +729,7 @@ function validateScene(ir) {
669
729
  );
670
730
  }
671
731
  const labels = /* @__PURE__ */ new Set();
732
+ const beatAnchors = [];
672
733
  const checkEase = (path2, ease) => {
673
734
  if (ease === void 0) return;
674
735
  if (typeof ease === "string") {
@@ -760,6 +821,7 @@ function validateScene(ir) {
760
821
  );
761
822
  }
762
823
  labels.add(tl.name);
824
+ if (typeof tl.at === "string") beatAnchors.push({ name: tl.name, at: tl.at, path: path2 });
763
825
  if (tl.duration !== void 0 && tl.duration <= 0) {
764
826
  problems.push(`${path2}: beat "${tl.name}" duration must be > 0`);
765
827
  }
@@ -778,6 +840,22 @@ function validateScene(ir) {
778
840
  }
779
841
  };
780
842
  if (ir.timeline) checkTimeline(ir.timeline, "timeline");
843
+ for (const a of beatAnchors) {
844
+ if (a.at === a.name) {
845
+ problems.push(`${a.path}: beat "${a.name}" at: "${a.at}" cannot anchor to itself`);
846
+ } else if (!labels.has(a.at)) {
847
+ problems.push(
848
+ `${a.path}: beat "${a.name}" at: "${a.at}" \u2014 unknown timeline label \u2014 known labels: ${[...labels].join(", ") || "(none)"}`
849
+ );
850
+ }
851
+ }
852
+ for (const a of startAnchors) {
853
+ if (!labels.has(a.at)) {
854
+ problems.push(
855
+ `video "${a.id}" start: "${a.at}" \u2014 unknown timeline label \u2014 known labels: ${[...labels].join(", ") || "(none)"}`
856
+ );
857
+ }
858
+ }
781
859
  for (const [i, b] of (ir.behaviors ?? []).entries()) {
782
860
  checkProps(`behaviors[${i}]`, b.target, { [b.prop]: 0 });
783
861
  }
@@ -1117,13 +1195,6 @@ function applyOverlay(ir, overlay, layer, report, baseNodeIds) {
1117
1195
  if ("children" in tl) tl.children.forEach(walkTimeline);
1118
1196
  };
1119
1197
  if (ir.timeline) walkTimeline(ir.timeline);
1120
- const PATCHABLE = {
1121
- to: ["duration", "ease", "stagger"],
1122
- tween: ["duration", "ease"],
1123
- wait: ["duration"],
1124
- motionPath: ["points", "duration", "ease", "curviness", "autoRotate"],
1125
- beat: ["at", "gap", "scale", "duration", "order"]
1126
- };
1127
1198
  let timingPatched = false;
1128
1199
  for (const [label, patch] of Object.entries(overlay.timeline)) {
1129
1200
  const step = byLabel.get(label);
@@ -1134,7 +1205,7 @@ function applyOverlay(ir, overlay, layer, report, baseNodeIds) {
1134
1205
  );
1135
1206
  continue;
1136
1207
  }
1137
- const allowed = PATCHABLE[step.kind] ?? [];
1208
+ const allowed = TIMELINE_PATCHABLE[step.kind] ?? [];
1138
1209
  for (const [key2, value] of Object.entries(patch)) {
1139
1210
  if (value === void 0) continue;
1140
1211
  if (!allowed.includes(key2)) {
@@ -1203,13 +1274,29 @@ function applyOverlay(ir, overlay, layer, report, baseNodeIds) {
1203
1274
  }
1204
1275
  }
1205
1276
  }
1206
- var SCENE_PATCHABLE;
1277
+ var SCENE_PATCHABLE, TIMELINE_PATCHABLE;
1207
1278
  var init_compose = __esm({
1208
1279
  "../core/src/compose.ts"() {
1209
1280
  "use strict";
1210
1281
  init_compile();
1211
1282
  init_validate();
1212
1283
  SCENE_PATCHABLE = ["background", "duration", "fps"];
1284
+ TIMELINE_PATCHABLE = {
1285
+ to: ["duration", "ease", "stagger"],
1286
+ tween: ["duration", "ease"],
1287
+ wait: ["duration"],
1288
+ motionPath: ["points", "duration", "ease", "curviness", "autoRotate"],
1289
+ beat: ["at", "gap", "scale", "duration", "order"]
1290
+ };
1291
+ }
1292
+ });
1293
+
1294
+ // ../core/src/manifest.ts
1295
+ var init_manifest = __esm({
1296
+ "../core/src/manifest.ts"() {
1297
+ "use strict";
1298
+ init_compose();
1299
+ init_validate();
1213
1300
  }
1214
1301
  });
1215
1302
 
@@ -1263,6 +1350,33 @@ var init_gradient = __esm({
1263
1350
  });
1264
1351
 
1265
1352
  // ../core/src/evaluate.ts
1353
+ function multiply(m, n) {
1354
+ return [
1355
+ m[0] * n[0] + m[2] * n[1],
1356
+ m[1] * n[0] + m[3] * n[1],
1357
+ m[0] * n[2] + m[2] * n[3],
1358
+ m[1] * n[2] + m[3] * n[3],
1359
+ m[0] * n[4] + m[2] * n[5] + m[4],
1360
+ m[1] * n[4] + m[3] * n[5] + m[5]
1361
+ ];
1362
+ }
1363
+ function localMatrix(x, y, rotationDeg, scale, scaleX = 1, scaleY = 1, skewXDeg = 0, skewYDeg = 0) {
1364
+ const r = rotationDeg * Math.PI / 180;
1365
+ if (scaleX === 1 && scaleY === 1 && skewXDeg === 0 && skewYDeg === 0) {
1366
+ const cos = Math.cos(r) * scale;
1367
+ const sin = Math.sin(r) * scale;
1368
+ return [cos, sin, -sin, cos, x, y];
1369
+ }
1370
+ const c = Math.cos(r);
1371
+ const s = Math.sin(r);
1372
+ const tx = Math.tan(skewXDeg * Math.PI / 180);
1373
+ const ty = Math.tan(skewYDeg * Math.PI / 180);
1374
+ const R = [c, s, -s, c, 0, 0];
1375
+ const K = [1, ty, tx, 1, 0, 0];
1376
+ const S = [scale * scaleX, 0, 0, scale * scaleY, 0, 0];
1377
+ const m = multiply(R, multiply(K, S));
1378
+ return [m[0], m[1], m[2], m[3], x, y];
1379
+ }
1266
1380
  function behaviorEnvelope(b, t) {
1267
1381
  const from = b.from ?? Number.NEGATIVE_INFINITY;
1268
1382
  const until = b.until ?? Number.POSITIVE_INFINITY;
@@ -1318,7 +1432,39 @@ function sampleProp(compiled, t, target, prop, fallback) {
1318
1432
  }
1319
1433
  return value;
1320
1434
  }
1321
- var DEG;
1435
+ function nodeParentMatrix(compiled, id, t) {
1436
+ const num2 = (target, prop, fallback) => {
1437
+ const v = sampleProp(compiled, t, target, prop, fallback);
1438
+ return typeof v === "number" ? v : fallback;
1439
+ };
1440
+ let result = null;
1441
+ const walk = (node, parent) => {
1442
+ if (node.id === id) {
1443
+ result = parent;
1444
+ return true;
1445
+ }
1446
+ if (node.type === "group") {
1447
+ const m = multiply(
1448
+ parent,
1449
+ localMatrix(
1450
+ num2(node.id, "x", node.props.x),
1451
+ num2(node.id, "y", node.props.y),
1452
+ num2(node.id, "rotation", node.props.rotation ?? 0),
1453
+ num2(node.id, "scale", node.props.scale ?? 1),
1454
+ num2(node.id, "scaleX", node.props.scaleX ?? 1),
1455
+ num2(node.id, "scaleY", node.props.scaleY ?? 1),
1456
+ num2(node.id, "skewX", node.props.skewX ?? 0),
1457
+ num2(node.id, "skewY", node.props.skewY ?? 0)
1458
+ )
1459
+ );
1460
+ for (const child of node.children) if (walk(child, m)) return true;
1461
+ }
1462
+ return false;
1463
+ };
1464
+ for (const node of compiled.ir.nodes) if (walk(node, IDENTITY)) break;
1465
+ return result;
1466
+ }
1467
+ var IDENTITY, DEG;
1322
1468
  var init_evaluate = __esm({
1323
1469
  "../core/src/evaluate.ts"() {
1324
1470
  "use strict";
@@ -1327,6 +1473,7 @@ var init_evaluate = __esm({
1327
1473
  init_gradient();
1328
1474
  init_interpolate();
1329
1475
  init_path();
1476
+ IDENTITY = [1, 0, 0, 1, 0, 0];
1330
1477
  DEG = Math.PI / 180;
1331
1478
  }
1332
1479
  });
@@ -1364,6 +1511,10 @@ function autoFoley(compiled, opts = {}) {
1364
1511
  os.push(num(compiled, t, id, "opacity", node.props.opacity ?? 1));
1365
1512
  }
1366
1513
  const speed = (i2) => i2 <= 0 ? 0 : Math.hypot(xs[i2] - xs[i2 - 1], ys[i2] - ys[i2 - 1]) * fps;
1514
+ const worldX = (frame) => {
1515
+ const m = nodeParentMatrix(compiled, id, frame / fps);
1516
+ return m ? m[0] * xs[frame] + m[2] * ys[frame] + m[4] : xs[frame];
1517
+ };
1367
1518
  let i = 1;
1368
1519
  while (i <= N) {
1369
1520
  if (speed(i) <= vMin) {
@@ -1386,19 +1537,20 @@ function autoFoley(compiled, opts = {}) {
1386
1537
  if (peakV > vMin && durS >= MIN_DUR && visible) {
1387
1538
  if (wantWhoosh) {
1388
1539
  const quickFlick = durS < 0.25;
1389
- cands.push({ t: peak / fps, sfx: quickFlick ? "swish" : "whoosh", gain: master * loud(peakV), pan: panOf(xs[peak]), rank: peakV });
1540
+ cands.push({ t: peak / fps, sfx: quickFlick ? "swish" : "whoosh", gain: master * loud(peakV), pan: panOf(worldX(peak)), rank: peakV });
1390
1541
  }
1391
1542
  const stopped = b >= N || speed(b + 1) < vStop && speed(Math.min(N, b + 2)) < vStop;
1392
- const landsOnScreen = xs[b] >= 0 && xs[b] <= W && os[b] > 0.1;
1543
+ const wxb = worldX(b);
1544
+ const landsOnScreen = wxb >= 0 && wxb <= W && os[b] > 0.1;
1393
1545
  if (wantImpact && peakV > vDecel && stopped && landsOnScreen && b < N) {
1394
- cands.push({ t: (b + 1) / fps, sfx: size > 220 ? "thud" : "knock", gain: master * loud(peakV), pan: panOf(xs[b]), rank: peakV * 1.1 });
1546
+ cands.push({ t: (b + 1) / fps, sfx: size > 220 ? "thud" : "knock", gain: master * loud(peakV), pan: panOf(worldX(b)), rank: peakV * 1.1 });
1395
1547
  }
1396
1548
  }
1397
1549
  }
1398
1550
  if (wantPop && ss[0] < 0.25) {
1399
1551
  for (let k = 1; k <= N; k++) {
1400
1552
  if (ss[k - 1] < 0.5 && ss[k] >= 0.5 && os[k] > 0.05) {
1401
- cands.push({ t: k / fps, sfx: "pop", gain: master * 0.7, pan: panOf(xs[k]), rank: 600 });
1553
+ cands.push({ t: k / fps, sfx: "pop", gain: master * 0.7, pan: panOf(worldX(k)), rank: 600 });
1402
1554
  break;
1403
1555
  }
1404
1556
  }
@@ -1461,6 +1613,31 @@ var init_montage = __esm({
1461
1613
  }
1462
1614
  });
1463
1615
 
1616
+ // ../core/src/textMetrics.ts
1617
+ var init_textMetrics = __esm({
1618
+ "../core/src/textMetrics.ts"() {
1619
+ "use strict";
1620
+ }
1621
+ });
1622
+
1623
+ // ../core/src/textFx.ts
1624
+ var init_textFx = __esm({
1625
+ "../core/src/textFx.ts"() {
1626
+ "use strict";
1627
+ init_dsl();
1628
+ init_textMetrics();
1629
+ }
1630
+ });
1631
+
1632
+ // ../core/src/titles.ts
1633
+ var init_titles = __esm({
1634
+ "../core/src/titles.ts"() {
1635
+ "use strict";
1636
+ init_dsl();
1637
+ init_textFx();
1638
+ }
1639
+ });
1640
+
1464
1641
  // ../core/src/presets.ts
1465
1642
  function makeRng(seed) {
1466
1643
  let a = seed >>> 0 || 2654435769;
@@ -1645,6 +1822,8 @@ var init_devicePreset = __esm({
1645
1822
  "../core/src/devicePreset.ts"() {
1646
1823
  "use strict";
1647
1824
  init_dsl();
1825
+ init_effects();
1826
+ init_gradient();
1648
1827
  }
1649
1828
  });
1650
1829
 
@@ -1682,22 +1861,6 @@ var init_figure = __esm({
1682
1861
  }
1683
1862
  });
1684
1863
 
1685
- // ../core/src/textMetrics.ts
1686
- var init_textMetrics = __esm({
1687
- "../core/src/textMetrics.ts"() {
1688
- "use strict";
1689
- }
1690
- });
1691
-
1692
- // ../core/src/textFx.ts
1693
- var init_textFx = __esm({
1694
- "../core/src/textFx.ts"() {
1695
- "use strict";
1696
- init_dsl();
1697
- init_textMetrics();
1698
- }
1699
- });
1700
-
1701
1864
  // ../core/src/motionOps.ts
1702
1865
  var init_motionOps = __esm({
1703
1866
  "../core/src/motionOps.ts"() {
@@ -1707,13 +1870,14 @@ var init_motionOps = __esm({
1707
1870
  });
1708
1871
 
1709
1872
  // ../core/src/audio.ts
1710
- function collectClipAudio(ir, duration, warnings) {
1873
+ function collectClipAudio(ir, duration, labelTimes, warnings) {
1711
1874
  const out = [];
1712
1875
  const walk = (nodes) => {
1713
1876
  for (const node of nodes) {
1714
1877
  if (node.type === "video") {
1715
1878
  const gain = node.props.volume ?? 1;
1716
- const start = node.props.start ?? 0;
1879
+ const startRaw = node.props.start;
1880
+ const start = typeof startRaw === "string" ? labelTimes.get(startRaw)?.t0 ?? 0 : startRaw ?? 0;
1717
1881
  if (gain <= 0) continue;
1718
1882
  if (start >= duration) {
1719
1883
  warnings.push(`video "${node.id}": start ${start.toFixed(2)}s past the scene end \u2014 audio dropped`);
@@ -1731,7 +1895,7 @@ function resolveAudioPlan(compiled) {
1731
1895
  const audio = compiled.ir.audio;
1732
1896
  const warnings = [];
1733
1897
  const duration = compiled.duration;
1734
- const clipAudio = collectClipAudio(compiled.ir, duration, warnings);
1898
+ const clipAudio = collectClipAudio(compiled.ir, duration, compiled.labelTimes, warnings);
1735
1899
  const autoCues = audio?.autoFoley ? autoFoley(compiled, audio.autoFoley === true ? {} : audio.autoFoley) : [];
1736
1900
  const manualCues = [...audio?.cues ?? [], ...autoCues];
1737
1901
  if (!audio || !audio.bgm && manualCues.length === 0) {
@@ -1919,6 +2083,7 @@ var init_src = __esm({
1919
2083
  init_validate();
1920
2084
  init_composeComposition();
1921
2085
  init_compose();
2086
+ init_manifest();
1922
2087
  init_compile();
1923
2088
  init_path();
1924
2089
  init_camera();
@@ -1927,6 +2092,7 @@ var init_src = __esm({
1927
2092
  init_effects();
1928
2093
  init_layout();
1929
2094
  init_montage();
2095
+ init_titles();
1930
2096
  init_presets();
1931
2097
  init_devicePreset();
1932
2098
  init_cursor();
@@ -3154,7 +3320,7 @@ ${stderr.slice(-2e3)}`))
3154
3320
  });
3155
3321
  }
3156
3322
  function neededSeconds(node, duration) {
3157
- const start = node.props.start ?? 0;
3323
+ const start = typeof node.props.start === "string" ? 0 : node.props.start ?? 0;
3158
3324
  const rate = node.props.rate ?? 1;
3159
3325
  const clipStart = node.props.clipStart ?? 0;
3160
3326
  return clipStart + Math.max(0, duration - start) * Math.max(0, rate) + 1 / 30;
@@ -3678,6 +3844,10 @@ var USER_CWD = process.env.INIT_CWD ?? process.cwd();
3678
3844
  var RENDER_CLI = PACKAGED ? join9(ROOT2, "dist", "cli.js") : join9(ROOT2, "packages", "render-cli", "src", "cli.ts");
3679
3845
  var LABELS = PACKAGED ? join9(ROOT2, "dist", "labels.js") : join9(ROOT2, "packages", "render-cli", "src", "labels.ts");
3680
3846
  var COMPILE = PACKAGED ? join9(ROOT2, "dist", "compile.js") : join9(ROOT2, "packages", "render-cli", "src", "compile.ts");
3847
+ var ASSEMBLE = PACKAGED ? join9(ROOT2, "dist", "assemble.js") : join9(ROOT2, "packages", "render-cli", "src", "assemble.ts");
3848
+ var MANIFEST = PACKAGED ? join9(ROOT2, "dist", "manifest.js") : join9(ROOT2, "packages", "render-cli", "src", "manifest.ts");
3849
+ var LINT = PACKAGED ? join9(ROOT2, "dist", "lint.js") : join9(ROOT2, "packages", "render-cli", "src", "lint.ts");
3850
+ var VERIFY_OVERLAY = PACKAGED ? join9(ROOT2, "dist", "verifyOverlay.js") : join9(ROOT2, "packages", "render-cli", "src", "verifyOverlay.ts");
3681
3851
  var DIFF = PACKAGED ? join9(ROOT2, "dist", "diff.js") : join9(ROOT2, "packages", "render-cli", "src", "diff.ts");
3682
3852
  var FRAME = PACKAGED ? join9(ROOT2, "dist", "frame.js") : join9(ROOT2, "packages", "render-cli", "src", "frame.ts");
3683
3853
  var PLAYER = PACKAGED ? join9(ROOT2, "dist", "player.js") : join9(ROOT2, "packages", "render-cli", "src", "player.ts");
@@ -3708,7 +3878,12 @@ usage:
3708
3878
  player (plays live in any browser or a Claude.ai artifact; visual only)
3709
3879
  ${CMD} preview open the scrub/edit UI (lists scenes in your directory)
3710
3880
  ${CMD} new <scene-name> scaffold <scene-name>.ts in your directory
3881
+ ${CMD} assemble <media...> [-o name] [--title "\u2026"] [--bgm <synth>] [--hold s] [--seed N]
3882
+ probe images/videos \u2192 scaffold a clip-aware montage scene .ts (then render it)
3711
3883
  ${CMD} labels <scene.ts|.json> print the event clock (label \u2192 exact seconds; for sound design / timing)
3884
+ ${CMD} manifest <scene.ts|.json> [--json] list the editable surface (node/state/label/beat/behavior addresses + patchable props)
3885
+ ${CMD} lint <scene.ts|.json> [--json] [--strict] flag un-addressable motion (regen-unsafe) + an addressability summary
3886
+ ${CMD} verify-overlay <base.ts|.json> <overlay.json>... [--json] compose an overlay onto a base, report survival (no render; non-zero exit on orphans)
3712
3887
  ${CMD} compile <scene.ts|.json> [-o out.json] [--stdin] [--code "<src>"] [--json]
3713
3888
  bundle + validate a scene to SceneIR JSON, no render (fast; no ffmpeg/chromium)
3714
3889
  ${CMD} frame <scene.ts|.json> [--t <sec>] [-o out.png] render ONE frame at time t to a PNG (no mp4; for a render-and-look loop)
@@ -3861,6 +4036,43 @@ ${USAGE}`);
3861
4036
  await (PACKAGED ? run2(process.execPath, [LABELS, inputPath]) : run2("npx", ["tsx", LABELS, inputPath]))
3862
4037
  );
3863
4038
  }
4039
+ case "assemble": {
4040
+ if (rest.length === 0) fail(`assemble needs at least one media file
4041
+
4042
+ ${USAGE}`);
4043
+ process.exit(
4044
+ await (PACKAGED ? run2(process.execPath, [ASSEMBLE, ...rest]) : run2("npx", ["tsx", ASSEMBLE, ...rest]))
4045
+ );
4046
+ }
4047
+ case "manifest":
4048
+ case "lint": {
4049
+ const input = rest.find((a) => !a.startsWith("-"));
4050
+ if (!input) fail(`${command} needs a scene file
4051
+
4052
+ ${USAGE}`);
4053
+ const inputPath = userPath(input);
4054
+ if (!existsSync6(inputPath)) fail(`no such file: ${inputPath}`);
4055
+ const flags = rest.filter((a) => a.startsWith("-"));
4056
+ const entry = command === "manifest" ? MANIFEST : LINT;
4057
+ process.exit(
4058
+ await (PACKAGED ? run2(process.execPath, [entry, inputPath, ...flags]) : run2("npx", ["tsx", entry, inputPath, ...flags]))
4059
+ );
4060
+ }
4061
+ case "verify-overlay": {
4062
+ const fileArgs = rest.filter((a) => !a.startsWith("-"));
4063
+ const flags = rest.filter((a) => a.startsWith("-"));
4064
+ if (fileArgs.length < 2) fail(`verify-overlay needs a base scene and at least one overlay
4065
+
4066
+ ${USAGE}`);
4067
+ const paths = fileArgs.map((p) => {
4068
+ const ap = userPath(p);
4069
+ if (!existsSync6(ap)) fail(`no such file: ${ap}`);
4070
+ return ap;
4071
+ });
4072
+ process.exit(
4073
+ await (PACKAGED ? run2(process.execPath, [VERIFY_OVERLAY, ...paths, ...flags]) : run2("npx", ["tsx", VERIFY_OVERLAY, ...paths, ...flags]))
4074
+ );
4075
+ }
3864
4076
  case "compile": {
3865
4077
  const hasInlineSource = rest.includes("--stdin") || rest.includes("--code");
3866
4078
  const fileArg = rest.find((a, i) => !a.startsWith("-") && !["-o", "--code", "--timeout"].includes(rest[i - 1] ?? ""));