reframe-video 0.6.10 → 0.6.11

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,8 +627,8 @@ 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"],
@@ -1384,6 +1389,7 @@ var init_interpolate = __esm({
1384
1389
  });
1385
1390
 
1386
1391
  // ../core/src/evaluate.ts
1392
+ var DEG;
1387
1393
  var init_evaluate = __esm({
1388
1394
  "../core/src/evaluate.ts"() {
1389
1395
  "use strict";
@@ -1392,6 +1398,7 @@ var init_evaluate = __esm({
1392
1398
  init_gradient();
1393
1399
  init_interpolate();
1394
1400
  init_path();
1401
+ DEG = Math.PI / 180;
1395
1402
  }
1396
1403
  });
1397
1404
 
@@ -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,13 +331,14 @@
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"],
@@ -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))),
@@ -859,7 +906,7 @@
859
906
  ops.push({
860
907
  type: "text",
861
908
  id,
862
- transform: matrix,
909
+ transform: projDraw(matrix, 0, 0),
863
910
  opacity,
864
911
  content: typeof raw === "number" ? formatNumber(raw, decimals, node.props.contentThousands === true) : raw,
865
912
  fontFamily: str(id, "fontFamily", node.props.fontFamily),
@@ -887,7 +934,8 @@
887
934
  ) : IDENTITY;
888
935
  for (const node of compiled2.ir.nodes) {
889
936
  const root = compiled2.hasCamera && node.props.fixed ? IDENTITY : cameraRoot;
890
- walk(node, root, 1, []);
937
+ const project = persp && !(node.props.fixed && compiled2.hasCamera);
938
+ walk(node, root, 1, [], 0, project);
891
939
  }
892
940
  return ops;
893
941
  }
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,8 +342,8 @@ 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"],
@@ -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,8 +352,8 @@ 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"],
@@ -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;
@@ -2978,6 +2983,26 @@ function multiply(m, n3) {
2978
2983
  m[1] * n3[4] + m[3] * n3[5] + m[5]
2979
2984
  ];
2980
2985
  }
2986
+ var DEG = Math.PI / 180;
2987
+ var z0 = (x) => x === 0 ? 0 : x;
2988
+ function projectDepth(m, z, vx, vy, d) {
2989
+ if (z === 0) return m;
2990
+ const p = d + z > 0 ? d / (d + z) : 1e-6;
2991
+ return [
2992
+ z0(m[0] * p),
2993
+ z0(m[1] * p),
2994
+ z0(m[2] * p),
2995
+ z0(m[3] * p),
2996
+ z0(vx + (m[4] - vx) * p),
2997
+ z0(vy + (m[5] - vy) * p)
2998
+ ];
2999
+ }
3000
+ function tiltSkew(m, rotXdeg, rotYdeg, hw, hh, d) {
3001
+ const ky = Math.sin(rotYdeg * DEG) * hw / d;
3002
+ const kx = Math.sin(rotXdeg * DEG) * hh / d;
3003
+ if (ky === 0 && kx === 0) return m;
3004
+ return multiply(m, [1, kx, ky, 1, 0, 0]);
3005
+ }
2981
3006
  function localMatrix(x, y, rotationDeg, scale, scaleX = 1, scaleY = 1, skewXDeg = 0, skewYDeg = 0) {
2982
3007
  const r = rotationDeg * Math.PI / 180;
2983
3008
  if (scaleX === 1 && scaleY === 1 && skewXDeg === 0 && skewYDeg === 0) {
@@ -3133,7 +3158,11 @@ function evaluate(compiled, t) {
3133
3158
  if (p.blend !== void 0 && p.blend !== "normal") fx.blend = p.blend;
3134
3159
  return fx;
3135
3160
  };
3136
- const walk = (node, parent, parentOpacity, clips) => {
3161
+ const persp = compiled.hasPerspective;
3162
+ const dPersp = persp ? num("camera", "perspective", 0) : 0;
3163
+ const vx = persp ? compiled.ir.size.width / 2 : 0;
3164
+ const vy = persp ? compiled.ir.size.height / 2 : 0;
3165
+ const walk = (node, parent, parentOpacity, clips, zAcc, project) => {
3137
3166
  const id = node.id;
3138
3167
  const clipSpread = clips.length > 0 ? { clips } : void 0;
3139
3168
  const fx = effectFx(id, node.props);
@@ -3146,7 +3175,8 @@ function evaluate(compiled, t) {
3146
3175
  ops.push({
3147
3176
  type: "line",
3148
3177
  id,
3149
- transform: parent,
3178
+ // a line carries no z/rotate of its own — it just inherits the subtree's depth
3179
+ transform: project ? projectDepth(parent, zAcc, vx, vy, dPersp) : parent,
3150
3180
  opacity: opacity2,
3151
3181
  x1,
3152
3182
  y1,
@@ -3161,6 +3191,18 @@ function evaluate(compiled, t) {
3161
3191
  }
3162
3192
  const opacity = parentOpacity * num(id, "opacity", node.props.opacity ?? 1);
3163
3193
  if (opacity <= 0) return;
3194
+ let effScaleX = num(id, "scaleX", node.props.scaleX ?? 1);
3195
+ let effScaleY = num(id, "scaleY", node.props.scaleY ?? 1);
3196
+ let depth = zAcc;
3197
+ let rotX = 0;
3198
+ let rotY = 0;
3199
+ if (project) {
3200
+ rotX = num(id, "rotateX", node.props.rotateX ?? 0);
3201
+ rotY = num(id, "rotateY", node.props.rotateY ?? 0);
3202
+ depth = zAcc + num(id, "z", node.props.z ?? 0);
3203
+ if (rotY !== 0) effScaleX *= Math.abs(Math.cos(rotY * DEG));
3204
+ if (rotX !== 0) effScaleY *= Math.abs(Math.cos(rotX * DEG));
3205
+ }
3164
3206
  const matrix = multiply(
3165
3207
  parent,
3166
3208
  localMatrix(
@@ -3168,25 +3210,31 @@ function evaluate(compiled, t) {
3168
3210
  num(id, "y", node.props.y),
3169
3211
  num(id, "rotation", node.props.rotation ?? 0),
3170
3212
  num(id, "scale", node.props.scale ?? 1),
3171
- num(id, "scaleX", node.props.scaleX ?? 1),
3172
- num(id, "scaleY", node.props.scaleY ?? 1),
3213
+ effScaleX,
3214
+ effScaleY,
3173
3215
  num(id, "skewX", node.props.skewX ?? 0),
3174
3216
  num(id, "skewY", node.props.skewY ?? 0)
3175
3217
  )
3176
3218
  );
3219
+ const projDraw = (m, hw, hh) => {
3220
+ if (!project) return m;
3221
+ const tilted = rotX !== 0 || rotY !== 0 ? tiltSkew(m, rotX, rotY, hw, hh, dPersp) : m;
3222
+ return projectDepth(tilted, depth, vx, vy, dPersp);
3223
+ };
3177
3224
  switch (node.type) {
3178
3225
  case "group": {
3179
- const childClips = node.props.clip ? [...clips, { transform: matrix, shape: node.props.clip }] : clips;
3226
+ const clipTf = projDraw(matrix, 0, 0);
3227
+ const childClips = node.props.clip ? [...clips, { transform: clipTf, shape: node.props.clip }] : clips;
3180
3228
  const hasFx = fx.blur !== void 0 || fx.shadowColor !== void 0 || fx.blend !== void 0;
3181
3229
  if (hasFx) ops.push({ type: "group-fx-push", id, transform: matrix, opacity, ...fx, ...clipSpread });
3182
3230
  if (node.props.matte && node.children.length >= 2) {
3183
3231
  ops.push({ type: "matte-push", id, transform: matrix, opacity, mode: node.props.matte, ...clipSpread });
3184
- walk(node.children[0], matrix, opacity, childClips);
3232
+ walk(node.children[0], matrix, opacity, childClips, depth, project);
3185
3233
  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);
3234
+ for (let i = 1; i < node.children.length; i++) walk(node.children[i], matrix, opacity, childClips, depth, project);
3187
3235
  ops.push({ type: "matte-pop", id, transform: matrix, opacity });
3188
3236
  } else {
3189
- for (const child of node.children) walk(child, matrix, opacity, childClips);
3237
+ for (const child of node.children) walk(child, matrix, opacity, childClips, depth, project);
3190
3238
  }
3191
3239
  if (hasFx) ops.push({ type: "group-fx-pop", id, transform: matrix, opacity });
3192
3240
  return;
@@ -3204,7 +3252,7 @@ function evaluate(compiled, t) {
3204
3252
  ops.push({
3205
3253
  type: node.type,
3206
3254
  id,
3207
- transform: matrix,
3255
+ transform: projDraw(matrix, width / 2, height / 2),
3208
3256
  opacity,
3209
3257
  width,
3210
3258
  height,
@@ -3225,7 +3273,7 @@ function evaluate(compiled, t) {
3225
3273
  ops.push({
3226
3274
  type: "image",
3227
3275
  id,
3228
- transform: matrix,
3276
+ transform: projDraw(matrix, width / 2, height / 2),
3229
3277
  opacity,
3230
3278
  src: str(id, "src", node.props.src),
3231
3279
  width,
@@ -3251,7 +3299,7 @@ function evaluate(compiled, t) {
3251
3299
  ops.push({
3252
3300
  type: "video",
3253
3301
  id,
3254
- transform: matrix,
3302
+ transform: projDraw(matrix, width / 2, height / 2),
3255
3303
  opacity,
3256
3304
  src: str(id, "src", node.props.src),
3257
3305
  width,
@@ -3277,7 +3325,8 @@ function evaluate(compiled, t) {
3277
3325
  ops.push({
3278
3326
  type: "path",
3279
3327
  id,
3280
- transform: ox === 0 && oy === 0 ? matrix : multiply(matrix, [1, 0, 0, 1, -ox, -oy]),
3328
+ // origin-shift in local space, then project (no per-op extent cos + VP only)
3329
+ transform: projDraw(ox === 0 && oy === 0 ? matrix : multiply(matrix, [1, 0, 0, 1, -ox, -oy]), 0, 0),
3281
3330
  opacity,
3282
3331
  d: dStr,
3283
3332
  progress: Math.max(0, Math.min(1, num(id, "progress", node.props.progress ?? 1))),
@@ -3299,7 +3348,7 @@ function evaluate(compiled, t) {
3299
3348
  ops.push({
3300
3349
  type: "text",
3301
3350
  id,
3302
- transform: matrix,
3351
+ transform: projDraw(matrix, 0, 0),
3303
3352
  opacity,
3304
3353
  content: typeof raw === "number" ? formatNumber(raw, decimals, node.props.contentThousands === true) : raw,
3305
3354
  fontFamily: str(id, "fontFamily", node.props.fontFamily),
@@ -3327,7 +3376,8 @@ function evaluate(compiled, t) {
3327
3376
  ) : IDENTITY;
3328
3377
  for (const node of compiled.ir.nodes) {
3329
3378
  const root = compiled.hasCamera && node.props.fixed ? IDENTITY : cameraRoot;
3330
- walk(node, root, 1, []);
3379
+ const project = persp && !(node.props.fixed && compiled.hasCamera);
3380
+ walk(node, root, 1, [], 0, project);
3331
3381
  }
3332
3382
  return ops;
3333
3383
  }
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,8 +336,8 @@ 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"],
@@ -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,7 +7,7 @@ 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"],
@@ -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",
@@ -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;
@@ -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 —
@@ -419,6 +433,15 @@ export interface CameraIR {
419
433
  y?: number;
420
434
  zoom?: number;
421
435
  rotation?: number;
436
+ /**
437
+ * Focal distance in px — the perspective activation switch. Absent ⇒ no
438
+ * projection (nodes' `z`/`rotateX`/`rotateY` are inert, scene byte-identical).
439
+ * When set, nodes project about the vanishing point (the camera look-at, or
440
+ * screen centre): depth factor `p = perspective / (perspective + z)`. Smaller =
441
+ * stronger perspective; larger = flatter. Keyframable (animate it for a dolly /
442
+ * focal pull). A node BEHIND the camera (`perspective + z <= 0`) is culled.
443
+ */
444
+ perspective?: number;
422
445
  }
423
446
  export interface SceneIR {
424
447
  version: 1;
@@ -152,6 +152,44 @@ scene({
152
152
 
153
153
  See `examples/scenes/camera-demo.ts`.
154
154
 
155
+ ## Depth & perspective (projected 2.5D)
156
+
157
+ Add `camera.perspective` (a focal distance in px) to switch on depth. Then any node's
158
+ `z` (depth) and `rotateX`/`rotateY` (3D tilt) take effect: nodes project about the frame
159
+ centre by `p = perspective / (perspective + z)` — further back = smaller and pulled toward
160
+ the optical centre. It's a pure step in `evaluate()` projected onto the normal 2D matrix, so
161
+ renders stay deterministic and the Canvas renderer is unchanged.
162
+
163
+ ```ts
164
+ scene({
165
+ camera: { x: W/2, y: H/2, perspective: 900 }, // focal distance — the switch (absent = no depth)
166
+ nodes: [
167
+ rect({ id: "near", x: 700, y: 540, width: 220, height: 300, anchor: "center", fill: "#6EA8FF", z: 0 }),
168
+ rect({ id: "far", x: 960, y: 540, width: 220, height: 300, anchor: "center", fill: "#8C7BFF", z: 500 }), // smaller, nearer centre
169
+ rect({ id: "hero", x: 960, y: 800, width: 300, height: 200, anchor: "center", fill: "#FF5C7A", rotateY: 0 }),
170
+ ],
171
+ timeline: seq(
172
+ cameraTo({ x: W/2 + 200 }, { duration: 2, ease: "easeInOutCubic" }), // PAN → parallax (near slides more than far)
173
+ tween("hero", { rotateY: 360 }, { duration: 1.4, ease: "easeInOutCubic" }), // CARD FLIP (rotateY)
174
+ cameraTo({ perspective: 2400 }, { duration: 1.6 }), // DOLLY (animate the focal length)
175
+ ),
176
+ })
177
+ ```
178
+
179
+ - **Parallax** falls out for free — pan the camera and near (`z` small) layers shift more
180
+ than far ones. **Dolly** = keyframe `camera.perspective`. **Perspective text** = give each
181
+ `splitText` glyph an increasing `z` so the word recedes.
182
+ - A node needs a base value to tween (`rotateY: 0` on the card before tweening it to 360).
183
+ - A tilted **group** foreshortens its whole subtree (cos folds into children). Clips project
184
+ by the group's depth. A `fixed` HUD ignores depth (perspective is part of the camera).
185
+ - **Limits (honest):** `rotateX`/`rotateY` are an affine approximation (cos-foreshorten +
186
+ keystone skew) — a single rotated quad is really a trapezoid Canvas 2D can't draw, so it
187
+ reads as a flip/tilt, not a pixel-true 3D face (that needs WebGL). Depth positioning
188
+ (parallax, convergence, dolly) IS exact. `z` does NOT reorder drawing — paint stays array
189
+ order, so order your nodes back-to-front yourself. No GPU 3D, no z-buffer.
190
+
191
+ See `examples/scenes/perspective-cards.ts`.
192
+
155
193
  ## Gradients (fill / stroke)
156
194
 
157
195
  `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.11",
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",