reframe-video 0.6.9 → 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,24 +768,33 @@
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;
786
+ const hasFx = fx.blur !== void 0 || fx.shadowColor !== void 0 || fx.blend !== void 0;
787
+ if (hasFx) ops.push({ type: "group-fx-push", id, transform: matrix, opacity, ...fx, ...clipSpread });
740
788
  if (node.props.matte && node.children.length >= 2) {
741
789
  ops.push({ type: "matte-push", id, transform: matrix, opacity, mode: node.props.matte, ...clipSpread });
742
- walk(node.children[0], matrix, opacity, childClips);
790
+ walk(node.children[0], matrix, opacity, childClips, depth, project);
743
791
  ops.push({ type: "matte-sep", id, transform: matrix, opacity });
744
- 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);
745
793
  ops.push({ type: "matte-pop", id, transform: matrix, opacity });
746
- return;
794
+ } else {
795
+ for (const child of node.children) walk(child, matrix, opacity, childClips, depth, project);
747
796
  }
748
- for (const child of node.children) walk(child, matrix, opacity, childClips);
797
+ if (hasFx) ops.push({ type: "group-fx-pop", id, transform: matrix, opacity });
749
798
  return;
750
799
  }
751
800
  case "rect":
@@ -761,7 +810,7 @@
761
810
  ops.push({
762
811
  type: node.type,
763
812
  id,
764
- transform: matrix,
813
+ transform: projDraw(matrix, width / 2, height / 2),
765
814
  opacity,
766
815
  width,
767
816
  height,
@@ -782,7 +831,7 @@
782
831
  ops.push({
783
832
  type: "image",
784
833
  id,
785
- transform: matrix,
834
+ transform: projDraw(matrix, width / 2, height / 2),
786
835
  opacity,
787
836
  src: str(id, "src", node.props.src),
788
837
  width,
@@ -808,7 +857,7 @@
808
857
  ops.push({
809
858
  type: "video",
810
859
  id,
811
- transform: matrix,
860
+ transform: projDraw(matrix, width / 2, height / 2),
812
861
  opacity,
813
862
  src: str(id, "src", node.props.src),
814
863
  width,
@@ -834,7 +883,8 @@
834
883
  ops.push({
835
884
  type: "path",
836
885
  id,
837
- 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),
838
888
  opacity,
839
889
  d: dStr,
840
890
  progress: Math.max(0, Math.min(1, num(id, "progress", node.props.progress ?? 1))),
@@ -856,7 +906,7 @@
856
906
  ops.push({
857
907
  type: "text",
858
908
  id,
859
- transform: matrix,
909
+ transform: projDraw(matrix, 0, 0),
860
910
  opacity,
861
911
  content: typeof raw === "number" ? formatNumber(raw, decimals, node.props.contentThousands === true) : raw,
862
912
  fontFamily: str(id, "fontFamily", node.props.fontFamily),
@@ -884,7 +934,8 @@
884
934
  ) : IDENTITY;
885
935
  for (const node of compiled2.ir.nodes) {
886
936
  const root = compiled2.hasCamera && node.props.fixed ? IDENTITY : cameraRoot;
887
- walk(node, root, 1, []);
937
+ const project = persp && !(node.props.fixed && compiled2.hasCamera);
938
+ walk(node, root, 1, [], 0, project);
888
939
  }
889
940
  return ops;
890
941
  }
@@ -930,6 +981,7 @@
930
981
  const target = () => {
931
982
  const f = stack[stack.length - 1];
932
983
  if (!f) return ctx2;
984
+ if (f.kind === "fx") return f.ctx;
933
985
  return f.phase === "content" && f.contentCtx ? f.contentCtx : f.matteCtx;
934
986
  };
935
987
  const newCtx = () => {
@@ -939,29 +991,54 @@
939
991
  return c.getContext("2d");
940
992
  };
941
993
  const composite = (f) => {
942
- if (!f.contentCtx) return;
943
- if (f.mode === "luma") lumaToAlpha(f.matteCtx);
944
- f.contentCtx.save();
945
- f.contentCtx.setTransform(1, 0, 0, 1, 0, 0);
946
- f.contentCtx.globalCompositeOperation = "destination-in";
947
- f.contentCtx.drawImage(f.matteCtx.canvas, 0, 0);
948
- f.contentCtx.restore();
994
+ if (f.kind === "matte") {
995
+ if (!f.contentCtx) return;
996
+ if (f.mode === "luma") lumaToAlpha(f.matteCtx);
997
+ f.contentCtx.save();
998
+ f.contentCtx.setTransform(1, 0, 0, 1, 0, 0);
999
+ f.contentCtx.globalCompositeOperation = "destination-in";
1000
+ f.contentCtx.drawImage(f.matteCtx.canvas, 0, 0);
1001
+ f.contentCtx.restore();
1002
+ f.parent.save();
1003
+ f.parent.setTransform(1, 0, 0, 1, 0, 0);
1004
+ f.parent.globalAlpha = 1;
1005
+ f.parent.globalCompositeOperation = "source-over";
1006
+ f.parent.filter = "none";
1007
+ f.parent.drawImage(f.contentCtx.canvas, 0, 0);
1008
+ f.parent.restore();
1009
+ return;
1010
+ }
949
1011
  f.parent.save();
950
1012
  f.parent.setTransform(1, 0, 0, 1, 0, 0);
951
1013
  f.parent.globalAlpha = 1;
952
- f.parent.globalCompositeOperation = "source-over";
953
- f.parent.filter = "none";
954
- f.parent.drawImage(f.contentCtx.canvas, 0, 0);
1014
+ f.parent.globalCompositeOperation = f.blend ? mapBlend(f.blend) : "source-over";
1015
+ f.parent.filter = f.blur ? `blur(${f.blur}px)` : "none";
1016
+ if (f.shadowColor) {
1017
+ f.parent.shadowColor = f.shadowColor;
1018
+ f.parent.shadowBlur = f.shadowBlur ?? 0;
1019
+ f.parent.shadowOffsetX = f.shadowX ?? 0;
1020
+ f.parent.shadowOffsetY = f.shadowY ?? 0;
1021
+ }
1022
+ f.parent.drawImage(f.ctx.canvas, 0, 0);
955
1023
  f.parent.restore();
956
1024
  };
957
1025
  for (const op of ops) {
1026
+ if (op.type === "group-fx-push") {
1027
+ stack.push({ kind: "fx", parent: target(), ctx: newCtx(), blur: op.blur, shadowColor: op.shadowColor, shadowBlur: op.shadowBlur, shadowX: op.shadowX, shadowY: op.shadowY, blend: op.blend });
1028
+ continue;
1029
+ }
1030
+ if (op.type === "group-fx-pop") {
1031
+ const f = stack.pop();
1032
+ if (f) composite(f);
1033
+ continue;
1034
+ }
958
1035
  if (op.type === "matte-push") {
959
- stack.push({ mode: op.mode, parent: target(), matteCtx: newCtx(), phase: "matte" });
1036
+ stack.push({ kind: "matte", mode: op.mode, parent: target(), matteCtx: newCtx(), phase: "matte" });
960
1037
  continue;
961
1038
  }
962
1039
  if (op.type === "matte-sep") {
963
1040
  const f = stack[stack.length - 1];
964
- if (f) {
1041
+ if (f && f.kind === "matte") {
965
1042
  f.contentCtx = newCtx();
966
1043
  f.phase = "content";
967
1044
  }
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,24 +3210,33 @@ 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;
3228
+ const hasFx = fx.blur !== void 0 || fx.shadowColor !== void 0 || fx.blend !== void 0;
3229
+ if (hasFx) ops.push({ type: "group-fx-push", id, transform: matrix, opacity, ...fx, ...clipSpread });
3180
3230
  if (node.props.matte && node.children.length >= 2) {
3181
3231
  ops.push({ type: "matte-push", id, transform: matrix, opacity, mode: node.props.matte, ...clipSpread });
3182
- walk(node.children[0], matrix, opacity, childClips);
3232
+ walk(node.children[0], matrix, opacity, childClips, depth, project);
3183
3233
  ops.push({ type: "matte-sep", id, transform: matrix, opacity });
3184
- 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);
3185
3235
  ops.push({ type: "matte-pop", id, transform: matrix, opacity });
3186
- return;
3236
+ } else {
3237
+ for (const child of node.children) walk(child, matrix, opacity, childClips, depth, project);
3187
3238
  }
3188
- for (const child of node.children) walk(child, matrix, opacity, childClips);
3239
+ if (hasFx) ops.push({ type: "group-fx-pop", id, transform: matrix, opacity });
3189
3240
  return;
3190
3241
  }
3191
3242
  case "rect":
@@ -3201,7 +3252,7 @@ function evaluate(compiled, t) {
3201
3252
  ops.push({
3202
3253
  type: node.type,
3203
3254
  id,
3204
- transform: matrix,
3255
+ transform: projDraw(matrix, width / 2, height / 2),
3205
3256
  opacity,
3206
3257
  width,
3207
3258
  height,
@@ -3222,7 +3273,7 @@ function evaluate(compiled, t) {
3222
3273
  ops.push({
3223
3274
  type: "image",
3224
3275
  id,
3225
- transform: matrix,
3276
+ transform: projDraw(matrix, width / 2, height / 2),
3226
3277
  opacity,
3227
3278
  src: str(id, "src", node.props.src),
3228
3279
  width,
@@ -3248,7 +3299,7 @@ function evaluate(compiled, t) {
3248
3299
  ops.push({
3249
3300
  type: "video",
3250
3301
  id,
3251
- transform: matrix,
3302
+ transform: projDraw(matrix, width / 2, height / 2),
3252
3303
  opacity,
3253
3304
  src: str(id, "src", node.props.src),
3254
3305
  width,
@@ -3274,7 +3325,8 @@ function evaluate(compiled, t) {
3274
3325
  ops.push({
3275
3326
  type: "path",
3276
3327
  id,
3277
- 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),
3278
3330
  opacity,
3279
3331
  d: dStr,
3280
3332
  progress: Math.max(0, Math.min(1, num(id, "progress", node.props.progress ?? 1))),
@@ -3296,7 +3348,7 @@ function evaluate(compiled, t) {
3296
3348
  ops.push({
3297
3349
  type: "text",
3298
3350
  id,
3299
- transform: matrix,
3351
+ transform: projDraw(matrix, 0, 0),
3300
3352
  opacity,
3301
3353
  content: typeof raw === "number" ? formatNumber(raw, decimals, node.props.contentThousands === true) : raw,
3302
3354
  fontFamily: str(id, "fontFamily", node.props.fontFamily),
@@ -3324,7 +3376,8 @@ function evaluate(compiled, t) {
3324
3376
  ) : IDENTITY;
3325
3377
  for (const node of compiled.ir.nodes) {
3326
3378
  const root = compiled.hasCamera && node.props.fixed ? IDENTITY : cameraRoot;
3327
- walk(node, root, 1, []);
3379
+ const project = persp && !(node.props.fixed && compiled.hasCamera);
3380
+ walk(node, root, 1, [], 0, project);
3328
3381
  }
3329
3382
  return ops;
3330
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";
@@ -40,6 +40,7 @@ function drawDisplayList(ctx, ops, images, videos) {
40
40
  const target = () => {
41
41
  const f = stack[stack.length - 1];
42
42
  if (!f) return ctx;
43
+ if (f.kind === "fx") return f.ctx;
43
44
  return f.phase === "content" && f.contentCtx ? f.contentCtx : f.matteCtx;
44
45
  };
45
46
  const newCtx = () => {
@@ -49,29 +50,54 @@ function drawDisplayList(ctx, ops, images, videos) {
49
50
  return c.getContext("2d");
50
51
  };
51
52
  const composite = (f) => {
52
- if (!f.contentCtx) return;
53
- if (f.mode === "luma") lumaToAlpha(f.matteCtx);
54
- f.contentCtx.save();
55
- f.contentCtx.setTransform(1, 0, 0, 1, 0, 0);
56
- f.contentCtx.globalCompositeOperation = "destination-in";
57
- f.contentCtx.drawImage(f.matteCtx.canvas, 0, 0);
58
- f.contentCtx.restore();
53
+ if (f.kind === "matte") {
54
+ if (!f.contentCtx) return;
55
+ if (f.mode === "luma") lumaToAlpha(f.matteCtx);
56
+ f.contentCtx.save();
57
+ f.contentCtx.setTransform(1, 0, 0, 1, 0, 0);
58
+ f.contentCtx.globalCompositeOperation = "destination-in";
59
+ f.contentCtx.drawImage(f.matteCtx.canvas, 0, 0);
60
+ f.contentCtx.restore();
61
+ f.parent.save();
62
+ f.parent.setTransform(1, 0, 0, 1, 0, 0);
63
+ f.parent.globalAlpha = 1;
64
+ f.parent.globalCompositeOperation = "source-over";
65
+ f.parent.filter = "none";
66
+ f.parent.drawImage(f.contentCtx.canvas, 0, 0);
67
+ f.parent.restore();
68
+ return;
69
+ }
59
70
  f.parent.save();
60
71
  f.parent.setTransform(1, 0, 0, 1, 0, 0);
61
72
  f.parent.globalAlpha = 1;
62
- f.parent.globalCompositeOperation = "source-over";
63
- f.parent.filter = "none";
64
- f.parent.drawImage(f.contentCtx.canvas, 0, 0);
73
+ f.parent.globalCompositeOperation = f.blend ? mapBlend(f.blend) : "source-over";
74
+ f.parent.filter = f.blur ? `blur(${f.blur}px)` : "none";
75
+ if (f.shadowColor) {
76
+ f.parent.shadowColor = f.shadowColor;
77
+ f.parent.shadowBlur = f.shadowBlur ?? 0;
78
+ f.parent.shadowOffsetX = f.shadowX ?? 0;
79
+ f.parent.shadowOffsetY = f.shadowY ?? 0;
80
+ }
81
+ f.parent.drawImage(f.ctx.canvas, 0, 0);
65
82
  f.parent.restore();
66
83
  };
67
84
  for (const op of ops) {
85
+ if (op.type === "group-fx-push") {
86
+ stack.push({ kind: "fx", parent: target(), ctx: newCtx(), blur: op.blur, shadowColor: op.shadowColor, shadowBlur: op.shadowBlur, shadowX: op.shadowX, shadowY: op.shadowY, blend: op.blend });
87
+ continue;
88
+ }
89
+ if (op.type === "group-fx-pop") {
90
+ const f = stack.pop();
91
+ if (f) composite(f);
92
+ continue;
93
+ }
68
94
  if (op.type === "matte-push") {
69
- stack.push({ mode: op.mode, parent: target(), matteCtx: newCtx(), phase: "matte" });
95
+ stack.push({ kind: "matte", mode: op.mode, parent: target(), matteCtx: newCtx(), phase: "matte" });
70
96
  continue;
71
97
  }
72
98
  if (op.type === "matte-sep") {
73
99
  const f = stack[stack.length - 1];
74
- if (f) {
100
+ if (f && f.kind === "matte") {
75
101
  f.contentCtx = newCtx();
76
102
  f.phase = "content";
77
103
  }
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;
@@ -110,6 +110,10 @@ export type DisplayOp = (OpBase & {
110
110
  type: "matte-sep";
111
111
  }) | (OpBase & {
112
112
  type: "matte-pop";
113
+ }) | (OpBase & {
114
+ type: "group-fx-push";
115
+ }) | (OpBase & {
116
+ type: "group-fx-pop";
113
117
  });
114
118
  export type DisplayList = DisplayOp[];
115
119
  /**
@@ -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 —
@@ -40,7 +54,8 @@ export interface BaseProps {
40
54
  * Paint effects (animatable scalars, in screen pixels — not transformed by the
41
55
  * node's rotation/scale or the camera, so a shadow keeps a consistent light
42
56
  * direction). `shadowColor` enables a drop shadow / outer glow (`glow`/`dropShadow`
43
- * helpers). No-op on a `group` (use a child; group/composite blur is a later add).
57
+ * helpers). On a `group` these apply to the WHOLE subtree as one composite (the
58
+ * renderer renders it offscreen, then draws it back with the effect once).
44
59
  */
45
60
  blur?: number;
46
61
  shadowColor?: string;
@@ -48,7 +63,8 @@ export interface BaseProps {
48
63
  shadowX?: number;
49
64
  shadowY?: number;
50
65
  /** How this node composites with what's already drawn (default "normal"). `screen`/
51
- * `add` brighten (additive light/glow), `multiply` tints/deepens. No-op on a group. */
66
+ * `add` brighten (additive light/glow), `multiply` tints/deepens. On a `group` the
67
+ * whole subtree composites as one layer (offscreen) — true group blend. */
52
68
  blend?: BlendMode;
53
69
  }
54
70
  /** Compositing modes (Canvas `globalCompositeOperation`; `add` maps to `lighter`). */
@@ -417,6 +433,15 @@ export interface CameraIR {
417
433
  y?: number;
418
434
  zoom?: number;
419
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;
420
445
  }
421
446
  export interface SceneIR {
422
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
@@ -193,8 +231,8 @@ rect({ id: "card", /* … */, blur: 18 }); tween("card", { blur: 0 }, { duration
193
231
  something to animate from).
194
232
  - Sugar: `glow(color, blur)` (offset 0) and `dropShadow(color, blur, x, y)` return
195
233
  a partial you spread into props (`...glow("#FFD24B", 28)`); still animatable.
196
- - No-op on a `group` (apply to a child; group/composite blur is a later add). See
197
- `examples/scenes/shadow-demo.ts`.
234
+ - On a `group` these apply to the whole subtree as one composite (focus pull / one
235
+ silhouette shadow) — see "Group effects" below. `examples/scenes/shadow-demo.ts`.
198
236
 
199
237
  ### Blend modes (compositing)
200
238
 
@@ -210,8 +248,8 @@ rect({ id: "neon", fill: linearGradient([...]), shadowColor: "#7A4DFF", blend: "
210
248
  - Modes: `normal` (default), `multiply`, `screen`, `overlay`, `lighten`, `darken`,
211
249
  `add` (additive light), `color-dodge`, `soft-light`, `hard-light`, `difference`.
212
250
  - **Discrete**, not interpolated — set per node (a static string). Default `normal`.
213
- - Per-shape. A whole-group blend (composite the subtree, then blend) is a later add;
214
- on a `group` the prop is a no-op. See `examples/scenes/blend-demo.ts`.
251
+ - Per-shape, or on a `group` to blend the whole composited subtree against the bg as one
252
+ layer (see "Group effects" below). See `examples/scenes/blend-demo.ts`.
215
253
 
216
254
  ## Character rig (skeleton, poses, IK)
217
255
 
@@ -370,6 +408,28 @@ group({ id: "reveal", x: W/2, y: H/2, anchor: "center", matte: "alpha" }, [
370
408
  combined via `destination-in`). Deterministic same-machine. See
371
409
  `examples/scenes/matte-demo.ts`.
372
410
 
411
+ ## Group effects (blur / shadow / blend on a whole group)
412
+
413
+ The paint effects (`blur`, `shadowColor`/`shadowBlur`/`shadowX`/`shadowY`, `blend`) also
414
+ work on a **group** — there they apply to the whole subtree as ONE composite layer, not per
415
+ child. The classic uses: a depth-of-field **focus pull** on a multi-node lockup, a single
416
+ **silhouette drop shadow** under a multi-shape mark, and a group that **blends against the
417
+ background** as one layer (so its own overlaps composite together).
418
+
419
+ ```ts
420
+ // the whole lockup sharpens as one image (animate the GROUP's blur)
421
+ group({ id: "lockup", x: 0, y: 0, blur: 20 }, [ card, dot, label ])
422
+ // timeline: tween("lockup", { blur: 0 }, { duration: 1.1, ease: "easeInOutCubic" })
423
+
424
+ group({ id: "mark", x: 0, y: 0, ...dropShadow("#000", 40, 0, 26) }, [ shapeA, shapeB, dot ]) // one shadow
425
+ group({ id: "burst", x, y, blend: "screen" }, [ disc1, disc2, disc3 ]) // one screen layer
426
+ ```
427
+
428
+ - Group `blur` is **animatable** (`tween(group, { blur })`); shadow scalars too.
429
+ - Same **offscreen compositing** as mattes (the subtree renders to a buffer, drawn back once
430
+ with the effect). It wraps a matte group and nests. The effects are screen-pixel space.
431
+ See `examples/scenes/group-fx-demo.ts`.
432
+
373
433
  ## Cursor (UI demos)
374
434
 
375
435
  A vector mouse pointer that glides across the scene and clicks things — for app
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reframe-video",
3
- "version": "0.6.9",
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",
@@ -576,6 +576,8 @@ function opCorners(op: DisplayOp): [number, number][] {
576
576
  case "matte-push":
577
577
  case "matte-sep":
578
578
  case "matte-pop":
579
+ case "group-fx-push":
580
+ case "group-fx-pop":
579
581
  return []; // boundary markers have no geometry
580
582
  }
581
583
  }