reframe-video 0.6.10 → 0.6.12

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
@@ -160,6 +160,7 @@ function compileScene(ir) {
160
160
  initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
161
161
  initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
162
162
  initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
163
+ if (cam.perspective !== void 0) initialValues.set(key("camera", "perspective"), cam.perspective);
163
164
  }
164
165
  const segments = /* @__PURE__ */ new Map();
165
166
  const motionPaths = /* @__PURE__ */ new Map();
@@ -322,6 +323,7 @@ function compileScene(ir) {
322
323
  for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
323
324
  for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
324
325
  const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
326
+ const hasPerspective = !cameraIsNode && (ir.camera?.perspective !== void 0 || segments.has("camera.perspective"));
325
327
  return {
326
328
  ir,
327
329
  duration: ir.duration ?? inferredEnd,
@@ -332,7 +334,8 @@ function compileScene(ir) {
332
334
  nodeOrder,
333
335
  labelTimes,
334
336
  beatTimes,
335
- hasCamera
337
+ hasCamera,
338
+ hasPerspective
336
339
  };
337
340
  }
338
341
  var key;
@@ -542,6 +545,8 @@ function validateScene(ir) {
542
545
  problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
543
546
  } else if (typeof value !== "number") {
544
547
  problems.push(`camera.${key2} must be a number`);
548
+ } else if (key2 === "perspective" && value <= 0) {
549
+ problems.push(`camera.perspective must be > 0 (focal distance in px) \u2014 drop it to disable perspective`);
545
550
  }
546
551
  }
547
552
  }
@@ -622,13 +627,13 @@ var init_validate = __esm({
622
627
  "difference"
623
628
  ]);
624
629
  IMAGE_FITS = /* @__PURE__ */ new Set(["fill", "cover"]);
625
- COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
626
- CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
630
+ COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "z", "rotateX", "rotateY", "anchor", "fixed", ...FX_PROPS];
631
+ CAMERA_PROPS = ["x", "y", "zoom", "rotation", "perspective"];
627
632
  PROPS_BY_TYPE = {
628
633
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
629
634
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
630
635
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
631
- text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
636
+ text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "prefix", "suffix", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
632
637
  image: [...COMMON_PROPS, "src", "width", "height", "fit"],
633
638
  video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume"],
634
639
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
@@ -963,6 +968,13 @@ var init_effects = __esm({
963
968
  }
964
969
  });
965
970
 
971
+ // ../core/src/layout.ts
972
+ var init_layout = __esm({
973
+ "../core/src/layout.ts"() {
974
+ "use strict";
975
+ }
976
+ });
977
+
966
978
  // ../core/src/montage.ts
967
979
  var init_montage = __esm({
968
980
  "../core/src/montage.ts"() {
@@ -1384,6 +1396,7 @@ var init_interpolate = __esm({
1384
1396
  });
1385
1397
 
1386
1398
  // ../core/src/evaluate.ts
1399
+ var DEG;
1387
1400
  var init_evaluate = __esm({
1388
1401
  "../core/src/evaluate.ts"() {
1389
1402
  "use strict";
@@ -1392,6 +1405,7 @@ var init_evaluate = __esm({
1392
1405
  init_gradient();
1393
1406
  init_interpolate();
1394
1407
  init_path();
1408
+ DEG = Math.PI / 180;
1395
1409
  }
1396
1410
  });
1397
1411
 
@@ -1460,6 +1474,7 @@ var init_src = __esm({
1460
1474
  init_camera();
1461
1475
  init_gradient();
1462
1476
  init_effects();
1477
+ init_layout();
1463
1478
  init_montage();
1464
1479
  init_presets();
1465
1480
  init_devicePreset();
@@ -2460,9 +2475,9 @@ __export(batch_exports, {
2460
2475
  import { mkdir as mkdir3, mkdtemp as mkdtemp4, readFile as readFile5, rm as rm4, writeFile as writeFile4 } from "node:fs/promises";
2461
2476
  import { tmpdir as tmpdir5 } from "node:os";
2462
2477
  import { join as join8, dirname as dirname5 } from "node:path";
2463
- function overlayFromFlat(row, name) {
2478
+ function overlayFromFlat(row2, name) {
2464
2479
  const doc = { reframeOverlay: 1, name };
2465
- for (const [key2, raw] of Object.entries(row)) {
2480
+ for (const [key2, raw] of Object.entries(row2)) {
2466
2481
  if (key2.startsWith("_")) continue;
2467
2482
  if (raw === null || raw === void 0 || raw === "") continue;
2468
2483
  const value = raw;
@@ -2523,13 +2538,13 @@ function parseCsv(text2) {
2523
2538
  const headers = split(lines[0]).map((h) => h.trim());
2524
2539
  return lines.slice(1).map((line) => {
2525
2540
  const cells = split(line);
2526
- const row = {};
2541
+ const row2 = {};
2527
2542
  headers.forEach((h, i) => {
2528
2543
  const cell = (cells[i] ?? "").trim();
2529
2544
  const asNumber = Number(cell);
2530
- row[h] = cell !== "" && !Number.isNaN(asNumber) ? asNumber : cell;
2545
+ row2[h] = cell !== "" && !Number.isNaN(asNumber) ? asNumber : cell;
2531
2546
  });
2532
- return row;
2547
+ return row2;
2533
2548
  });
2534
2549
  }
2535
2550
  async function loadRows(path2) {
@@ -2547,11 +2562,11 @@ async function runBatch(scene2, rows, opts) {
2547
2562
  for (; ; ) {
2548
2563
  const index = next++;
2549
2564
  if (index >= rows.length) return;
2550
- const row = rows[index];
2551
- const name = sanitize(String(row._name ?? `row-${index}`));
2565
+ const row2 = rows[index];
2566
+ const name = sanitize(String(row2._name ?? `row-${index}`));
2552
2567
  let result;
2553
2568
  try {
2554
- const rowOverlay = overlayFromFlat(row, name);
2569
+ const rowOverlay = overlayFromFlat(row2, name);
2555
2570
  const { ir, report } = composeScene(scene2, ...opts.baseOverlays, rowOverlay);
2556
2571
  const framesDir = await mkdtemp4(join8(tmpdir5(), `reframe-batch-${index}-`));
2557
2572
  const output = join8(opts.outDir, `${name}.mp4`);
@@ -157,6 +157,7 @@
157
157
  initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
158
158
  initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
159
159
  initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
160
+ if (cam.perspective !== void 0) initialValues.set(key("camera", "perspective"), cam.perspective);
160
161
  }
161
162
  const segments = /* @__PURE__ */ new Map();
162
163
  const motionPaths = /* @__PURE__ */ new Map();
@@ -319,6 +320,7 @@
319
320
  for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
320
321
  for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
321
322
  const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
323
+ const hasPerspective = !cameraIsNode && (ir.camera?.perspective !== void 0 || segments.has("camera.perspective"));
322
324
  return {
323
325
  ir,
324
326
  duration: ir.duration ?? inferredEnd,
@@ -329,18 +331,19 @@
329
331
  nodeOrder,
330
332
  labelTimes,
331
333
  beatTimes,
332
- hasCamera
334
+ hasCamera,
335
+ hasPerspective
333
336
  };
334
337
  }
335
338
 
336
339
  // ../core/src/validate.ts
337
340
  var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY", "blend"];
338
- var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
341
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "z", "rotateX", "rotateY", "anchor", "fixed", ...FX_PROPS];
339
342
  var PROPS_BY_TYPE = {
340
343
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
341
344
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
342
345
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
343
- text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
346
+ text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "prefix", "suffix", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
344
347
  image: [...COMMON_PROPS, "src", "width", "height", "fit"],
345
348
  video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume"],
346
349
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
@@ -570,6 +573,26 @@
570
573
  m[1] * n[4] + m[3] * n[5] + m[5]
571
574
  ];
572
575
  }
576
+ var DEG = Math.PI / 180;
577
+ var z0 = (x) => x === 0 ? 0 : x;
578
+ function projectDepth(m, z, vx, vy, d) {
579
+ if (z === 0) return m;
580
+ const p = d + z > 0 ? d / (d + z) : 1e-6;
581
+ return [
582
+ z0(m[0] * p),
583
+ z0(m[1] * p),
584
+ z0(m[2] * p),
585
+ z0(m[3] * p),
586
+ z0(vx + (m[4] - vx) * p),
587
+ z0(vy + (m[5] - vy) * p)
588
+ ];
589
+ }
590
+ function tiltSkew(m, rotXdeg, rotYdeg, hw, hh, d) {
591
+ const ky = Math.sin(rotYdeg * DEG) * hw / d;
592
+ const kx = Math.sin(rotXdeg * DEG) * hh / d;
593
+ if (ky === 0 && kx === 0) return m;
594
+ return multiply(m, [1, kx, ky, 1, 0, 0]);
595
+ }
573
596
  function localMatrix(x, y, rotationDeg, scale, scaleX = 1, scaleY = 1, skewXDeg = 0, skewYDeg = 0) {
574
597
  const r = rotationDeg * Math.PI / 180;
575
598
  if (scaleX === 1 && scaleY === 1 && skewXDeg === 0 && skewYDeg === 0) {
@@ -693,7 +716,11 @@
693
716
  if (p.blend !== void 0 && p.blend !== "normal") fx.blend = p.blend;
694
717
  return fx;
695
718
  };
696
- const walk = (node, parent, parentOpacity, clips) => {
719
+ const persp = compiled2.hasPerspective;
720
+ const dPersp = persp ? num("camera", "perspective", 0) : 0;
721
+ const vx = persp ? compiled2.ir.size.width / 2 : 0;
722
+ const vy = persp ? compiled2.ir.size.height / 2 : 0;
723
+ const walk = (node, parent, parentOpacity, clips, zAcc, project) => {
697
724
  const id = node.id;
698
725
  const clipSpread = clips.length > 0 ? { clips } : void 0;
699
726
  const fx = effectFx(id, node.props);
@@ -706,7 +733,8 @@
706
733
  ops.push({
707
734
  type: "line",
708
735
  id,
709
- transform: parent,
736
+ // a line carries no z/rotate of its own — it just inherits the subtree's depth
737
+ transform: project ? projectDepth(parent, zAcc, vx, vy, dPersp) : parent,
710
738
  opacity: opacity2,
711
739
  x1,
712
740
  y1,
@@ -721,6 +749,18 @@
721
749
  }
722
750
  const opacity = parentOpacity * num(id, "opacity", node.props.opacity ?? 1);
723
751
  if (opacity <= 0) return;
752
+ let effScaleX = num(id, "scaleX", node.props.scaleX ?? 1);
753
+ let effScaleY = num(id, "scaleY", node.props.scaleY ?? 1);
754
+ let depth = zAcc;
755
+ let rotX = 0;
756
+ let rotY = 0;
757
+ if (project) {
758
+ rotX = num(id, "rotateX", node.props.rotateX ?? 0);
759
+ rotY = num(id, "rotateY", node.props.rotateY ?? 0);
760
+ depth = zAcc + num(id, "z", node.props.z ?? 0);
761
+ if (rotY !== 0) effScaleX *= Math.abs(Math.cos(rotY * DEG));
762
+ if (rotX !== 0) effScaleY *= Math.abs(Math.cos(rotX * DEG));
763
+ }
724
764
  const matrix = multiply(
725
765
  parent,
726
766
  localMatrix(
@@ -728,25 +768,31 @@
728
768
  num(id, "y", node.props.y),
729
769
  num(id, "rotation", node.props.rotation ?? 0),
730
770
  num(id, "scale", node.props.scale ?? 1),
731
- num(id, "scaleX", node.props.scaleX ?? 1),
732
- num(id, "scaleY", node.props.scaleY ?? 1),
771
+ effScaleX,
772
+ effScaleY,
733
773
  num(id, "skewX", node.props.skewX ?? 0),
734
774
  num(id, "skewY", node.props.skewY ?? 0)
735
775
  )
736
776
  );
777
+ const projDraw = (m, hw, hh) => {
778
+ if (!project) return m;
779
+ const tilted = rotX !== 0 || rotY !== 0 ? tiltSkew(m, rotX, rotY, hw, hh, dPersp) : m;
780
+ return projectDepth(tilted, depth, vx, vy, dPersp);
781
+ };
737
782
  switch (node.type) {
738
783
  case "group": {
739
- const childClips = node.props.clip ? [...clips, { transform: matrix, shape: node.props.clip }] : clips;
784
+ const clipTf = projDraw(matrix, 0, 0);
785
+ const childClips = node.props.clip ? [...clips, { transform: clipTf, shape: node.props.clip }] : clips;
740
786
  const hasFx = fx.blur !== void 0 || fx.shadowColor !== void 0 || fx.blend !== void 0;
741
787
  if (hasFx) ops.push({ type: "group-fx-push", id, transform: matrix, opacity, ...fx, ...clipSpread });
742
788
  if (node.props.matte && node.children.length >= 2) {
743
789
  ops.push({ type: "matte-push", id, transform: matrix, opacity, mode: node.props.matte, ...clipSpread });
744
- walk(node.children[0], matrix, opacity, childClips);
790
+ walk(node.children[0], matrix, opacity, childClips, depth, project);
745
791
  ops.push({ type: "matte-sep", id, transform: matrix, opacity });
746
- for (let i = 1; i < node.children.length; i++) walk(node.children[i], matrix, opacity, childClips);
792
+ for (let i = 1; i < node.children.length; i++) walk(node.children[i], matrix, opacity, childClips, depth, project);
747
793
  ops.push({ type: "matte-pop", id, transform: matrix, opacity });
748
794
  } else {
749
- for (const child of node.children) walk(child, matrix, opacity, childClips);
795
+ for (const child of node.children) walk(child, matrix, opacity, childClips, depth, project);
750
796
  }
751
797
  if (hasFx) ops.push({ type: "group-fx-pop", id, transform: matrix, opacity });
752
798
  return;
@@ -764,7 +810,7 @@
764
810
  ops.push({
765
811
  type: node.type,
766
812
  id,
767
- transform: matrix,
813
+ transform: projDraw(matrix, width / 2, height / 2),
768
814
  opacity,
769
815
  width,
770
816
  height,
@@ -785,7 +831,7 @@
785
831
  ops.push({
786
832
  type: "image",
787
833
  id,
788
- transform: matrix,
834
+ transform: projDraw(matrix, width / 2, height / 2),
789
835
  opacity,
790
836
  src: str(id, "src", node.props.src),
791
837
  width,
@@ -811,7 +857,7 @@
811
857
  ops.push({
812
858
  type: "video",
813
859
  id,
814
- transform: matrix,
860
+ transform: projDraw(matrix, width / 2, height / 2),
815
861
  opacity,
816
862
  src: str(id, "src", node.props.src),
817
863
  width,
@@ -837,7 +883,8 @@
837
883
  ops.push({
838
884
  type: "path",
839
885
  id,
840
- transform: ox === 0 && oy === 0 ? matrix : multiply(matrix, [1, 0, 0, 1, -ox, -oy]),
886
+ // origin-shift in local space, then project (no per-op extent cos + VP only)
887
+ transform: projDraw(ox === 0 && oy === 0 ? matrix : multiply(matrix, [1, 0, 0, 1, -ox, -oy]), 0, 0),
841
888
  opacity,
842
889
  d: dStr,
843
890
  progress: Math.max(0, Math.min(1, num(id, "progress", node.props.progress ?? 1))),
@@ -856,12 +903,14 @@
856
903
  0,
857
904
  Math.round(num(id, "contentDecimals", node.props.contentDecimals ?? 0))
858
905
  );
906
+ const body = typeof raw === "number" ? formatNumber(raw, decimals, node.props.contentThousands === true) : raw;
859
907
  ops.push({
860
908
  type: "text",
861
909
  id,
862
- transform: matrix,
910
+ transform: projDraw(matrix, 0, 0),
863
911
  opacity,
864
- content: typeof raw === "number" ? formatNumber(raw, decimals, node.props.contentThousands === true) : raw,
912
+ // static affixes wrap the (possibly counting-up) body; absent body unchanged
913
+ content: (node.props.prefix ?? "") + body + (node.props.suffix ?? ""),
865
914
  fontFamily: str(id, "fontFamily", node.props.fontFamily),
866
915
  fontSize: num(id, "fontSize", node.props.fontSize),
867
916
  fontWeight: num(id, "fontWeight", node.props.fontWeight ?? 400),
@@ -887,7 +936,8 @@
887
936
  ) : IDENTITY;
888
937
  for (const node of compiled2.ir.nodes) {
889
938
  const root = compiled2.hasCamera && node.props.fixed ? IDENTITY : cameraRoot;
890
- walk(node, root, 1, []);
939
+ const project = persp && !(node.props.fixed && compiled2.hasCamera);
940
+ walk(node, root, 1, [], 0, project);
891
941
  }
892
942
  return ops;
893
943
  }
package/dist/cli.js CHANGED
@@ -147,6 +147,7 @@ function compileScene(ir) {
147
147
  initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
148
148
  initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
149
149
  initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
150
+ if (cam.perspective !== void 0) initialValues.set(key("camera", "perspective"), cam.perspective);
150
151
  }
151
152
  const segments = /* @__PURE__ */ new Map();
152
153
  const motionPaths = /* @__PURE__ */ new Map();
@@ -309,6 +310,7 @@ function compileScene(ir) {
309
310
  for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
310
311
  for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
311
312
  const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
313
+ const hasPerspective = !cameraIsNode && (ir.camera?.perspective !== void 0 || segments.has("camera.perspective"));
312
314
  return {
313
315
  ir,
314
316
  duration: ir.duration ?? inferredEnd,
@@ -319,7 +321,8 @@ function compileScene(ir) {
319
321
  nodeOrder,
320
322
  labelTimes,
321
323
  beatTimes,
322
- hasCamera
324
+ hasCamera,
325
+ hasPerspective
323
326
  };
324
327
  }
325
328
 
@@ -339,13 +342,13 @@ var BLEND_MODES = /* @__PURE__ */ new Set([
339
342
  "difference"
340
343
  ]);
341
344
  var IMAGE_FITS = /* @__PURE__ */ new Set(["fill", "cover"]);
342
- var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
343
- var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
345
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "z", "rotateX", "rotateY", "anchor", "fixed", ...FX_PROPS];
346
+ var CAMERA_PROPS = ["x", "y", "zoom", "rotation", "perspective"];
344
347
  var PROPS_BY_TYPE = {
345
348
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
346
349
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
347
350
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
348
- text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
351
+ text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "prefix", "suffix", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
349
352
  image: [...COMMON_PROPS, "src", "width", "height", "fit"],
350
353
  video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume"],
351
354
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
@@ -556,6 +559,8 @@ function validateScene(ir) {
556
559
  problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
557
560
  } else if (typeof value !== "number") {
558
561
  problems.push(`camera.${key2} must be a number`);
562
+ } else if (key2 === "perspective" && value <= 0) {
563
+ problems.push(`camera.perspective must be > 0 (focal distance in px) \u2014 drop it to disable perspective`);
559
564
  }
560
565
  }
561
566
  }
@@ -1083,6 +1088,9 @@ var EASE_TABLE = {
1083
1088
  };
1084
1089
  var EASE_NAMES = Object.keys(EASE_TABLE);
1085
1090
 
1091
+ // ../core/src/evaluate.ts
1092
+ var DEG = Math.PI / 180;
1093
+
1086
1094
  // ../core/src/assets.ts
1087
1095
  function collectSrcs(ir, type) {
1088
1096
  const srcs = /* @__PURE__ */ new Set();
package/dist/index.js CHANGED
@@ -157,6 +157,7 @@ function compileScene(ir) {
157
157
  initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
158
158
  initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
159
159
  initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
160
+ if (cam.perspective !== void 0) initialValues.set(key("camera", "perspective"), cam.perspective);
160
161
  }
161
162
  const segments = /* @__PURE__ */ new Map();
162
163
  const motionPaths = /* @__PURE__ */ new Map();
@@ -319,6 +320,7 @@ function compileScene(ir) {
319
320
  for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
320
321
  for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
321
322
  const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
323
+ const hasPerspective = !cameraIsNode && (ir.camera?.perspective !== void 0 || segments.has("camera.perspective"));
322
324
  return {
323
325
  ir,
324
326
  duration: ir.duration ?? inferredEnd,
@@ -329,7 +331,8 @@ function compileScene(ir) {
329
331
  nodeOrder,
330
332
  labelTimes,
331
333
  beatTimes,
332
- hasCamera
334
+ hasCamera,
335
+ hasPerspective
333
336
  };
334
337
  }
335
338
 
@@ -349,13 +352,13 @@ var BLEND_MODES = /* @__PURE__ */ new Set([
349
352
  "difference"
350
353
  ]);
351
354
  var IMAGE_FITS = /* @__PURE__ */ new Set(["fill", "cover"]);
352
- var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
353
- var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
355
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "z", "rotateX", "rotateY", "anchor", "fixed", ...FX_PROPS];
356
+ var CAMERA_PROPS = ["x", "y", "zoom", "rotation", "perspective"];
354
357
  var PROPS_BY_TYPE = {
355
358
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
356
359
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
357
360
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
358
- text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
361
+ text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "prefix", "suffix", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
359
362
  image: [...COMMON_PROPS, "src", "width", "height", "fit"],
360
363
  video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume"],
361
364
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
@@ -566,6 +569,8 @@ function validateScene(ir) {
566
569
  problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
567
570
  } else if (typeof value !== "number") {
568
571
  problems.push(`camera.${key2} must be a number`);
572
+ } else if (key2 === "perspective" && value <= 0) {
573
+ problems.push(`camera.perspective must be > 0 (focal distance in px) \u2014 drop it to disable perspective`);
569
574
  }
570
575
  }
571
576
  }
@@ -969,7 +974,7 @@ function formatComposeReport(report) {
969
974
 
970
975
  // ../core/src/camera.ts
971
976
  var CAMERA_ID = "camera";
972
- var CAMERA_PROPS2 = ["x", "y", "zoom", "rotation"];
977
+ var CAMERA_PROPS2 = ["x", "y", "zoom", "rotation", "perspective"];
973
978
  function cameraMatrix(cam, size) {
974
979
  const W = size.width;
975
980
  const H = size.height;
@@ -1027,6 +1032,38 @@ function dropShadow(color, blur = 24, x = 0, y = 12) {
1027
1032
  return { shadowColor: color, shadowBlur: blur, shadowX: x, shadowY: y };
1028
1033
  }
1029
1034
 
1035
+ // ../core/src/layout.ts
1036
+ function row(count, opts = {}) {
1037
+ if (count <= 0) return [];
1038
+ const center = opts.center ?? 0;
1039
+ if (count === 1) return [center];
1040
+ if (opts.span !== void 0) {
1041
+ const start2 = center - opts.span / 2;
1042
+ const pitch2 = opts.span / (count - 1);
1043
+ return Array.from({ length: count }, (_, i) => start2 + i * pitch2);
1044
+ }
1045
+ const iw = opts.itemWidth ?? 0;
1046
+ const gap = opts.gap ?? 0;
1047
+ const pitch = iw + gap;
1048
+ const total = count * iw + (count - 1) * gap;
1049
+ const start = center - total / 2 + iw / 2;
1050
+ return Array.from({ length: count }, (_, i) => start + i * pitch);
1051
+ }
1052
+ var column = row;
1053
+ function grid(rows, cols, opts = {}) {
1054
+ const axis = (center, gap, item, span) => ({
1055
+ center,
1056
+ ...gap !== void 0 ? { gap } : {},
1057
+ ...item !== void 0 ? { itemWidth: item } : {},
1058
+ ...span !== void 0 ? { span } : {}
1059
+ });
1060
+ const xs = row(cols, axis(opts.center?.x ?? 0, opts.gapX, opts.cellW, opts.spanX));
1061
+ const ys = row(rows, axis(opts.center?.y ?? 0, opts.gapY, opts.cellH, opts.spanY));
1062
+ const out = [];
1063
+ for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) out.push({ x: xs[c], y: ys[r] });
1064
+ return out;
1065
+ }
1066
+
1030
1067
  // ../core/src/montage.ts
1031
1068
  var VIDEO_EXT = /\.(mp4|mov|webm|m4v|mkv)$/i;
1032
1069
  var isVideoSrc = (src) => VIDEO_EXT.test(src);
@@ -2978,6 +3015,26 @@ function multiply(m, n3) {
2978
3015
  m[1] * n3[4] + m[3] * n3[5] + m[5]
2979
3016
  ];
2980
3017
  }
3018
+ var DEG = Math.PI / 180;
3019
+ var z0 = (x) => x === 0 ? 0 : x;
3020
+ function projectDepth(m, z, vx, vy, d) {
3021
+ if (z === 0) return m;
3022
+ const p = d + z > 0 ? d / (d + z) : 1e-6;
3023
+ return [
3024
+ z0(m[0] * p),
3025
+ z0(m[1] * p),
3026
+ z0(m[2] * p),
3027
+ z0(m[3] * p),
3028
+ z0(vx + (m[4] - vx) * p),
3029
+ z0(vy + (m[5] - vy) * p)
3030
+ ];
3031
+ }
3032
+ function tiltSkew(m, rotXdeg, rotYdeg, hw, hh, d) {
3033
+ const ky = Math.sin(rotYdeg * DEG) * hw / d;
3034
+ const kx = Math.sin(rotXdeg * DEG) * hh / d;
3035
+ if (ky === 0 && kx === 0) return m;
3036
+ return multiply(m, [1, kx, ky, 1, 0, 0]);
3037
+ }
2981
3038
  function localMatrix(x, y, rotationDeg, scale, scaleX = 1, scaleY = 1, skewXDeg = 0, skewYDeg = 0) {
2982
3039
  const r = rotationDeg * Math.PI / 180;
2983
3040
  if (scaleX === 1 && scaleY === 1 && skewXDeg === 0 && skewYDeg === 0) {
@@ -3133,7 +3190,11 @@ function evaluate(compiled, t) {
3133
3190
  if (p.blend !== void 0 && p.blend !== "normal") fx.blend = p.blend;
3134
3191
  return fx;
3135
3192
  };
3136
- const walk = (node, parent, parentOpacity, clips) => {
3193
+ const persp = compiled.hasPerspective;
3194
+ const dPersp = persp ? num("camera", "perspective", 0) : 0;
3195
+ const vx = persp ? compiled.ir.size.width / 2 : 0;
3196
+ const vy = persp ? compiled.ir.size.height / 2 : 0;
3197
+ const walk = (node, parent, parentOpacity, clips, zAcc, project) => {
3137
3198
  const id = node.id;
3138
3199
  const clipSpread = clips.length > 0 ? { clips } : void 0;
3139
3200
  const fx = effectFx(id, node.props);
@@ -3146,7 +3207,8 @@ function evaluate(compiled, t) {
3146
3207
  ops.push({
3147
3208
  type: "line",
3148
3209
  id,
3149
- transform: parent,
3210
+ // a line carries no z/rotate of its own — it just inherits the subtree's depth
3211
+ transform: project ? projectDepth(parent, zAcc, vx, vy, dPersp) : parent,
3150
3212
  opacity: opacity2,
3151
3213
  x1,
3152
3214
  y1,
@@ -3161,6 +3223,18 @@ function evaluate(compiled, t) {
3161
3223
  }
3162
3224
  const opacity = parentOpacity * num(id, "opacity", node.props.opacity ?? 1);
3163
3225
  if (opacity <= 0) return;
3226
+ let effScaleX = num(id, "scaleX", node.props.scaleX ?? 1);
3227
+ let effScaleY = num(id, "scaleY", node.props.scaleY ?? 1);
3228
+ let depth = zAcc;
3229
+ let rotX = 0;
3230
+ let rotY = 0;
3231
+ if (project) {
3232
+ rotX = num(id, "rotateX", node.props.rotateX ?? 0);
3233
+ rotY = num(id, "rotateY", node.props.rotateY ?? 0);
3234
+ depth = zAcc + num(id, "z", node.props.z ?? 0);
3235
+ if (rotY !== 0) effScaleX *= Math.abs(Math.cos(rotY * DEG));
3236
+ if (rotX !== 0) effScaleY *= Math.abs(Math.cos(rotX * DEG));
3237
+ }
3164
3238
  const matrix = multiply(
3165
3239
  parent,
3166
3240
  localMatrix(
@@ -3168,25 +3242,31 @@ function evaluate(compiled, t) {
3168
3242
  num(id, "y", node.props.y),
3169
3243
  num(id, "rotation", node.props.rotation ?? 0),
3170
3244
  num(id, "scale", node.props.scale ?? 1),
3171
- num(id, "scaleX", node.props.scaleX ?? 1),
3172
- num(id, "scaleY", node.props.scaleY ?? 1),
3245
+ effScaleX,
3246
+ effScaleY,
3173
3247
  num(id, "skewX", node.props.skewX ?? 0),
3174
3248
  num(id, "skewY", node.props.skewY ?? 0)
3175
3249
  )
3176
3250
  );
3251
+ const projDraw = (m, hw, hh) => {
3252
+ if (!project) return m;
3253
+ const tilted = rotX !== 0 || rotY !== 0 ? tiltSkew(m, rotX, rotY, hw, hh, dPersp) : m;
3254
+ return projectDepth(tilted, depth, vx, vy, dPersp);
3255
+ };
3177
3256
  switch (node.type) {
3178
3257
  case "group": {
3179
- const childClips = node.props.clip ? [...clips, { transform: matrix, shape: node.props.clip }] : clips;
3258
+ const clipTf = projDraw(matrix, 0, 0);
3259
+ const childClips = node.props.clip ? [...clips, { transform: clipTf, shape: node.props.clip }] : clips;
3180
3260
  const hasFx = fx.blur !== void 0 || fx.shadowColor !== void 0 || fx.blend !== void 0;
3181
3261
  if (hasFx) ops.push({ type: "group-fx-push", id, transform: matrix, opacity, ...fx, ...clipSpread });
3182
3262
  if (node.props.matte && node.children.length >= 2) {
3183
3263
  ops.push({ type: "matte-push", id, transform: matrix, opacity, mode: node.props.matte, ...clipSpread });
3184
- walk(node.children[0], matrix, opacity, childClips);
3264
+ walk(node.children[0], matrix, opacity, childClips, depth, project);
3185
3265
  ops.push({ type: "matte-sep", id, transform: matrix, opacity });
3186
- for (let i = 1; i < node.children.length; i++) walk(node.children[i], matrix, opacity, childClips);
3266
+ for (let i = 1; i < node.children.length; i++) walk(node.children[i], matrix, opacity, childClips, depth, project);
3187
3267
  ops.push({ type: "matte-pop", id, transform: matrix, opacity });
3188
3268
  } else {
3189
- for (const child of node.children) walk(child, matrix, opacity, childClips);
3269
+ for (const child of node.children) walk(child, matrix, opacity, childClips, depth, project);
3190
3270
  }
3191
3271
  if (hasFx) ops.push({ type: "group-fx-pop", id, transform: matrix, opacity });
3192
3272
  return;
@@ -3204,7 +3284,7 @@ function evaluate(compiled, t) {
3204
3284
  ops.push({
3205
3285
  type: node.type,
3206
3286
  id,
3207
- transform: matrix,
3287
+ transform: projDraw(matrix, width / 2, height / 2),
3208
3288
  opacity,
3209
3289
  width,
3210
3290
  height,
@@ -3225,7 +3305,7 @@ function evaluate(compiled, t) {
3225
3305
  ops.push({
3226
3306
  type: "image",
3227
3307
  id,
3228
- transform: matrix,
3308
+ transform: projDraw(matrix, width / 2, height / 2),
3229
3309
  opacity,
3230
3310
  src: str(id, "src", node.props.src),
3231
3311
  width,
@@ -3251,7 +3331,7 @@ function evaluate(compiled, t) {
3251
3331
  ops.push({
3252
3332
  type: "video",
3253
3333
  id,
3254
- transform: matrix,
3334
+ transform: projDraw(matrix, width / 2, height / 2),
3255
3335
  opacity,
3256
3336
  src: str(id, "src", node.props.src),
3257
3337
  width,
@@ -3277,7 +3357,8 @@ function evaluate(compiled, t) {
3277
3357
  ops.push({
3278
3358
  type: "path",
3279
3359
  id,
3280
- transform: ox === 0 && oy === 0 ? matrix : multiply(matrix, [1, 0, 0, 1, -ox, -oy]),
3360
+ // origin-shift in local space, then project (no per-op extent cos + VP only)
3361
+ transform: projDraw(ox === 0 && oy === 0 ? matrix : multiply(matrix, [1, 0, 0, 1, -ox, -oy]), 0, 0),
3281
3362
  opacity,
3282
3363
  d: dStr,
3283
3364
  progress: Math.max(0, Math.min(1, num(id, "progress", node.props.progress ?? 1))),
@@ -3296,12 +3377,14 @@ function evaluate(compiled, t) {
3296
3377
  0,
3297
3378
  Math.round(num(id, "contentDecimals", node.props.contentDecimals ?? 0))
3298
3379
  );
3380
+ const body = typeof raw === "number" ? formatNumber(raw, decimals, node.props.contentThousands === true) : raw;
3299
3381
  ops.push({
3300
3382
  type: "text",
3301
3383
  id,
3302
- transform: matrix,
3384
+ transform: projDraw(matrix, 0, 0),
3303
3385
  opacity,
3304
- content: typeof raw === "number" ? formatNumber(raw, decimals, node.props.contentThousands === true) : raw,
3386
+ // static affixes wrap the (possibly counting-up) body; absent body unchanged
3387
+ content: (node.props.prefix ?? "") + body + (node.props.suffix ?? ""),
3305
3388
  fontFamily: str(id, "fontFamily", node.props.fontFamily),
3306
3389
  fontSize: num(id, "fontSize", node.props.fontSize),
3307
3390
  fontWeight: num(id, "fontWeight", node.props.fontWeight ?? 400),
@@ -3327,7 +3410,8 @@ function evaluate(compiled, t) {
3327
3410
  ) : IDENTITY;
3328
3411
  for (const node of compiled.ir.nodes) {
3329
3412
  const root = compiled.hasCamera && node.props.fixed ? IDENTITY : cameraRoot;
3330
- walk(node, root, 1, []);
3413
+ const project = persp && !(node.props.fixed && compiled.hasCamera);
3414
+ walk(node, root, 1, [], 0, project);
3331
3415
  }
3332
3416
  return ops;
3333
3417
  }
@@ -3436,6 +3520,7 @@ export {
3436
3520
  characterPreset,
3437
3521
  collectImageSrcs,
3438
3522
  collectVideoSrcs,
3523
+ column,
3439
3524
  compileComposition,
3440
3525
  compileScene,
3441
3526
  composeScene,
@@ -3457,6 +3542,7 @@ export {
3457
3542
  figure,
3458
3543
  formatComposeReport,
3459
3544
  glow,
3545
+ grid,
3460
3546
  group,
3461
3547
  humanoid,
3462
3548
  ikReach,
@@ -3486,6 +3572,7 @@ export {
3486
3572
  resolveEase,
3487
3573
  rig,
3488
3574
  rigPose,
3575
+ row,
3489
3576
  sampleBehavior,
3490
3577
  sampleProp,
3491
3578
  scene,
package/dist/labels.js CHANGED
@@ -141,6 +141,7 @@ function compileScene(ir) {
141
141
  initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
142
142
  initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
143
143
  initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
144
+ if (cam.perspective !== void 0) initialValues.set(key("camera", "perspective"), cam.perspective);
144
145
  }
145
146
  const segments = /* @__PURE__ */ new Map();
146
147
  const motionPaths = /* @__PURE__ */ new Map();
@@ -303,6 +304,7 @@ function compileScene(ir) {
303
304
  for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
304
305
  for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
305
306
  const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
307
+ const hasPerspective = !cameraIsNode && (ir.camera?.perspective !== void 0 || segments.has("camera.perspective"));
306
308
  return {
307
309
  ir,
308
310
  duration: ir.duration ?? inferredEnd,
@@ -313,7 +315,8 @@ function compileScene(ir) {
313
315
  nodeOrder,
314
316
  labelTimes,
315
317
  beatTimes,
316
- hasCamera
318
+ hasCamera,
319
+ hasPerspective
317
320
  };
318
321
  }
319
322
 
@@ -333,13 +336,13 @@ var BLEND_MODES = /* @__PURE__ */ new Set([
333
336
  "difference"
334
337
  ]);
335
338
  var IMAGE_FITS = /* @__PURE__ */ new Set(["fill", "cover"]);
336
- var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
337
- var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
339
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "z", "rotateX", "rotateY", "anchor", "fixed", ...FX_PROPS];
340
+ var CAMERA_PROPS = ["x", "y", "zoom", "rotation", "perspective"];
338
341
  var PROPS_BY_TYPE = {
339
342
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
340
343
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
341
344
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
342
- text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
345
+ text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "prefix", "suffix", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
343
346
  image: [...COMMON_PROPS, "src", "width", "height", "fit"],
344
347
  video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume"],
345
348
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
@@ -550,6 +553,8 @@ function validateScene(ir) {
550
553
  problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
551
554
  } else if (typeof value !== "number") {
552
555
  problems.push(`camera.${key2} must be a number`);
556
+ } else if (key2 === "perspective" && value <= 0) {
557
+ problems.push(`camera.perspective must be > 0 (focal distance in px) \u2014 drop it to disable perspective`);
553
558
  }
554
559
  }
555
560
  }
@@ -630,6 +635,9 @@ var EASE_TABLE = {
630
635
  };
631
636
  var EASE_NAMES = Object.keys(EASE_TABLE);
632
637
 
638
+ // ../core/src/evaluate.ts
639
+ var DEG = Math.PI / 180;
640
+
633
641
  // ../render-cli/src/loadScene.ts
634
642
  import { build } from "esbuild";
635
643
  import { readFile } from "node:fs/promises";
package/dist/trace-cli.js CHANGED
@@ -7,12 +7,12 @@ import { pathToFileURL } from "node:url";
7
7
 
8
8
  // ../core/src/validate.ts
9
9
  var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY", "blend"];
10
- var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
10
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "z", "rotateX", "rotateY", "anchor", "fixed", ...FX_PROPS];
11
11
  var PROPS_BY_TYPE = {
12
12
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
13
13
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
14
14
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
15
- text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
15
+ text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "prefix", "suffix", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
16
16
  image: [...COMMON_PROPS, "src", "width", "height", "fit"],
17
17
  video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume"],
18
18
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
@@ -80,6 +80,9 @@ var EASE_TABLE = {
80
80
  };
81
81
  var EASE_NAMES = Object.keys(EASE_TABLE);
82
82
 
83
+ // ../core/src/evaluate.ts
84
+ var DEG = Math.PI / 180;
85
+
83
86
  // ../core/src/motion.ts
84
87
  var EASE_BY_CLASS = {
85
88
  accelerating: "easeInCubic",
@@ -522,7 +525,7 @@ var INPLACE_RATIO = 0.3;
522
525
  var mean2 = (xs) => xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
523
526
  function backgroundLevel(diff) {
524
527
  const flat = [];
525
- for (const row of diff) for (const v of row) flat.push(v);
528
+ for (const row2 of diff) for (const v of row2) flat.push(v);
526
529
  if (flat.length === 0) return 0;
527
530
  flat.sort((a, b) => a - b);
528
531
  return flat[Math.floor(flat.length / 2)];
@@ -16,8 +16,8 @@ import type { CameraIR, Ease, Size, TimelineIR } from "./ir.js";
16
16
  import type { Mat2D } from "./evaluate.js";
17
17
  /** Reserved timeline/behavior target id for the camera. */
18
18
  export declare const CAMERA_ID = "camera";
19
- /** The animatable camera props (look-at point + zoom + rotation). */
20
- export declare const CAMERA_PROPS: readonly ["x", "y", "zoom", "rotation"];
19
+ /** The animatable camera props (look-at point + zoom + rotation + perspective). */
20
+ export declare const CAMERA_PROPS: readonly ["x", "y", "zoom", "rotation", "perspective"];
21
21
  /**
22
22
  * The camera's affine matrix: `T(W/2,H/2) · R(rotation) · S(zoom) · T(-x,-y)`,
23
23
  * i.e. center the focal point, then zoom/rotate about the frame centre. Defaults
@@ -51,5 +51,7 @@ export interface CompiledScene {
51
51
  beatTimes: Map<string, LabelSpan>;
52
52
  /** True iff the scene declares or animates a `camera` (gates the camera matrix). */
53
53
  hasCamera: boolean;
54
+ /** True iff the scene sets/animates `camera.perspective` (gates depth projection). */
55
+ hasPerspective: boolean;
54
56
  }
55
57
  export declare function compileScene(ir: SceneIR): CompiledScene;
@@ -8,6 +8,7 @@ export { pathPoint, pathTangentAngle, type Pt } from "./path.js";
8
8
  export { cameraTo, cameraMatrix, CAMERA_ID, CAMERA_PROPS } from "./camera.js";
9
9
  export { linearGradient, radialGradient, conicGradient, isGradient } from "./gradient.js";
10
10
  export { glow, dropShadow } from "./effects.js";
11
+ export { row, column, grid, type RowOpts, type GridOpts } from "./layout.js";
11
12
  export { photoMontage, videoMontage, type MontageImage, type MontageOpts, type MontageResult, type KenBurns } from "./montage.js";
12
13
  export { motionPreset, PRESET_NAMES, type PresetName, type PresetRig, type PresetOpts } from "./presets.js";
13
14
  export { devicePreset, deviceScreen, deviceScreenCenter, deviceBounds, deviceScreenPoint, DEVICE_PRESET_NAMES, type DevicePresetName, type DevicePresetOpts } from "./devicePreset.js";
@@ -30,6 +30,20 @@ export interface BaseProps {
30
30
  /** Shear angles in degrees (default 0) — a 2.5D lean. No true perspective. */
31
31
  skewX?: number;
32
32
  skewY?: number;
33
+ /**
34
+ * Projected depth + 3D tilt. ONLY take effect when the scene sets
35
+ * `camera.perspective` (the activation switch); otherwise inert (absent ⇒
36
+ * byte-identical). `z` places the node in front of (`-z`) or behind (`+z`) the
37
+ * focal plane — the renderer scales it about the vanishing point (parallax,
38
+ * dolly, depth convergence; exact in 2D affine). `rotateX`/`rotateY` (degrees)
39
+ * tilt the node about its horizontal/vertical axis for card-flips and leaning
40
+ * planes — an affine APPROXIMATION (cos foreshorten + keystone skew), not a
41
+ * pixel-true trapezoid (a single rotated quad under perspective is non-affine,
42
+ * which Canvas 2D can't draw; that needs WebGL). See `camera.perspective`.
43
+ */
44
+ z?: number;
45
+ rotateX?: number;
46
+ rotateY?: number;
33
47
  anchor?: Anchor;
34
48
  /**
35
49
  * Pin a TOP-LEVEL node to the screen so the scene `camera` does not move it —
@@ -125,6 +139,11 @@ export interface TextProps extends BaseProps {
125
139
  contentDecimals?: number;
126
140
  /** Group the integer part with thousands separators (e.g. 35,786). */
127
141
  contentThousands?: boolean;
142
+ /** Static affixes wrapped around the rendered content — so a count-up can read
143
+ * "$2.4M" or "+32%" from ONE node (prefix `"$"`, suffix `"M"`) instead of three
144
+ * hand-positioned ones. Absent ⇒ no change. */
145
+ prefix?: string;
146
+ suffix?: string;
128
147
  fontFamily: string;
129
148
  fontSize: number;
130
149
  fontWeight?: number;
@@ -419,6 +438,15 @@ export interface CameraIR {
419
438
  y?: number;
420
439
  zoom?: number;
421
440
  rotation?: number;
441
+ /**
442
+ * Focal distance in px — the perspective activation switch. Absent ⇒ no
443
+ * projection (nodes' `z`/`rotateX`/`rotateY` are inert, scene byte-identical).
444
+ * When set, nodes project about the vanishing point (the camera look-at, or
445
+ * screen centre): depth factor `p = perspective / (perspective + z)`. Smaller =
446
+ * stronger perspective; larger = flatter. Keyframable (animate it for a dolly /
447
+ * focal pull). A node BEHIND the camera (`perspective + z <= 0`) is culled.
448
+ */
449
+ perspective?: number;
422
450
  }
423
451
  export interface SceneIR {
424
452
  version: 1;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Layout helpers — pure coordinate math for the common "evenly space N things"
3
+ * jobs (a row of cards, a grid of tiles) so authors don't hand-roll a `cx(i)`
4
+ * every time. They return coordinates you spread into node `x`/`y`; no nodes,
5
+ * no renderer involvement — authoring sugar only. Deterministic.
6
+ *
7
+ * const xs = row(3, { center: 960, gap: 60, itemWidth: 440 });
8
+ * xs.map((x, i) => rect({ id: `card-${i}`, x, y: 540, ... }));
9
+ */
10
+ export interface RowOpts {
11
+ /** Centre of the row/column (default 0). */
12
+ center?: number;
13
+ /** Gap between adjacent items, paired with `itemWidth` (packed layout). */
14
+ gap?: number;
15
+ /** Item extent along the axis, paired with `gap` (packed layout). */
16
+ itemWidth?: number;
17
+ /** Alternative to gap+itemWidth: spread the item CENTRES evenly across this span. */
18
+ span?: number;
19
+ }
20
+ /** N evenly-spaced positions along one axis, centred on `center`. Give either
21
+ * `span` (spread centres across it) or `gap`+`itemWidth` (pack fixed-width items). */
22
+ export declare function row(count: number, opts?: RowOpts): number[];
23
+ /** N evenly-spaced positions along the vertical axis — `row` for the y axis. */
24
+ export declare const column: typeof row;
25
+ export interface GridOpts {
26
+ center?: {
27
+ x: number;
28
+ y: number;
29
+ };
30
+ gapX?: number;
31
+ gapY?: number;
32
+ cellW?: number;
33
+ cellH?: number;
34
+ /** Alternatives to gap+cell: spread cell centres across these spans. */
35
+ spanX?: number;
36
+ spanY?: number;
37
+ }
38
+ /** `rows × cols` cell centres in row-major order, centred on `center`. */
39
+ export declare function grid(rows: number, cols: number, opts?: GridOpts): {
40
+ x: number;
41
+ y: number;
42
+ }[];
@@ -4,6 +4,10 @@ You write a motion-graphics scene as **declarative data** using the reframe
4
4
  TypeScript eDSL. Your output is a single `.ts` file that default-exports a
5
5
  `scene({...})` call. Everything imports from `@reframe/core`.
6
6
 
7
+ > `See examples/scenes/…` pointers below refer to the GitHub repo
8
+ > (github.com/kiyeonjeon21/reframe), not the installed npm package — this guide is
9
+ > self-contained; you don't need them to write a scene.
10
+
7
11
  ```ts
8
12
  import { scene, group, rect, ellipse, line, text,
9
13
  seq, par, stagger, to, tween, wait,
@@ -30,10 +34,13 @@ Factories return plain data. Every node needs a unique `id`.
30
34
  - `ellipse({ id, x, y, width, height, fill?, stroke?, strokeWidth?, ... })`
31
35
  - `line({ id, x1, y1, x2, y2, stroke, strokeWidth?, opacity?, progress? })` —
32
36
  `progress` 0..1 draws the line on (1 = full line).
33
- - `text({ id, x, y, content, contentDecimals?, fontFamily, fontSize, fontWeight?, fill?, letterSpacing?, ... })` —
37
+ - `text({ id, x, y, content, contentDecimals?, contentThousands?, prefix?, suffix?, fontFamily, fontSize, fontWeight?, fill?, letterSpacing?, ... })` —
34
38
  `content` may be a number; numeric content interpolates (count-up) and renders
35
39
  via `toFixed(contentDecimals ?? 0)`. For a "8.2"-style label, set
36
- `contentDecimals: 1`.
40
+ `contentDecimals: 1`; `contentThousands: true` groups the integer (35,786).
41
+ **`prefix`/`suffix`** wrap the value so a count-up reads `$2.4M` or `+32%` from
42
+ ONE node (`{ content: 2.4, contentDecimals: 1, prefix: "$", suffix: "M" }`) —
43
+ don't hand-position separate `$`/`%` nodes.
37
44
  - `path({ id, d, x, y, fill?, stroke?, strokeWidth?, progress?, originX?, originY?, opacity?, rotation?, scale?, anchor? })` —
38
45
  a true vector shape from an SVG path `d` string (crisp at any zoom; recolour by
39
46
  animating `fill`/`stroke`). `progress` 0..1 draws the stroke OUTLINE on (animate
@@ -63,6 +70,23 @@ Factories return plain data. Every node needs a unique `id`.
63
70
  Example: a bar that grows upward = `anchor: "bottom-left"` + animate `height`.
64
71
  Font: use `fontFamily: "Inter"` (weights 400/700/800 are available).
65
72
 
73
+ ### Layout helpers (evenly spacing things)
74
+
75
+ Positions are absolute pixels. For a row of cards or a grid of tiles, use the
76
+ layout helpers instead of hand-rolling the column math — they return coordinates
77
+ you spread into `x`/`y`:
78
+
79
+ ```ts
80
+ import { row, grid } from "@reframe/core";
81
+ // 3 cards, 440px wide, 60px apart, centred on the frame:
82
+ row(3, { center: 960, gap: 60, itemWidth: 440 }).map((x, i) =>
83
+ rect({ id: `card-${i}`, x, y: 540, width: 440, height: 300, anchor: "center", fill: "#1A1F2E" }));
84
+ // or spread centres across a span: row(3, { center: 960, span: 900 })
85
+ // grid(rows, cols, { center: {x,y}, gapX, gapY, cellW, cellH }) → { x, y }[] (row-major)
86
+ ```
87
+
88
+ `column` is `row` for the y axis.
89
+
66
90
  ## States: declare looks, not motion
67
91
 
68
92
  Base props on nodes describe the **finished design**. A state is a sparse
@@ -98,7 +122,8 @@ them with normal TS (`Object.fromEntries`, `.map`) for data-driven scenes.
98
122
  later `tween` can chain from there. Use it for swoops/arcs/orbits — straight
99
123
  `tween`s on x and y can't curve. `closed: true` loops the waypoints (orbit).
100
124
  `curviness` shapes the path: `1` smooth (default), `0` sharp corners, `>1` loopier.
101
- - `wait(seconds)` — hold.
125
+ - `wait(seconds, label?)` — hold; the optional `label` names the hold so audio
126
+ cues and overlay retiming can address it.
102
127
 
103
128
  Eases: `linear`, `easeIn/Out/InOutQuad`, `easeIn/Out/InOutCubic`,
104
129
  `easeIn/Out/InOutQuart`, `easeIn/Out/InOutExpo`, or `{ cubicBezier: [x1,y1,x2,y2] }`.
@@ -152,6 +177,44 @@ scene({
152
177
 
153
178
  See `examples/scenes/camera-demo.ts`.
154
179
 
180
+ ## Depth & perspective (projected 2.5D)
181
+
182
+ Add `camera.perspective` (a focal distance in px) to switch on depth. Then any node's
183
+ `z` (depth) and `rotateX`/`rotateY` (3D tilt) take effect: nodes project about the frame
184
+ centre by `p = perspective / (perspective + z)` — further back = smaller and pulled toward
185
+ the optical centre. It's a pure step in `evaluate()` projected onto the normal 2D matrix, so
186
+ renders stay deterministic and the Canvas renderer is unchanged.
187
+
188
+ ```ts
189
+ scene({
190
+ camera: { x: W/2, y: H/2, perspective: 900 }, // focal distance — the switch (absent = no depth)
191
+ nodes: [
192
+ rect({ id: "near", x: 700, y: 540, width: 220, height: 300, anchor: "center", fill: "#6EA8FF", z: 0 }),
193
+ rect({ id: "far", x: 960, y: 540, width: 220, height: 300, anchor: "center", fill: "#8C7BFF", z: 500 }), // smaller, nearer centre
194
+ rect({ id: "hero", x: 960, y: 800, width: 300, height: 200, anchor: "center", fill: "#FF5C7A", rotateY: 0 }),
195
+ ],
196
+ timeline: seq(
197
+ cameraTo({ x: W/2 + 200 }, { duration: 2, ease: "easeInOutCubic" }), // PAN → parallax (near slides more than far)
198
+ tween("hero", { rotateY: 360 }, { duration: 1.4, ease: "easeInOutCubic" }), // CARD FLIP (rotateY)
199
+ cameraTo({ perspective: 2400 }, { duration: 1.6 }), // DOLLY (animate the focal length)
200
+ ),
201
+ })
202
+ ```
203
+
204
+ - **Parallax** falls out for free — pan the camera and near (`z` small) layers shift more
205
+ than far ones. **Dolly** = keyframe `camera.perspective`. **Perspective text** = give each
206
+ `splitText` glyph an increasing `z` so the word recedes.
207
+ - A node needs a base value to tween (`rotateY: 0` on the card before tweening it to 360).
208
+ - A tilted **group** foreshortens its whole subtree (cos folds into children). Clips project
209
+ by the group's depth. A `fixed` HUD ignores depth (perspective is part of the camera).
210
+ - **Limits (honest):** `rotateX`/`rotateY` are an affine approximation (cos-foreshorten +
211
+ keystone skew) — a single rotated quad is really a trapezoid Canvas 2D can't draw, so it
212
+ reads as a flip/tilt, not a pixel-true 3D face (that needs WebGL). Depth positioning
213
+ (parallax, convergence, dolly) IS exact. `z` does NOT reorder drawing — paint stays array
214
+ order, so order your nodes back-to-front yourself. No GPU 3D, no z-buffer.
215
+
216
+ See `examples/scenes/perspective-cards.ts`.
217
+
155
218
  ## Gradients (fill / stroke)
156
219
 
157
220
  `fill` and `stroke` on **rect / ellipse / path** accept a gradient as well as a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reframe-video",
3
- "version": "0.6.10",
3
+ "version": "0.6.12",
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",