reframe-video 0.1.3 → 0.3.0

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.
Files changed (50) hide show
  1. package/assets/sfx/LICENSE.md +2 -1
  2. package/assets/sfx/bong_001.ogg +0 -0
  3. package/assets/sfx/click_001.ogg +0 -0
  4. package/assets/sfx/confirmation_002.ogg +0 -0
  5. package/assets/sfx/confirmation_003.ogg +0 -0
  6. package/assets/sfx/confirmation_004.ogg +0 -0
  7. package/assets/sfx/footstep_001.ogg +0 -0
  8. package/assets/sfx/footstep_002.ogg +0 -0
  9. package/assets/sfx/footstep_003.ogg +0 -0
  10. package/assets/sfx/glass_001.ogg +0 -0
  11. package/assets/sfx/maximize_001.ogg +0 -0
  12. package/assets/sfx/maximize_002.ogg +0 -0
  13. package/assets/sfx/maximize_005.ogg +0 -0
  14. package/assets/sfx/maximize_009.ogg +0 -0
  15. package/assets/sfx/open_001.ogg +0 -0
  16. package/assets/sfx/pluck_001.ogg +0 -0
  17. package/assets/sfx/pluck_002.ogg +0 -0
  18. package/assets/sfx/select_001.ogg +0 -0
  19. package/assets/sfx/select_002.ogg +0 -0
  20. package/assets/sfx/select_003.ogg +0 -0
  21. package/dist/bin.js +271 -49
  22. package/dist/browserEntry.js +179 -68
  23. package/dist/cli.js +445 -85
  24. package/dist/index.js +1187 -116
  25. package/dist/labels.js +606 -0
  26. package/dist/renderer-canvas.js +15 -0
  27. package/dist/trace-cli.js +9 -9
  28. package/dist/types/audio.d.ts +9 -0
  29. package/dist/types/characterPreset.d.ts +39 -0
  30. package/dist/types/compile.d.ts +1 -0
  31. package/dist/types/compose.d.ts +18 -2
  32. package/dist/types/composeComposition.d.ts +27 -0
  33. package/dist/types/devicePreset.d.ts +65 -0
  34. package/dist/types/dsl.d.ts +12 -1
  35. package/dist/types/evaluate.d.ts +32 -0
  36. package/dist/types/figure.d.ts +32 -0
  37. package/dist/types/index.d.ts +9 -3
  38. package/dist/types/interpolate.d.ts +3 -2
  39. package/dist/types/ir.d.ts +68 -0
  40. package/dist/types/motionOps.d.ts +36 -0
  41. package/dist/types/path.d.ts +7 -3
  42. package/dist/types/rig.d.ts +87 -0
  43. package/dist/types/validate.d.ts +4 -1
  44. package/guides/edsl-guide.md +54 -1
  45. package/guides/regen-contract.md +11 -0
  46. package/package.json +1 -1
  47. package/preview/index.html +56 -3
  48. package/preview/src/main.ts +1132 -46
  49. package/preview/src/panel.ts +478 -8
  50. package/preview/src/store.ts +323 -6
package/dist/cli.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env tsx
2
2
 
3
3
  // ../render-cli/src/cli.ts
4
- import { mkdtemp as mkdtemp2, readFile as readFile4, rm as rm2 } from "node:fs/promises";
5
- import { tmpdir as tmpdir3 } from "node:os";
6
- import { basename, dirname as dirname6, join as join5, resolve as resolve4 } from "node:path";
4
+ import { mkdtemp as mkdtemp3, readFile as readFile4, rm as rm3 } from "node:fs/promises";
5
+ import { tmpdir as tmpdir4 } from "node:os";
6
+ import { basename, dirname as dirname7, join as join6, resolve as resolve4 } from "node:path";
7
7
 
8
8
  // ../core/src/ir.ts
9
+ var DEFAULT_CROSSFADE = 0.5;
9
10
  var DEFAULT_TO_DURATION = 0.5;
10
11
  var DEFAULT_TWEEN_DURATION = 0.5;
11
12
  var DEFAULT_MOTIONPATH_DURATION = 1;
@@ -32,7 +33,7 @@ function segCountOf(points, closed) {
32
33
  if (n < 2) return 0;
33
34
  return closed ? n : n - 1;
34
35
  }
35
- function pathPoint(points, closed, u) {
36
+ function pathPoint(points, closed, u, curviness = 1) {
36
37
  const n = points.length;
37
38
  if (n === 0) return [0, 0];
38
39
  if (n === 1) return [points[0][0], points[0][1]];
@@ -41,19 +42,41 @@ function pathPoint(points, closed, u) {
41
42
  const [p0, p1, p2, p3] = controls(points, closed, i);
42
43
  const t2 = t * t;
43
44
  const t3 = t2 * t;
44
- 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);
45
- return [f(p0[0], p1[0], p2[0], p3[0]), f(p0[1], p1[1], p2[1], p3[1])];
45
+ if (curviness === 1) {
46
+ 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);
47
+ return [f(p0[0], p1[0], p2[0], p3[0]), f(p0[1], p1[1], p2[1], p3[1])];
48
+ }
49
+ const h00 = 2 * t3 - 3 * t2 + 1;
50
+ const h10 = t3 - 2 * t2 + t;
51
+ const h01 = -2 * t3 + 3 * t2;
52
+ const h11 = t3 - t2;
53
+ const k = curviness * 0.5;
54
+ const H = (a, b, c, d) => h00 * b + h10 * k * (c - a) + h01 * c + h11 * k * (d - b);
55
+ return [H(p0[0], p1[0], p2[0], p3[0]), H(p0[1], p1[1], p2[1], p3[1])];
46
56
  }
47
- function pathTangentAngle(points, closed, u) {
57
+ function pathTangentAngle(points, closed, u, curviness = 1) {
48
58
  const n = points.length;
49
59
  if (n < 2) return 0;
50
60
  const segs = segCountOf(points, closed);
51
61
  const { i, t } = locate(segs, u);
52
62
  const [p0, p1, p2, p3] = controls(points, closed, i);
53
63
  const t2 = t * t;
54
- 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);
55
- const dx = d(p0[0], p1[0], p2[0], p3[0]);
56
- const dy = d(p0[1], p1[1], p2[1], p3[1]);
64
+ let dx;
65
+ let dy;
66
+ if (curviness === 1) {
67
+ 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);
68
+ dx = d(p0[0], p1[0], p2[0], p3[0]);
69
+ dy = d(p0[1], p1[1], p2[1], p3[1]);
70
+ } else {
71
+ const g00 = 6 * t2 - 6 * t;
72
+ const g10 = 3 * t2 - 4 * t + 1;
73
+ const g01 = -6 * t2 + 6 * t;
74
+ const g11 = 3 * t2 - 2 * t;
75
+ const k = curviness * 0.5;
76
+ const D = (a, b, c, e) => g00 * b + g10 * k * (c - a) + g01 * c + g11 * k * (e - b);
77
+ dx = D(p0[0], p1[0], p2[0], p3[0]);
78
+ dy = D(p0[1], p1[1], p2[1], p3[1]);
79
+ }
57
80
  if (dx === 0 && dy === 0) return 0;
58
81
  return Math.atan2(dy, dx) * 180 / Math.PI;
59
82
  }
@@ -130,8 +153,8 @@ function compileScene(ir) {
130
153
  const currentValue = (target, prop) => {
131
154
  const v = current.get(key(target, prop));
132
155
  if (v !== void 0) return v;
133
- if (prop === "opacity" || prop === "scale" || prop === "progress") return 1;
134
- if (prop === "rotation") return 0;
156
+ if (prop === "opacity" || prop === "scale" || prop === "progress" || prop === "scaleX" || prop === "scaleY") return 1;
157
+ if (prop === "rotation" || prop === "skewX" || prop === "skewY") return 0;
135
158
  throw new Error(`cannot animate "${prop}" of "${target}": no base value to start from`);
136
159
  };
137
160
  const labelTimes = /* @__PURE__ */ new Map();
@@ -234,16 +257,17 @@ function compileScene(ir) {
234
257
  const duration = tl.duration ?? DEFAULT_MOTIONPATH_DURATION;
235
258
  const points = tl.points;
236
259
  const closed = tl.closed ?? false;
260
+ const curviness = tl.curviness ?? 1;
237
261
  const autoRotate = tl.autoRotate ?? false;
238
262
  const rotateOffset = tl.rotateOffset ?? 0;
239
263
  let list = motionPaths.get(tl.target);
240
264
  if (!list) motionPaths.set(tl.target, list = []);
241
- list.push({ t0: start, t1: start + duration, points, closed, autoRotate, rotateOffset, ...tl.ease !== void 0 && { ease: tl.ease } });
265
+ list.push({ t0: start, t1: start + duration, points, closed, curviness, autoRotate, rotateOffset, ...tl.ease !== void 0 && { ease: tl.ease } });
242
266
  if (points.length > 0) {
243
- const [ex, ey] = pathPoint(points, closed, 1);
267
+ const [ex, ey] = pathPoint(points, closed, 1, curviness);
244
268
  current.set(key(tl.target, "x"), ex);
245
269
  current.set(key(tl.target, "y"), ey);
246
- if (autoRotate) current.set(key(tl.target, "rotation"), pathTangentAngle(points, closed, 1) + rotateOffset);
270
+ if (autoRotate) current.set(key(tl.target, "rotation"), pathTangentAngle(points, closed, 1, curviness) + rotateOffset);
247
271
  }
248
272
  return start + duration;
249
273
  }
@@ -290,7 +314,7 @@ function compileScene(ir) {
290
314
  }
291
315
 
292
316
  // ../core/src/validate.ts
293
- var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "anchor"];
317
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor"];
294
318
  var PROPS_BY_TYPE = {
295
319
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
296
320
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
@@ -318,7 +342,18 @@ function validateScene(ir) {
318
342
  problems.push(`duplicate node id "${node.id}" \u2014 every node id must be unique`);
319
343
  }
320
344
  nodeById.set(node.id, node);
321
- if (node.type === "group") collect(node.children);
345
+ if (node.type === "group") {
346
+ const clip = node.props.clip;
347
+ if (clip) {
348
+ if (clip.kind !== "rect" && clip.kind !== "ellipse") {
349
+ problems.push(`group "${node.id}" clip: unknown kind "${clip.kind}" \u2014 use "rect" or "ellipse"`);
350
+ }
351
+ if (!(clip.width > 0) || !(clip.height > 0)) {
352
+ problems.push(`group "${node.id}" clip: width and height must be > 0`);
353
+ }
354
+ }
355
+ collect(node.children);
356
+ }
322
357
  }
323
358
  };
324
359
  collect(ir.nodes);
@@ -351,11 +386,11 @@ function validateScene(ir) {
351
386
  );
352
387
  }
353
388
  const labels = /* @__PURE__ */ new Set();
354
- const checkTimeline = (tl, path) => {
389
+ const checkTimeline = (tl, path2) => {
355
390
  if ("label" in tl && tl.label !== void 0) {
356
391
  if (labels.has(tl.label)) {
357
392
  problems.push(
358
- `${path}: duplicate timeline label "${tl.label}" \u2014 labels are overlay addresses and must be unique`
393
+ `${path2}: duplicate timeline label "${tl.label}" \u2014 labels are overlay addresses and must be unique`
359
394
  );
360
395
  }
361
396
  labels.add(tl.label);
@@ -363,63 +398,73 @@ function validateScene(ir) {
363
398
  switch (tl.kind) {
364
399
  case "seq":
365
400
  case "par":
366
- tl.children.forEach((c, i) => checkTimeline(c, `${path}.${tl.kind}[${i}]`));
401
+ tl.children.forEach((c, i) => checkTimeline(c, `${path2}.${tl.kind}[${i}]`));
367
402
  break;
368
403
  case "stagger":
369
- if (tl.interval < 0) problems.push(`${path}: stagger interval must be >= 0`);
370
- tl.children.forEach((c, i) => checkTimeline(c, `${path}.stagger[${i}]`));
404
+ if (tl.interval < 0) problems.push(`${path2}: stagger interval must be >= 0`);
405
+ tl.children.forEach((c, i) => checkTimeline(c, `${path2}.stagger[${i}]`));
371
406
  break;
372
407
  case "to":
373
408
  if (!(tl.state in states)) {
374
409
  problems.push(
375
- `${path}: to("${tl.state}") references an undefined state \u2014 defined states: ${Object.keys(states).join(", ") || "(none)"}`
410
+ `${path2}: to("${tl.state}") references an undefined state \u2014 defined states: ${Object.keys(states).join(", ") || "(none)"}`
376
411
  );
377
412
  }
378
413
  if (tl.duration !== void 0 && tl.duration <= 0) {
379
- problems.push(`${path}: to("${tl.state}") duration must be > 0`);
414
+ problems.push(`${path2}: to("${tl.state}") duration must be > 0`);
380
415
  }
381
416
  for (const id of tl.filter ?? []) {
382
- if (!nodeById.has(id)) problems.push(`${path}: filter contains unknown node "${id}"`);
417
+ if (!nodeById.has(id)) problems.push(`${path2}: filter contains unknown node "${id}"`);
383
418
  }
384
419
  break;
385
420
  case "tween":
386
- checkProps(path, tl.target, tl.props);
421
+ checkProps(path2, tl.target, tl.props);
387
422
  if (tl.duration !== void 0 && tl.duration <= 0) {
388
- problems.push(`${path}: tween duration must be > 0`);
423
+ problems.push(`${path2}: tween duration must be > 0`);
389
424
  }
390
425
  break;
391
426
  case "motionPath": {
392
427
  const node = nodeById.get(tl.target);
393
428
  if (!node) {
394
429
  problems.push(
395
- `${path}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
430
+ `${path2}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
396
431
  );
397
432
  } else if (node.type === "line") {
398
- problems.push(`${path}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
433
+ problems.push(`${path2}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
399
434
  }
400
- if (tl.points.length < 1) problems.push(`${path}: motionPath "${tl.target}" needs at least 1 point`);
435
+ if (tl.points.length < 1) problems.push(`${path2}: motionPath "${tl.target}" needs at least 1 point`);
401
436
  if (tl.duration !== void 0 && tl.duration <= 0) {
402
- problems.push(`${path}: motionPath "${tl.target}" duration must be > 0`);
437
+ problems.push(`${path2}: motionPath "${tl.target}" duration must be > 0`);
438
+ }
439
+ if (tl.curviness !== void 0 && tl.curviness < 0) {
440
+ problems.push(`${path2}: motionPath "${tl.target}" curviness must be >= 0`);
403
441
  }
404
442
  break;
405
443
  }
406
444
  case "wait":
407
- if (tl.duration < 0) problems.push(`${path}: wait duration must be >= 0`);
445
+ if (tl.duration < 0) problems.push(`${path2}: wait duration must be >= 0`);
408
446
  break;
409
447
  case "beat":
410
448
  if (labels.has(tl.name)) {
411
449
  problems.push(
412
- `${path}: duplicate timeline label "${tl.name}" (beat name) \u2014 labels are overlay addresses and must be unique`
450
+ `${path2}: duplicate timeline label "${tl.name}" (beat name) \u2014 labels are overlay addresses and must be unique`
413
451
  );
414
452
  }
415
453
  labels.add(tl.name);
416
454
  if (tl.duration !== void 0 && tl.duration <= 0) {
417
- problems.push(`${path}: beat "${tl.name}" duration must be > 0`);
455
+ problems.push(`${path2}: beat "${tl.name}" duration must be > 0`);
418
456
  }
419
457
  if (tl.scale !== void 0 && tl.scale <= 0) {
420
- problems.push(`${path}: beat "${tl.name}" scale must be > 0`);
458
+ problems.push(`${path2}: beat "${tl.name}" scale must be > 0`);
459
+ }
460
+ for (const id of tl.nodes ?? []) {
461
+ if (!nodeById.has(id)) {
462
+ problems.push(
463
+ `${path2}: beat "${tl.name}" owns unknown node "${id}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
464
+ );
465
+ }
421
466
  }
422
- tl.children.forEach((c, i) => checkTimeline(c, `${path}.beat(${tl.name})[${i}]`));
467
+ tl.children.forEach((c, i) => checkTimeline(c, `${path2}.beat(${tl.name})[${i}]`));
423
468
  break;
424
469
  }
425
470
  };
@@ -459,12 +504,78 @@ function validateScene(ir) {
459
504
  }
460
505
  if (problems.length > 0) throw new SceneValidationError(problems);
461
506
  }
507
+ var TRANSITIONS = ["cut", "crossfade"];
508
+ function validateComposition(comp) {
509
+ const problems = [];
510
+ if (comp.scenes.length === 0) problems.push("composition has no scenes");
511
+ const seen = /* @__PURE__ */ new Set();
512
+ for (const [i, entry] of comp.scenes.entries()) {
513
+ const where = `scenes[${i}]`;
514
+ try {
515
+ validateScene(entry.scene);
516
+ } catch (err) {
517
+ if (err instanceof SceneValidationError) {
518
+ for (const p of err.problems) problems.push(`${where} (scene "${entry.scene.id}"): ${p}`);
519
+ } else throw err;
520
+ }
521
+ if (seen.has(entry.scene.id)) {
522
+ problems.push(`${where}: duplicate scene id "${entry.scene.id}" \u2014 scene ids must be unique in a composition`);
523
+ }
524
+ seen.add(entry.scene.id);
525
+ if (entry.transition !== void 0 && !TRANSITIONS.includes(entry.transition)) {
526
+ problems.push(`${where}: unknown transition "${entry.transition}" \u2014 valid: ${TRANSITIONS.join(", ")}`);
527
+ }
528
+ if (typeof entry.at === "string" && Number.isNaN(Number(entry.at))) {
529
+ problems.push(`${where}: "at" string "${entry.at}" is not a number (use "-0.5"/"+0.5" or a number)`);
530
+ }
531
+ if (typeof entry.at === "number" && entry.at < 0) {
532
+ problems.push(`${where}: absolute "at" must be >= 0`);
533
+ }
534
+ }
535
+ if (problems.length > 0) throw new SceneValidationError(problems);
536
+ }
537
+
538
+ // ../core/src/composeComposition.ts
539
+ function compileComposition(comp) {
540
+ const scenes = [];
541
+ let prevEnd = 0;
542
+ comp.scenes.forEach((entry, i) => {
543
+ const compiled = compileScene(entry.scene);
544
+ const duration2 = compiled.duration;
545
+ const transition = entry.transition ?? "cut";
546
+ const append = i === 0 ? 0 : prevEnd;
547
+ let start;
548
+ if (typeof entry.at === "number") {
549
+ start = entry.at;
550
+ } else if (typeof entry.at === "string") {
551
+ start = append + Number(entry.at);
552
+ } else if (transition === "crossfade" && i > 0) {
553
+ start = append - DEFAULT_CROSSFADE;
554
+ } else {
555
+ start = append;
556
+ }
557
+ start = Math.max(0, start);
558
+ const overlap = i > 0 ? Math.max(0, prevEnd - start) : 0;
559
+ scenes.push({ id: entry.scene.id, scene: entry.scene, compiled, start, duration: duration2, transition, overlap });
560
+ prevEnd = start + duration2;
561
+ });
562
+ const duration = scenes.reduce((max, s) => Math.max(max, s.start + s.duration), 0);
563
+ return { ir: comp, scenes, duration };
564
+ }
462
565
 
463
566
  // ../core/src/compose.ts
464
567
  var SCENE_PATCHABLE = ["background", "duration", "fps"];
465
568
  function composeScene(base, ...overlays) {
466
569
  const ir = structuredClone(base);
467
570
  const report = { applied: [], orphans: [], warnings: [] };
571
+ const baseNodeIds = /* @__PURE__ */ new Set();
572
+ const collectBase = (nodes) => {
573
+ for (const node of nodes) {
574
+ baseNodeIds.add(node.id);
575
+ if (node.type === "group") collectBase(node.children);
576
+ }
577
+ };
578
+ collectBase(base.nodes);
468
579
  overlays.forEach((overlay, index) => {
469
580
  const layer = overlay.name ?? `overlay-${index}`;
470
581
  if (overlay.target !== void 0 && overlay.target !== ir.id) {
@@ -472,12 +583,12 @@ function composeScene(base, ...overlays) {
472
583
  `${layer}: authored against scene "${overlay.target}" but composing onto "${ir.id}"`
473
584
  );
474
585
  }
475
- applyOverlay(ir, overlay, layer, report);
586
+ applyOverlay(ir, overlay, layer, report, baseNodeIds);
476
587
  });
477
588
  validateScene(ir);
478
589
  return { ir, report };
479
590
  }
480
- function applyOverlay(ir, overlay, layer, report) {
591
+ function applyOverlay(ir, overlay, layer, report, baseNodeIds) {
481
592
  const nodeById = /* @__PURE__ */ new Map();
482
593
  const collect = (nodes) => {
483
594
  for (const node of nodes) {
@@ -592,7 +703,7 @@ function applyOverlay(ir, overlay, layer, report) {
592
703
  to: ["duration", "ease", "stagger"],
593
704
  tween: ["duration", "ease"],
594
705
  wait: ["duration"],
595
- motionPath: ["points", "duration", "ease"],
706
+ motionPath: ["points", "duration", "ease", "curviness", "autoRotate"],
596
707
  beat: ["at", "gap", "scale", "duration", "order"]
597
708
  };
598
709
  let timingPatched = false;
@@ -630,6 +741,49 @@ function applyOverlay(ir, overlay, layer, report) {
630
741
  nodeById.set(node.id, node);
631
742
  applied(`addNodes.${node.id}`, "add-node");
632
743
  }
744
+ for (const id of overlay.removeNodes ?? []) {
745
+ if (baseNodeIds.has(id)) {
746
+ orphan(
747
+ `removeNodes.${id}`,
748
+ `"${id}" is a base scene node \u2014 the scene owns it; hide it with opacity: 0 instead of removing`
749
+ );
750
+ continue;
751
+ }
752
+ const index = ir.nodes.findIndex((n) => n.id === id);
753
+ if (index < 0) {
754
+ orphan(
755
+ `removeNodes.${id}`,
756
+ `unknown overlay-added node "${id}" \u2014 nothing to remove`
757
+ );
758
+ continue;
759
+ }
760
+ ir.nodes.splice(index, 1);
761
+ nodeById.delete(id);
762
+ applied(`removeNodes.${id}`, "remove-node");
763
+ }
764
+ if (overlay.addTimeline && overlay.addTimeline.length > 0) {
765
+ const collectTargets = (tl, out) => {
766
+ if (tl.kind === "tween" || tl.kind === "motionPath") out.add(tl.target);
767
+ if ("children" in tl) tl.children.forEach((c) => collectTargets(c, out));
768
+ };
769
+ const valid = [];
770
+ overlay.addTimeline.forEach((frag, i) => {
771
+ const targets = /* @__PURE__ */ new Set();
772
+ collectTargets(frag, targets);
773
+ const missing = [...targets].filter((id) => !nodeById.has(id));
774
+ if (missing.length > 0) {
775
+ orphan(`addTimeline[${i}]`, `targets unknown node(s) ${missing.join(", ")} \u2014 known ids: ${knownIds()}`);
776
+ return;
777
+ }
778
+ valid.push(structuredClone(frag));
779
+ applied(`addTimeline[${i}]`, "add-timeline");
780
+ });
781
+ if (valid.length > 0) {
782
+ ir.timeline = ir.timeline ? { kind: "par", children: [ir.timeline, ...valid] } : valid.length === 1 ? valid[0] : { kind: "par", children: valid };
783
+ delete ir.duration;
784
+ ir.duration = compileScene(ir).duration;
785
+ }
786
+ }
633
787
  }
634
788
  function formatComposeReport(report) {
635
789
  const lines = [];
@@ -690,6 +844,15 @@ function resolveAudioPlan(compiled) {
690
844
  });
691
845
  }
692
846
  cues.sort((a, b) => a.t - b.t);
847
+ return {
848
+ duration,
849
+ bgm: resolveBgm(audio.bgm),
850
+ cues,
851
+ duckWindows: mergeDuckWindows(cues, duration),
852
+ warnings
853
+ };
854
+ }
855
+ function mergeDuckWindows(cues, duration) {
693
856
  const duckWindows = [];
694
857
  for (const cue of cues) {
695
858
  const window2 = { t0: cue.t, t1: Math.min(duration, cue.t + cue.duration) };
@@ -697,23 +860,68 @@ function resolveAudioPlan(compiled) {
697
860
  if (last && window2.t0 <= last.t1 + 0.1) last.t1 = Math.max(last.t1, window2.t1);
698
861
  else duckWindows.push(window2);
699
862
  }
700
- let bgm = null;
701
- if (audio.bgm) {
702
- const b = audio.bgm;
703
- const duck = b.duck === false ? null : {
704
- depth: b.duck?.depth ?? 0.5,
705
- attack: b.duck?.attack ?? 0.05,
706
- release: b.duck?.release ?? 0.25
707
- };
708
- bgm = {
709
- source: b.file ? { kind: "file", path: b.file } : { kind: "synth", name: b.synth ?? "ambient-pad" },
710
- gain: b.gain ?? 0.5,
711
- fadeIn: b.fadeIn ?? 0,
712
- fadeOut: b.fadeOut ?? 0,
713
- duck
714
- };
863
+ return duckWindows;
864
+ }
865
+ function resolveBgm(b) {
866
+ if (!b) return null;
867
+ const duck = b.duck === false ? null : {
868
+ depth: b.duck?.depth ?? 0.5,
869
+ attack: b.duck?.attack ?? 0.05,
870
+ release: b.duck?.release ?? 0.25
871
+ };
872
+ return {
873
+ source: b.file ? { kind: "file", path: b.file } : { kind: "synth", name: b.synth ?? "ambient-pad" },
874
+ gain: b.gain ?? 0.5,
875
+ fadeIn: b.fadeIn ?? 0,
876
+ fadeOut: b.fadeOut ?? 0,
877
+ duck
878
+ };
879
+ }
880
+ function resolveCompositionAudioPlan(comp) {
881
+ const audio = comp.ir.audio;
882
+ const duration = comp.duration;
883
+ const warnings = [];
884
+ const cues = [];
885
+ for (const placement of comp.scenes) {
886
+ const plan = resolveAudioPlan(placement.compiled);
887
+ if (!plan) continue;
888
+ if (plan.bgm) {
889
+ warnings.push(`scene "${placement.id}": per-scene bgm ignored \u2014 set bgm at the composition level`);
890
+ }
891
+ for (const w of plan.warnings) warnings.push(`scene "${placement.id}": ${w}`);
892
+ for (const cue of plan.cues) {
893
+ const t = cue.t + placement.start;
894
+ if (t >= duration) continue;
895
+ cues.push({ ...cue, t });
896
+ }
715
897
  }
716
- return { duration, bgm, cues, duckWindows, warnings };
898
+ for (const [index, cue] of (audio?.cues ?? []).entries()) {
899
+ if (typeof cue.at !== "number") {
900
+ warnings.push(`composition cue[${index}]: "at" must be an absolute number (no composition labels) \u2014 dropped`);
901
+ continue;
902
+ }
903
+ const t = Math.max(0, cue.at + (cue.offset ?? 0));
904
+ const cueDuration = cue.sfx ? SFX_DURATION[cue.sfx] : FILE_CUE_DURATION;
905
+ if (t >= duration) {
906
+ warnings.push(`composition cue[${index}] at ${t.toFixed(2)}s past the composition end \u2014 dropped`);
907
+ continue;
908
+ }
909
+ cues.push({
910
+ t,
911
+ gain: cue.gain ?? 1,
912
+ duration: cueDuration,
913
+ source: cue.sfx ? { kind: "sfx", name: cue.sfx, params: cue.params ?? {} } : { kind: "file", path: cue.file }
914
+ });
915
+ }
916
+ if (!audio?.bgm && cues.length === 0) return null;
917
+ cues.sort((a, b) => a.t - b.t);
918
+ return {
919
+ duration,
920
+ bgm: resolveBgm(audio?.bgm),
921
+ cues,
922
+ duckWindows: mergeDuckWindows(cues, duration),
923
+ warnings
924
+ };
717
925
  }
718
926
 
719
927
  // ../core/src/interpolate.ts
@@ -968,22 +1176,22 @@ function synthAmbientPad(duration, seed = 0) {
968
1176
  var ROOT = true ? resolve(dirname(fileURLToPath(import.meta.url)), "..") : resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..");
969
1177
  var VENDORED = join(ROOT, "assets", "sfx");
970
1178
  var CACHE = join(tmpdir(), "reframe-sfx-cache");
971
- function fnv1a(text) {
1179
+ function fnv1a(text2) {
972
1180
  let h = 2166136261;
973
- for (let i = 0; i < text.length; i++) {
974
- h ^= text.charCodeAt(i);
1181
+ for (let i = 0; i < text2.length; i++) {
1182
+ h ^= text2.charCodeAt(i);
975
1183
  h = Math.imul(h, 16777619);
976
1184
  }
977
1185
  return (h >>> 0).toString(16);
978
1186
  }
979
1187
  async function writeCached(key2, make) {
980
- const path = join(CACHE, `${key2}.wav`);
981
- if (existsSync(path)) return path;
1188
+ const path2 = join(CACHE, `${key2}.wav`);
1189
+ if (existsSync(path2)) return path2;
982
1190
  await mkdir(CACHE, { recursive: true });
983
- const temp = `${path}.${process.pid}.${fnv1a(String(performance.now()))}.tmp`;
1191
+ const temp = `${path2}.${process.pid}.${fnv1a(String(performance.now()))}.tmp`;
984
1192
  await writeFile(temp, encodeWavMono16(make()));
985
- await rename(temp, path);
986
- return path;
1193
+ await rename(temp, path2);
1194
+ return path2;
987
1195
  }
988
1196
  async function resolveCueFile(cue, sceneDir) {
989
1197
  if (cue.source.kind === "file") {
@@ -1110,6 +1318,12 @@ async function buildAudioTrack(plan, scenePath, videoIn, outFile) {
1110
1318
  await muxAudio(videoIn, plan, { cueFiles, bgmFile }, outFile);
1111
1319
  }
1112
1320
 
1321
+ // ../render-cli/src/composition.ts
1322
+ import { spawn as spawn3 } from "node:child_process";
1323
+ import { copyFile, mkdtemp as mkdtemp2, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
1324
+ import { tmpdir as tmpdir3 } from "node:os";
1325
+ import { dirname as dirname5, join as join5 } from "node:path";
1326
+
1113
1327
  // ../render-cli/src/encode.ts
1114
1328
  import { spawn as spawn2 } from "node:child_process";
1115
1329
  async function encodeMp4(framesDir, fps, outFile) {
@@ -1353,31 +1567,149 @@ async function captureHtml(htmlPath, opts) {
1353
1567
  (ms) => window.__vclock.advanceTo(ms),
1354
1568
  f / opts.fps * 1e3
1355
1569
  );
1356
- const path = framePath(opts.framesDir, f);
1357
- if (hasStage) await stage.screenshot({ path, animations: "allow" });
1358
- else await page.screenshot({ path, animations: "allow" });
1570
+ const path2 = framePath(opts.framesDir, f);
1571
+ if (hasStage) await stage.screenshot({ path: path2, animations: "allow" });
1572
+ else await page.screenshot({ path: path2, animations: "allow" });
1359
1573
  }
1360
1574
  return { framesDir: opts.framesDir, frameCount, fps: opts.fps };
1361
1575
  });
1362
1576
  }
1363
1577
 
1578
+ // ../render-cli/src/composition.ts
1579
+ function runFfmpeg(args) {
1580
+ return new Promise((resolve5, reject) => {
1581
+ const proc = spawn3("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
1582
+ let stderr = "";
1583
+ proc.stderr.on("data", (d) => stderr += d.toString());
1584
+ proc.on("close", (code) => code === 0 ? resolve5() : reject(new Error(`ffmpeg exited ${code}:
1585
+ ${stderr.slice(-2e3)}`)));
1586
+ proc.on("error", reject);
1587
+ });
1588
+ }
1589
+ var sanitize = (id) => id.replace(/[^a-z0-9_-]/gi, "_");
1590
+ async function renderSceneVideo(scene, sceneDir, fps, out) {
1591
+ const framesDir = await mkdtemp2(join5(tmpdir3(), "reframe-frames-"));
1592
+ try {
1593
+ const result = await captureIr(scene, { framesDir, sceneDir, ...fps !== void 0 && { fps } });
1594
+ await encodeMp4(result.framesDir, result.fps, out);
1595
+ return { fps: result.fps, frameCount: result.frameCount };
1596
+ } finally {
1597
+ await rm2(framesDir, { recursive: true, force: true });
1598
+ }
1599
+ }
1600
+ async function renderStandaloneScene(scene, sceneDir, fps, noAudio, out) {
1601
+ const plan = noAudio ? null : resolveAudioPlan(compileScene(scene));
1602
+ if (plan) {
1603
+ const videoOut = `${out}.video.mp4`;
1604
+ await renderSceneVideo(scene, sceneDir, fps, videoOut);
1605
+ await buildAudioTrack(plan, join5(sceneDir, "scene"), videoOut, out);
1606
+ await rm2(videoOut, { force: true });
1607
+ } else {
1608
+ await renderSceneVideo(scene, sceneDir, fps, out);
1609
+ }
1610
+ }
1611
+ async function concatVideos(files, out) {
1612
+ if (files.length === 1) {
1613
+ await copyFile(files[0], out);
1614
+ return;
1615
+ }
1616
+ const list = `${out}.concat.txt`;
1617
+ await writeFile4(list, files.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n"));
1618
+ await runFfmpeg(["-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", "-movflags", "+faststart", out]);
1619
+ }
1620
+ async function xfade2(a, b, overlap, offset, out) {
1621
+ await runFfmpeg([
1622
+ "-y",
1623
+ "-i",
1624
+ a,
1625
+ "-i",
1626
+ b,
1627
+ "-filter_complex",
1628
+ `[0:v][1:v]xfade=transition=fade:duration=${overlap.toFixed(4)}:offset=${offset.toFixed(4)}[v]`,
1629
+ "-map",
1630
+ "[v]",
1631
+ "-c:v",
1632
+ "libx264",
1633
+ "-preset",
1634
+ "slow",
1635
+ "-crf",
1636
+ "18",
1637
+ "-pix_fmt",
1638
+ "yuv420p",
1639
+ "-movflags",
1640
+ "+faststart",
1641
+ out
1642
+ ]);
1643
+ }
1644
+ async function combineWithTransitions(videos, out, tmp) {
1645
+ let acc = videos[0].file;
1646
+ let accDur = videos[0].placement.duration;
1647
+ for (let i = 1; i < videos.length; i++) {
1648
+ const { overlap, duration } = videos[i].placement;
1649
+ const step = join5(tmp, `step${i}.mp4`);
1650
+ if (overlap <= 0) {
1651
+ await concatVideos([acc, videos[i].file], step);
1652
+ accDur += duration;
1653
+ } else {
1654
+ const offset = Math.max(0, accDur - overlap);
1655
+ await xfade2(acc, videos[i].file, overlap, offset, step);
1656
+ accDur = offset + duration;
1657
+ }
1658
+ acc = step;
1659
+ }
1660
+ await copyFile(acc, out);
1661
+ }
1662
+ async function renderComposition(comp, opts) {
1663
+ const cc = compileComposition(comp);
1664
+ const sceneDir = dirname5(opts.compositionPath);
1665
+ if (opts.onlyScene) {
1666
+ const p = cc.scenes.find((s) => s.id === opts.onlyScene);
1667
+ if (!p) throw new Error(`--scene "${opts.onlyScene}" not in composition; scenes: ${cc.scenes.map((s) => s.id).join(", ")}`);
1668
+ await renderStandaloneScene(p.scene, sceneDir, opts.fps, opts.noAudio, opts.out);
1669
+ return { duration: p.duration, sceneCount: 1 };
1670
+ }
1671
+ const tmp = await mkdtemp2(join5(tmpdir3(), "reframe-comp-"));
1672
+ try {
1673
+ const videos = [];
1674
+ for (const p of cc.scenes) {
1675
+ const file = join5(tmp, `${sanitize(p.id)}.mp4`);
1676
+ const { fps } = await renderSceneVideo(p.scene, sceneDir, opts.fps, file);
1677
+ videos.push({ id: p.id, file, placement: p, fps });
1678
+ }
1679
+ const combined = join5(tmp, "combined.mp4");
1680
+ const allCut = cc.scenes.every((s) => s.overlap === 0);
1681
+ if (allCut) await concatVideos(videos.map((v) => v.file), combined);
1682
+ else await combineWithTransitions(videos, combined, tmp);
1683
+ if (!opts.noAudio) {
1684
+ const plan = resolveCompositionAudioPlan(cc);
1685
+ if (plan) {
1686
+ for (const w of plan.warnings) console.error(`audio: ${w}`);
1687
+ await buildAudioTrack(plan, opts.compositionPath, combined, opts.out);
1688
+ } else {
1689
+ await copyFile(combined, opts.out);
1690
+ }
1691
+ } else {
1692
+ await copyFile(combined, opts.out);
1693
+ }
1694
+ return { duration: cc.duration, sceneCount: cc.scenes.length };
1695
+ } finally {
1696
+ await rm2(tmp, { recursive: true, force: true });
1697
+ }
1698
+ }
1699
+
1364
1700
  // ../render-cli/src/loadScene.ts
1365
1701
  import { build as build2 } from "esbuild";
1366
1702
  import { readFile as readFile3 } from "node:fs/promises";
1367
- import { dirname as dirname5, resolve as resolve3 } from "node:path";
1703
+ import { dirname as dirname6, resolve as resolve3 } from "node:path";
1368
1704
  import { fileURLToPath as fileURLToPath4 } from "node:url";
1369
- var HERE = dirname5(fileURLToPath4(import.meta.url));
1705
+ var HERE = dirname6(fileURLToPath4(import.meta.url));
1370
1706
  var CORE_ENTRY = true ? resolve3(HERE, "index.js") : resolve3(HERE, "..", "..", "core", "src", "index.ts");
1371
- async function loadScene(path) {
1372
- if (path.endsWith(".json")) {
1373
- const ir = JSON.parse(await readFile3(path, "utf8"));
1374
- validateScene(ir);
1375
- return ir;
1376
- }
1707
+ async function loadDefault(path2) {
1708
+ if (path2.endsWith(".json")) return JSON.parse(await readFile3(path2, "utf8"));
1377
1709
  let code;
1378
1710
  try {
1379
1711
  const out = await build2({
1380
- entryPoints: [path],
1712
+ entryPoints: [path2],
1381
1713
  bundle: true,
1382
1714
  format: "esm",
1383
1715
  platform: "neutral",
@@ -1390,15 +1722,25 @@ async function loadScene(path) {
1390
1722
  });
1391
1723
  code = out.outputFiles[0].text;
1392
1724
  } catch (err) {
1393
- throw new Error(
1394
- `failed to bundle ${path}:
1395
- ${err instanceof Error ? err.message : String(err)}`
1396
- );
1725
+ throw new Error(`failed to bundle ${path2}:
1726
+ ${err instanceof Error ? err.message : String(err)}`);
1397
1727
  }
1398
1728
  const mod = await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`);
1399
- if (!mod.default) throw new Error(`${path} must default-export a scene`);
1729
+ if (mod.default === void 0) throw new Error(`${path2} must default-export a scene or composition`);
1400
1730
  return mod.default;
1401
1731
  }
1732
+ function isComposition(def) {
1733
+ return typeof def === "object" && def !== null && Array.isArray(def.scenes);
1734
+ }
1735
+ async function loadModule(path2) {
1736
+ const def = await loadDefault(path2);
1737
+ if (isComposition(def)) {
1738
+ validateComposition(def);
1739
+ return { kind: "composition", ir: def };
1740
+ }
1741
+ validateScene(def);
1742
+ return { kind: "scene", ir: def };
1743
+ }
1402
1744
 
1403
1745
  // ../render-cli/src/cli.ts
1404
1746
  function parseArgs(argv) {
@@ -1426,6 +1768,7 @@ function parseArgs(argv) {
1426
1768
  else if (a === "--frames-dir") args.framesDir = resolve4(rest[++i]);
1427
1769
  else if (a === "--overlay") args.overlays.push(resolve4(rest[++i]));
1428
1770
  else if (a === "--no-audio") args.noAudio = true;
1771
+ else if (a === "--scene") args.scene = rest[++i];
1429
1772
  else {
1430
1773
  console.error(`unknown flag ${a}`);
1431
1774
  process.exit(2);
@@ -1435,11 +1778,28 @@ function parseArgs(argv) {
1435
1778
  }
1436
1779
  async function main() {
1437
1780
  const args = parseArgs(process.argv.slice(2));
1438
- const framesDir = args.framesDir ?? await mkdtemp2(join5(tmpdir3(), "reframe-frames-"));
1781
+ const loaded = args.mode === "ir" ? await loadModule(args.input) : null;
1782
+ if (loaded?.kind === "composition") {
1783
+ if (args.overlays.length > 0) {
1784
+ console.error("note: overlays apply per-scene, not to a composition \u2014 ignored here");
1785
+ }
1786
+ const { duration, sceneCount } = await renderComposition(loaded.ir, {
1787
+ compositionPath: args.input,
1788
+ out: args.out,
1789
+ noAudio: args.noAudio,
1790
+ ...args.fps !== void 0 && { fps: args.fps },
1791
+ ...args.scene !== void 0 && { onlyScene: args.scene }
1792
+ });
1793
+ console.log(
1794
+ args.scene !== void 0 ? `${args.out} (scene "${args.scene}", ${duration.toFixed(2)}s)` : `${args.out} (composition: ${sceneCount} scene${sceneCount > 1 ? "s" : ""}, ${duration.toFixed(2)}s)`
1795
+ );
1796
+ return;
1797
+ }
1798
+ const framesDir = args.framesDir ?? await mkdtemp3(join6(tmpdir4(), "reframe-frames-"));
1439
1799
  let result;
1440
1800
  let audioJob = null;
1441
1801
  if (args.mode === "ir") {
1442
- let ir = await loadScene(args.input);
1802
+ let ir = loaded.ir;
1443
1803
  if (args.overlays.length > 0) {
1444
1804
  const docs = await Promise.all(
1445
1805
  args.overlays.map(async (p) => JSON.parse(await readFile4(p, "utf8")))
@@ -1457,7 +1817,7 @@ async function main() {
1457
1817
  }
1458
1818
  result = await captureIr(ir, {
1459
1819
  framesDir,
1460
- sceneDir: dirname6(args.input),
1820
+ sceneDir: dirname7(args.input),
1461
1821
  ...args.fps !== void 0 && { fps: args.fps },
1462
1822
  ...args.duration !== void 0 && { duration: args.duration }
1463
1823
  });
@@ -1474,10 +1834,10 @@ async function main() {
1474
1834
  await encodeMp4(result.framesDir, result.fps, audioJob ? audioJob.videoOut : args.out);
1475
1835
  if (audioJob) {
1476
1836
  await buildAudioTrack(audioJob.plan, args.input, audioJob.videoOut, args.out);
1477
- await rm2(audioJob.videoOut, { force: true });
1837
+ await rm3(audioJob.videoOut, { force: true });
1478
1838
  }
1479
1839
  if (!args.keepFrames && args.framesDir === void 0) {
1480
- await rm2(framesDir, { recursive: true, force: true });
1840
+ await rm3(framesDir, { recursive: true, force: true });
1481
1841
  }
1482
1842
  console.log(
1483
1843
  `${args.out} (${result.frameCount} frames @ ${result.fps}fps${audioJob ? `, ${audioJob.plan.cues.length} audio cues` : ""})`