reframe-video 0.6.0 → 0.6.2

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
@@ -349,12 +349,36 @@ var init_compile = __esm({
349
349
  function validateScene(ir) {
350
350
  const problems = [];
351
351
  const nodeById = /* @__PURE__ */ new Map();
352
+ const checkPaint = (where, value) => {
353
+ if (typeof value !== "object" || value === null) return;
354
+ const g = value;
355
+ if (g.kind !== "linear" && g.kind !== "radial" && g.kind !== "conic") {
356
+ problems.push(`${where}: a paint object must be a gradient with kind "linear" / "radial" / "conic"`);
357
+ return;
358
+ }
359
+ if (!Array.isArray(g.stops) || g.stops.length === 0) {
360
+ problems.push(`${where}: gradient "${g.kind}" needs at least one color stop`);
361
+ return;
362
+ }
363
+ g.stops.forEach((s, i) => {
364
+ const st = s;
365
+ if (typeof st?.color !== "string") problems.push(`${where}: gradient stop ${i} needs a color string`);
366
+ if (typeof st?.offset !== "number" || st.offset < 0 || st.offset > 1) {
367
+ problems.push(`${where}: gradient stop ${i} "offset" must be a number in 0..1`);
368
+ }
369
+ });
370
+ };
352
371
  const collect = (nodes) => {
353
372
  for (const node of nodes) {
354
373
  if (nodeById.has(node.id)) {
355
374
  problems.push(`duplicate node id "${node.id}" \u2014 every node id must be unique`);
356
375
  }
357
376
  nodeById.set(node.id, node);
377
+ const props = node.props;
378
+ checkPaint(`node "${node.id}" fill`, props.fill);
379
+ checkPaint(`node "${node.id}" stroke`, props.stroke);
380
+ if (typeof props.blur === "number" && props.blur < 0) problems.push(`node "${node.id}": blur must be >= 0`);
381
+ if (typeof props.shadowBlur === "number" && props.shadowBlur < 0) problems.push(`node "${node.id}": shadowBlur must be >= 0`);
358
382
  if (node.type === "group") {
359
383
  const clip = node.props.clip;
360
384
  if (clip) {
@@ -569,16 +593,17 @@ function validateComposition(comp) {
569
593
  }
570
594
  if (problems.length > 0) throw new SceneValidationError(problems);
571
595
  }
572
- var COMMON_PROPS, CAMERA_PROPS, PROPS_BY_TYPE, SceneValidationError, TRANSITIONS;
596
+ var FX_PROPS, COMMON_PROPS, CAMERA_PROPS, PROPS_BY_TYPE, SceneValidationError, TRANSITIONS;
573
597
  var init_validate = __esm({
574
598
  "../core/src/validate.ts"() {
575
599
  "use strict";
576
- COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
600
+ FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY"];
601
+ COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
577
602
  CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
578
603
  PROPS_BY_TYPE = {
579
604
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
580
605
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
581
- line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
606
+ line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
582
607
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
583
608
  image: [...COMMON_PROPS, "src", "width", "height"],
584
609
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
@@ -899,6 +924,20 @@ var init_camera = __esm({
899
924
  }
900
925
  });
901
926
 
927
+ // ../core/src/gradient.ts
928
+ var init_gradient = __esm({
929
+ "../core/src/gradient.ts"() {
930
+ "use strict";
931
+ }
932
+ });
933
+
934
+ // ../core/src/effects.ts
935
+ var init_effects = __esm({
936
+ "../core/src/effects.ts"() {
937
+ "use strict";
938
+ }
939
+ });
940
+
902
941
  // ../core/src/presets.ts
903
942
  function makeRng(seed) {
904
943
  let a = seed >>> 0 || 2654435769;
@@ -1292,6 +1331,7 @@ var init_evaluate = __esm({
1292
1331
  "use strict";
1293
1332
  init_behaviors();
1294
1333
  init_camera();
1334
+ init_gradient();
1295
1335
  init_interpolate();
1296
1336
  init_path();
1297
1337
  }
@@ -1354,6 +1394,8 @@ var init_src = __esm({
1354
1394
  init_compile();
1355
1395
  init_path();
1356
1396
  init_camera();
1397
+ init_gradient();
1398
+ init_effects();
1357
1399
  init_presets();
1358
1400
  init_devicePreset();
1359
1401
  init_cursor();
@@ -6,6 +6,22 @@
6
6
  var DEFAULT_MOTIONPATH_DURATION = 1;
7
7
 
8
8
  // ../core/src/path.ts
9
+ function pathBBox(d) {
10
+ const nums = d.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi);
11
+ if (!nums || nums.length < 2) return [0, 0, 1, 1];
12
+ let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
13
+ for (let i = 0; i + 1 < nums.length; i += 2) {
14
+ const x = parseFloat(nums[i]);
15
+ const y = parseFloat(nums[i + 1]);
16
+ if (x < minx) minx = x;
17
+ if (x > maxx) maxx = x;
18
+ if (y < miny) miny = y;
19
+ if (y > maxy) maxy = y;
20
+ }
21
+ const w = maxx - minx;
22
+ const h = maxy - miny;
23
+ return [minx, miny, w > 0 ? w : 1, h > 0 ? h : 1];
24
+ }
9
25
  function locate(segCount, u) {
10
26
  if (segCount <= 0) return { i: 0, t: 0 };
11
27
  const clamped = Math.max(0, Math.min(1, u));
@@ -318,11 +334,12 @@
318
334
  }
319
335
 
320
336
  // ../core/src/validate.ts
321
- var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
337
+ var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY"];
338
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
322
339
  var PROPS_BY_TYPE = {
323
340
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
324
341
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
325
- line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
342
+ line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
326
343
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
327
344
  image: [...COMMON_PROPS, "src", "width", "height"],
328
345
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
@@ -344,6 +361,11 @@
344
361
  return [a, b, c, d, W / 2 - a * x - c * y, H / 2 - b * x - d * y];
345
362
  }
346
363
 
364
+ // ../core/src/gradient.ts
365
+ function isGradient(p) {
366
+ return typeof p === "object" && p !== null && (p.kind === "linear" || p.kind === "radial" || p.kind === "conic");
367
+ }
368
+
347
369
  // ../core/src/presets.ts
348
370
  var SET = 1 / 120;
349
371
 
@@ -658,9 +680,21 @@
658
680
  const v = valueAt(target, prop, base ?? "");
659
681
  return v === "" && base === void 0 ? void 0 : String(v);
660
682
  };
683
+ const effectFx = (id, p) => {
684
+ const fx = {};
685
+ if (p.blur !== void 0) fx.blur = num(id, "blur", p.blur);
686
+ if (p.shadowColor !== void 0) {
687
+ fx.shadowColor = str(id, "shadowColor", p.shadowColor);
688
+ fx.shadowBlur = num(id, "shadowBlur", p.shadowBlur ?? 0);
689
+ fx.shadowX = num(id, "shadowX", p.shadowX ?? 0);
690
+ fx.shadowY = num(id, "shadowY", p.shadowY ?? 0);
691
+ }
692
+ return fx;
693
+ };
661
694
  const walk = (node, parent, parentOpacity, clips) => {
662
695
  const id = node.id;
663
696
  const clipSpread = clips.length > 0 ? { clips } : void 0;
697
+ const fx = effectFx(id, node.props);
664
698
  if (node.type === "line") {
665
699
  const opacity2 = parentOpacity * num(id, "opacity", node.props.opacity ?? 1);
666
700
  if (opacity2 <= 0) return;
@@ -678,6 +712,7 @@
678
712
  y2: y1 + (num(id, "y2", node.props.y2) - y1) * progress,
679
713
  stroke: str(id, "stroke", node.props.stroke),
680
714
  strokeWidth: num(id, "strokeWidth", node.props.strokeWidth ?? 1),
715
+ ...fx,
681
716
  ...clipSpread
682
717
  });
683
718
  return;
@@ -709,8 +744,10 @@
709
744
  const height = num(id, "height", node.props.height);
710
745
  const [ax, ay] = ANCHOR_FACTORS[node.props.anchor ?? "top-left"];
711
746
  const strokeWidth = num(id, "strokeWidth", node.props.strokeWidth ?? 1);
712
- const fill = opt(id, "fill", node.props.fill);
713
- const stroke = opt(id, "stroke", node.props.stroke);
747
+ const fillP = node.props.fill;
748
+ const strokeP = node.props.stroke;
749
+ const fill = isGradient(fillP) ? fillP : opt(id, "fill", fillP);
750
+ const stroke = isGradient(strokeP) ? strokeP : opt(id, "stroke", strokeP);
714
751
  ops.push({
715
752
  type: node.type,
716
753
  id,
@@ -723,6 +760,7 @@
723
760
  ...fill !== void 0 && { fill },
724
761
  ...stroke !== void 0 && { stroke, strokeWidth },
725
762
  ...node.type === "rect" && { radius: num(id, "radius", node.props.radius ?? 0) },
763
+ ...fx,
726
764
  ...clipSpread
727
765
  });
728
766
  return;
@@ -741,6 +779,7 @@
741
779
  height,
742
780
  offsetX: -width * ax,
743
781
  offsetY: -height * ay,
782
+ ...fx,
744
783
  ...clipSpread
745
784
  });
746
785
  return;
@@ -748,17 +787,23 @@
748
787
  case "path": {
749
788
  const ox = num(id, "originX", node.props.originX ?? 0);
750
789
  const oy = num(id, "originY", node.props.originY ?? 0);
751
- const fill = opt(id, "fill", node.props.fill);
752
- const stroke = opt(id, "stroke", node.props.stroke);
790
+ const fillP = node.props.fill;
791
+ const strokeP = node.props.stroke;
792
+ const fill = isGradient(fillP) ? fillP : opt(id, "fill", fillP);
793
+ const stroke = isGradient(strokeP) ? strokeP : opt(id, "stroke", strokeP);
794
+ const dStr = str(id, "d", node.props.d);
795
+ const needsBox = isGradient(fill) || isGradient(stroke);
753
796
  ops.push({
754
797
  type: "path",
755
798
  id,
756
799
  transform: ox === 0 && oy === 0 ? matrix : multiply(matrix, [1, 0, 0, 1, -ox, -oy]),
757
800
  opacity,
758
- d: str(id, "d", node.props.d),
801
+ d: dStr,
759
802
  progress: Math.max(0, Math.min(1, num(id, "progress", node.props.progress ?? 1))),
760
803
  ...fill !== void 0 && { fill },
761
804
  ...stroke !== void 0 && { stroke, strokeWidth: num(id, "strokeWidth", node.props.strokeWidth ?? 1) },
805
+ ...needsBox && { bbox: pathBBox(dStr) },
806
+ ...fx,
762
807
  ...clipSpread
763
808
  });
764
809
  return;
@@ -783,6 +828,7 @@
783
828
  letterSpacing: num(id, "letterSpacing", node.props.letterSpacing ?? 0),
784
829
  align: TEXT_ALIGN[ax] ?? "left",
785
830
  baseline: TEXT_BASELINE[ay] ?? "top",
831
+ ...fx,
786
832
  ...clipSpread
787
833
  });
788
834
  return;
@@ -806,6 +852,31 @@
806
852
  }
807
853
 
808
854
  // ../renderer-canvas/src/index.ts
855
+ function resolvePaint(ctx2, paint, box) {
856
+ if (typeof paint === "string") return paint;
857
+ const { x, y, w, h } = box;
858
+ let g;
859
+ if (paint.kind === "linear") {
860
+ const a = (paint.angle ?? 0) * Math.PI / 180;
861
+ const dx = Math.cos(a);
862
+ const dy = Math.sin(a);
863
+ const cx = x + w / 2;
864
+ const cy = y + h / 2;
865
+ const half = Math.abs(dx) * (w / 2) + Math.abs(dy) * (h / 2);
866
+ g = ctx2.createLinearGradient(cx - dx * half, cy - dy * half, cx + dx * half, cy + dy * half);
867
+ } else if (paint.kind === "radial") {
868
+ const cx = x + (paint.cx ?? 0.5) * w;
869
+ const cy = y + (paint.cy ?? 0.5) * h;
870
+ const r = Math.max((paint.r ?? 0.5) * Math.max(w, h), 1e-4);
871
+ g = ctx2.createRadialGradient(cx, cy, 0, cx, cy, r);
872
+ } else {
873
+ const cx = x + (paint.cx ?? 0.5) * w;
874
+ const cy = y + (paint.cy ?? 0.5) * h;
875
+ g = ctx2.createConicGradient((paint.angle ?? 0) * Math.PI / 180, cx, cy);
876
+ }
877
+ for (const s of paint.stops) g.addColorStop(Math.max(0, Math.min(1, s.offset)), s.color);
878
+ return g;
879
+ }
809
880
  function renderFrame(ctx2, compiled2, t, images2) {
810
881
  const { size, background } = compiled2.ir;
811
882
  ctx2.setTransform(1, 0, 0, 1, 0, 0);
@@ -836,8 +907,16 @@
836
907
  }
837
908
  ctx2.setTransform(...op.transform);
838
909
  ctx2.globalAlpha = Math.max(0, Math.min(1, op.opacity));
910
+ if (op.blur) ctx2.filter = `blur(${op.blur}px)`;
911
+ if (op.shadowColor) {
912
+ ctx2.shadowColor = op.shadowColor;
913
+ ctx2.shadowBlur = op.shadowBlur ?? 0;
914
+ ctx2.shadowOffsetX = op.shadowX ?? 0;
915
+ ctx2.shadowOffsetY = op.shadowY ?? 0;
916
+ }
839
917
  switch (op.type) {
840
918
  case "rect": {
919
+ const box = { x: op.offsetX, y: op.offsetY, w: op.width, h: op.height };
841
920
  ctx2.beginPath();
842
921
  if (op.radius && op.radius > 0) {
843
922
  ctx2.roundRect(op.offsetX, op.offsetY, op.width, op.height, op.radius);
@@ -845,17 +924,18 @@
845
924
  ctx2.rect(op.offsetX, op.offsetY, op.width, op.height);
846
925
  }
847
926
  if (op.fill) {
848
- ctx2.fillStyle = op.fill;
927
+ ctx2.fillStyle = resolvePaint(ctx2, op.fill, box);
849
928
  ctx2.fill();
850
929
  }
851
930
  if (op.stroke) {
852
- ctx2.strokeStyle = op.stroke;
931
+ ctx2.strokeStyle = resolvePaint(ctx2, op.stroke, box);
853
932
  ctx2.lineWidth = op.strokeWidth ?? 1;
854
933
  ctx2.stroke();
855
934
  }
856
935
  break;
857
936
  }
858
937
  case "ellipse": {
938
+ const box = { x: op.offsetX, y: op.offsetY, w: op.width, h: op.height };
859
939
  ctx2.beginPath();
860
940
  ctx2.ellipse(
861
941
  op.offsetX + op.width / 2,
@@ -867,11 +947,11 @@
867
947
  Math.PI * 2
868
948
  );
869
949
  if (op.fill) {
870
- ctx2.fillStyle = op.fill;
950
+ ctx2.fillStyle = resolvePaint(ctx2, op.fill, box);
871
951
  ctx2.fill();
872
952
  }
873
953
  if (op.stroke) {
874
- ctx2.strokeStyle = op.stroke;
954
+ ctx2.strokeStyle = resolvePaint(ctx2, op.stroke, box);
875
955
  ctx2.lineWidth = op.strokeWidth ?? 1;
876
956
  ctx2.stroke();
877
957
  }
@@ -908,12 +988,13 @@
908
988
  }
909
989
  case "path": {
910
990
  const p = new Path2D(op.d);
991
+ const box = op.bbox ? { x: op.bbox[0], y: op.bbox[1], w: op.bbox[2], h: op.bbox[3] } : { x: 0, y: 0, w: 1, h: 1 };
911
992
  if (op.fill) {
912
- ctx2.fillStyle = op.fill;
993
+ ctx2.fillStyle = resolvePaint(ctx2, op.fill, box);
913
994
  ctx2.fill(p);
914
995
  }
915
996
  if (op.stroke && (op.strokeWidth ?? 1) > 0) {
916
- ctx2.strokeStyle = op.stroke;
997
+ ctx2.strokeStyle = resolvePaint(ctx2, op.stroke, box);
917
998
  ctx2.lineWidth = op.strokeWidth ?? 1;
918
999
  ctx2.lineJoin = "round";
919
1000
  ctx2.lineCap = "round";
package/dist/cli.js CHANGED
@@ -324,12 +324,13 @@ function compileScene(ir) {
324
324
  }
325
325
 
326
326
  // ../core/src/validate.ts
327
- var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
327
+ var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY"];
328
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
328
329
  var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
329
330
  var PROPS_BY_TYPE = {
330
331
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
331
332
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
332
- line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
333
+ line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
333
334
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
334
335
  image: [...COMMON_PROPS, "src", "width", "height"],
335
336
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
@@ -347,12 +348,36 @@ ${problems.map((p) => ` - ${p}`).join("\n")}`);
347
348
  function validateScene(ir) {
348
349
  const problems = [];
349
350
  const nodeById = /* @__PURE__ */ new Map();
351
+ const checkPaint = (where, value) => {
352
+ if (typeof value !== "object" || value === null) return;
353
+ const g = value;
354
+ if (g.kind !== "linear" && g.kind !== "radial" && g.kind !== "conic") {
355
+ problems.push(`${where}: a paint object must be a gradient with kind "linear" / "radial" / "conic"`);
356
+ return;
357
+ }
358
+ if (!Array.isArray(g.stops) || g.stops.length === 0) {
359
+ problems.push(`${where}: gradient "${g.kind}" needs at least one color stop`);
360
+ return;
361
+ }
362
+ g.stops.forEach((s, i) => {
363
+ const st = s;
364
+ if (typeof st?.color !== "string") problems.push(`${where}: gradient stop ${i} needs a color string`);
365
+ if (typeof st?.offset !== "number" || st.offset < 0 || st.offset > 1) {
366
+ problems.push(`${where}: gradient stop ${i} "offset" must be a number in 0..1`);
367
+ }
368
+ });
369
+ };
350
370
  const collect = (nodes) => {
351
371
  for (const node of nodes) {
352
372
  if (nodeById.has(node.id)) {
353
373
  problems.push(`duplicate node id "${node.id}" \u2014 every node id must be unique`);
354
374
  }
355
375
  nodeById.set(node.id, node);
376
+ const props = node.props;
377
+ checkPaint(`node "${node.id}" fill`, props.fill);
378
+ checkPaint(`node "${node.id}" stroke`, props.stroke);
379
+ if (typeof props.blur === "number" && props.blur < 0) problems.push(`node "${node.id}": blur must be >= 0`);
380
+ if (typeof props.shadowBlur === "number" && props.shadowBlur < 0) problems.push(`node "${node.id}": shadowBlur must be >= 0`);
356
381
  if (node.type === "group") {
357
382
  const clip = node.props.clip;
358
383
  if (clip) {
package/dist/index.js CHANGED
@@ -6,6 +6,22 @@ var DEFAULT_MOTIONPATH_DURATION = 1;
6
6
  var DEFAULT_FPS = 30;
7
7
 
8
8
  // ../core/src/path.ts
9
+ function pathBBox(d) {
10
+ const nums = d.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi);
11
+ if (!nums || nums.length < 2) return [0, 0, 1, 1];
12
+ let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
13
+ for (let i = 0; i + 1 < nums.length; i += 2) {
14
+ const x = parseFloat(nums[i]);
15
+ const y = parseFloat(nums[i + 1]);
16
+ if (x < minx) minx = x;
17
+ if (x > maxx) maxx = x;
18
+ if (y < miny) miny = y;
19
+ if (y > maxy) maxy = y;
20
+ }
21
+ const w = maxx - minx;
22
+ const h = maxy - miny;
23
+ return [minx, miny, w > 0 ? w : 1, h > 0 ? h : 1];
24
+ }
9
25
  function locate(segCount, u) {
10
26
  if (segCount <= 0) return { i: 0, t: 0 };
11
27
  const clamped = Math.max(0, Math.min(1, u));
@@ -318,12 +334,13 @@ function compileScene(ir) {
318
334
  }
319
335
 
320
336
  // ../core/src/validate.ts
321
- var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
337
+ var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY"];
338
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
322
339
  var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
323
340
  var PROPS_BY_TYPE = {
324
341
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
325
342
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
326
- line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
343
+ line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
327
344
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
328
345
  image: [...COMMON_PROPS, "src", "width", "height"],
329
346
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
@@ -341,12 +358,36 @@ ${problems.map((p) => ` - ${p}`).join("\n")}`);
341
358
  function validateScene(ir) {
342
359
  const problems = [];
343
360
  const nodeById = /* @__PURE__ */ new Map();
361
+ const checkPaint = (where, value) => {
362
+ if (typeof value !== "object" || value === null) return;
363
+ const g = value;
364
+ if (g.kind !== "linear" && g.kind !== "radial" && g.kind !== "conic") {
365
+ problems.push(`${where}: a paint object must be a gradient with kind "linear" / "radial" / "conic"`);
366
+ return;
367
+ }
368
+ if (!Array.isArray(g.stops) || g.stops.length === 0) {
369
+ problems.push(`${where}: gradient "${g.kind}" needs at least one color stop`);
370
+ return;
371
+ }
372
+ g.stops.forEach((s, i) => {
373
+ const st = s;
374
+ if (typeof st?.color !== "string") problems.push(`${where}: gradient stop ${i} needs a color string`);
375
+ if (typeof st?.offset !== "number" || st.offset < 0 || st.offset > 1) {
376
+ problems.push(`${where}: gradient stop ${i} "offset" must be a number in 0..1`);
377
+ }
378
+ });
379
+ };
344
380
  const collect = (nodes) => {
345
381
  for (const node of nodes) {
346
382
  if (nodeById.has(node.id)) {
347
383
  problems.push(`duplicate node id "${node.id}" \u2014 every node id must be unique`);
348
384
  }
349
385
  nodeById.set(node.id, node);
386
+ const props = node.props;
387
+ checkPaint(`node "${node.id}" fill`, props.fill);
388
+ checkPaint(`node "${node.id}" stroke`, props.stroke);
389
+ if (typeof props.blur === "number" && props.blur < 0) problems.push(`node "${node.id}": blur must be >= 0`);
390
+ if (typeof props.shadowBlur === "number" && props.shadowBlur < 0) problems.push(`node "${node.id}": shadowBlur must be >= 0`);
350
391
  if (node.type === "group") {
351
392
  const clip = node.props.clip;
352
393
  if (clip) {
@@ -917,6 +958,46 @@ function cameraTo(props, opts = {}) {
917
958
  return tween(CAMERA_ID, props, opts);
918
959
  }
919
960
 
961
+ // ../core/src/gradient.ts
962
+ function isGradient(p) {
963
+ return typeof p === "object" && p !== null && (p.kind === "linear" || p.kind === "radial" || p.kind === "conic");
964
+ }
965
+ function toStops(stops) {
966
+ if (stops.length > 0 && typeof stops[0] === "object") return stops;
967
+ const cs = stops;
968
+ const n3 = cs.length;
969
+ return cs.map((color, i) => ({ offset: n3 <= 1 ? 0 : i / (n3 - 1), color }));
970
+ }
971
+ function linearGradient(stops, opts = {}) {
972
+ return { kind: "linear", ...opts.angle !== void 0 && { angle: opts.angle }, stops: toStops(stops) };
973
+ }
974
+ function radialGradient(stops, opts = {}) {
975
+ return {
976
+ kind: "radial",
977
+ ...opts.cx !== void 0 && { cx: opts.cx },
978
+ ...opts.cy !== void 0 && { cy: opts.cy },
979
+ ...opts.r !== void 0 && { r: opts.r },
980
+ stops: toStops(stops)
981
+ };
982
+ }
983
+ function conicGradient(stops, opts = {}) {
984
+ return {
985
+ kind: "conic",
986
+ ...opts.angle !== void 0 && { angle: opts.angle },
987
+ ...opts.cx !== void 0 && { cx: opts.cx },
988
+ ...opts.cy !== void 0 && { cy: opts.cy },
989
+ stops: toStops(stops)
990
+ };
991
+ }
992
+
993
+ // ../core/src/effects.ts
994
+ function glow(color, blur = 24) {
995
+ return { shadowColor: color, shadowBlur: blur, shadowX: 0, shadowY: 0 };
996
+ }
997
+ function dropShadow(color, blur = 24, x = 0, y = 12) {
998
+ return { shadowColor: color, shadowBlur: blur, shadowX: x, shadowY: y };
999
+ }
1000
+
920
1001
  // ../core/src/presets.ts
921
1002
  var PRESET_NAMES = [
922
1003
  "draw-bloom",
@@ -1456,11 +1537,11 @@ function ikReach(upper, lower, dx, dy, flip = false) {
1456
1537
  function humanoid(opts = {}) {
1457
1538
  const line2 = opts.color ?? DEFAULT_LINE;
1458
1539
  const fill = opts.fill ?? DEFAULT_FILL;
1459
- const glow = opts.glow;
1540
+ const glow2 = opts.glow;
1460
1541
  const blob = (jid, a, b, cy) => {
1461
1542
  const d = ovalPath(a, b, 0, cy);
1462
1543
  const nodes = [];
1463
- if (glow) nodes.push(path({ id: `${jid}-glow`, d, x: 0, y: 0, fill: "none", stroke: glow, strokeWidth: GLOW_W, opacity: 0.18 }));
1544
+ if (glow2) nodes.push(path({ id: `${jid}-glow`, d, x: 0, y: 0, fill: "none", stroke: glow2, strokeWidth: GLOW_W, opacity: 0.18 }));
1464
1545
  nodes.push(path({ id: `${jid}-shape`, d, x: 0, y: 0, fill, stroke: line2, strokeWidth: LINE_W }));
1465
1546
  return nodes;
1466
1547
  };
@@ -2868,9 +2949,21 @@ function evaluate(compiled, t) {
2868
2949
  const v = valueAt(target, prop, base ?? "");
2869
2950
  return v === "" && base === void 0 ? void 0 : String(v);
2870
2951
  };
2952
+ const effectFx = (id, p) => {
2953
+ const fx = {};
2954
+ if (p.blur !== void 0) fx.blur = num(id, "blur", p.blur);
2955
+ if (p.shadowColor !== void 0) {
2956
+ fx.shadowColor = str(id, "shadowColor", p.shadowColor);
2957
+ fx.shadowBlur = num(id, "shadowBlur", p.shadowBlur ?? 0);
2958
+ fx.shadowX = num(id, "shadowX", p.shadowX ?? 0);
2959
+ fx.shadowY = num(id, "shadowY", p.shadowY ?? 0);
2960
+ }
2961
+ return fx;
2962
+ };
2871
2963
  const walk = (node, parent, parentOpacity, clips) => {
2872
2964
  const id = node.id;
2873
2965
  const clipSpread = clips.length > 0 ? { clips } : void 0;
2966
+ const fx = effectFx(id, node.props);
2874
2967
  if (node.type === "line") {
2875
2968
  const opacity2 = parentOpacity * num(id, "opacity", node.props.opacity ?? 1);
2876
2969
  if (opacity2 <= 0) return;
@@ -2888,6 +2981,7 @@ function evaluate(compiled, t) {
2888
2981
  y2: y1 + (num(id, "y2", node.props.y2) - y1) * progress,
2889
2982
  stroke: str(id, "stroke", node.props.stroke),
2890
2983
  strokeWidth: num(id, "strokeWidth", node.props.strokeWidth ?? 1),
2984
+ ...fx,
2891
2985
  ...clipSpread
2892
2986
  });
2893
2987
  return;
@@ -2919,8 +3013,10 @@ function evaluate(compiled, t) {
2919
3013
  const height = num(id, "height", node.props.height);
2920
3014
  const [ax, ay] = ANCHOR_FACTORS[node.props.anchor ?? "top-left"];
2921
3015
  const strokeWidth = num(id, "strokeWidth", node.props.strokeWidth ?? 1);
2922
- const fill = opt(id, "fill", node.props.fill);
2923
- const stroke = opt(id, "stroke", node.props.stroke);
3016
+ const fillP = node.props.fill;
3017
+ const strokeP = node.props.stroke;
3018
+ const fill = isGradient(fillP) ? fillP : opt(id, "fill", fillP);
3019
+ const stroke = isGradient(strokeP) ? strokeP : opt(id, "stroke", strokeP);
2924
3020
  ops.push({
2925
3021
  type: node.type,
2926
3022
  id,
@@ -2933,6 +3029,7 @@ function evaluate(compiled, t) {
2933
3029
  ...fill !== void 0 && { fill },
2934
3030
  ...stroke !== void 0 && { stroke, strokeWidth },
2935
3031
  ...node.type === "rect" && { radius: num(id, "radius", node.props.radius ?? 0) },
3032
+ ...fx,
2936
3033
  ...clipSpread
2937
3034
  });
2938
3035
  return;
@@ -2951,6 +3048,7 @@ function evaluate(compiled, t) {
2951
3048
  height,
2952
3049
  offsetX: -width * ax,
2953
3050
  offsetY: -height * ay,
3051
+ ...fx,
2954
3052
  ...clipSpread
2955
3053
  });
2956
3054
  return;
@@ -2958,17 +3056,23 @@ function evaluate(compiled, t) {
2958
3056
  case "path": {
2959
3057
  const ox = num(id, "originX", node.props.originX ?? 0);
2960
3058
  const oy = num(id, "originY", node.props.originY ?? 0);
2961
- const fill = opt(id, "fill", node.props.fill);
2962
- const stroke = opt(id, "stroke", node.props.stroke);
3059
+ const fillP = node.props.fill;
3060
+ const strokeP = node.props.stroke;
3061
+ const fill = isGradient(fillP) ? fillP : opt(id, "fill", fillP);
3062
+ const stroke = isGradient(strokeP) ? strokeP : opt(id, "stroke", strokeP);
3063
+ const dStr = str(id, "d", node.props.d);
3064
+ const needsBox = isGradient(fill) || isGradient(stroke);
2963
3065
  ops.push({
2964
3066
  type: "path",
2965
3067
  id,
2966
3068
  transform: ox === 0 && oy === 0 ? matrix : multiply(matrix, [1, 0, 0, 1, -ox, -oy]),
2967
3069
  opacity,
2968
- d: str(id, "d", node.props.d),
3070
+ d: dStr,
2969
3071
  progress: Math.max(0, Math.min(1, num(id, "progress", node.props.progress ?? 1))),
2970
3072
  ...fill !== void 0 && { fill },
2971
3073
  ...stroke !== void 0 && { stroke, strokeWidth: num(id, "strokeWidth", node.props.strokeWidth ?? 1) },
3074
+ ...needsBox && { bbox: pathBBox(dStr) },
3075
+ ...fx,
2972
3076
  ...clipSpread
2973
3077
  });
2974
3078
  return;
@@ -2993,6 +3097,7 @@ function evaluate(compiled, t) {
2993
3097
  letterSpacing: num(id, "letterSpacing", node.props.letterSpacing ?? 0),
2994
3098
  align: TEXT_ALIGN[ax] ?? "left",
2995
3099
  baseline: TEXT_BASELINE[ay] ?? "top",
3100
+ ...fx,
2996
3101
  ...clipSpread
2997
3102
  });
2998
3103
  return;
@@ -3116,6 +3221,7 @@ export {
3116
3221
  compileScene,
3117
3222
  composeScene,
3118
3223
  composition,
3224
+ conicGradient,
3119
3225
  cursor,
3120
3226
  cursorClick,
3121
3227
  cursorDouble,
@@ -3126,17 +3232,21 @@ export {
3126
3232
  deviceScreen,
3127
3233
  deviceScreenCenter,
3128
3234
  deviceScreenPoint,
3235
+ dropShadow,
3129
3236
  ellipse,
3130
3237
  evaluate,
3131
3238
  figure,
3132
3239
  formatComposeReport,
3240
+ glow,
3133
3241
  group,
3134
3242
  humanoid,
3135
3243
  ikReach,
3136
3244
  image,
3137
3245
  isColor,
3246
+ isGradient,
3138
3247
  lerpValue,
3139
3248
  line,
3249
+ linearGradient,
3140
3250
  motionOp,
3141
3251
  motionOpLabel,
3142
3252
  motionPath,
@@ -3149,6 +3259,7 @@ export {
3149
3259
  pathPoint,
3150
3260
  pathTangentAngle,
3151
3261
  poseTo,
3262
+ radialGradient,
3152
3263
  rect,
3153
3264
  resolveAudioPlan,
3154
3265
  resolveCompositionAudioPlan,
package/dist/labels.js CHANGED
@@ -318,12 +318,13 @@ function compileScene(ir) {
318
318
  }
319
319
 
320
320
  // ../core/src/validate.ts
321
- var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
321
+ var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY"];
322
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
322
323
  var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
323
324
  var PROPS_BY_TYPE = {
324
325
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
325
326
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
326
- line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
327
+ line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
327
328
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
328
329
  image: [...COMMON_PROPS, "src", "width", "height"],
329
330
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
@@ -341,12 +342,36 @@ ${problems.map((p) => ` - ${p}`).join("\n")}`);
341
342
  function validateScene(ir) {
342
343
  const problems = [];
343
344
  const nodeById = /* @__PURE__ */ new Map();
345
+ const checkPaint = (where, value) => {
346
+ if (typeof value !== "object" || value === null) return;
347
+ const g = value;
348
+ if (g.kind !== "linear" && g.kind !== "radial" && g.kind !== "conic") {
349
+ problems.push(`${where}: a paint object must be a gradient with kind "linear" / "radial" / "conic"`);
350
+ return;
351
+ }
352
+ if (!Array.isArray(g.stops) || g.stops.length === 0) {
353
+ problems.push(`${where}: gradient "${g.kind}" needs at least one color stop`);
354
+ return;
355
+ }
356
+ g.stops.forEach((s, i) => {
357
+ const st = s;
358
+ if (typeof st?.color !== "string") problems.push(`${where}: gradient stop ${i} needs a color string`);
359
+ if (typeof st?.offset !== "number" || st.offset < 0 || st.offset > 1) {
360
+ problems.push(`${where}: gradient stop ${i} "offset" must be a number in 0..1`);
361
+ }
362
+ });
363
+ };
344
364
  const collect = (nodes) => {
345
365
  for (const node of nodes) {
346
366
  if (nodeById.has(node.id)) {
347
367
  problems.push(`duplicate node id "${node.id}" \u2014 every node id must be unique`);
348
368
  }
349
369
  nodeById.set(node.id, node);
370
+ const props = node.props;
371
+ checkPaint(`node "${node.id}" fill`, props.fill);
372
+ checkPaint(`node "${node.id}" stroke`, props.stroke);
373
+ if (typeof props.blur === "number" && props.blur < 0) problems.push(`node "${node.id}": blur must be >= 0`);
374
+ if (typeof props.shadowBlur === "number" && props.shadowBlur < 0) problems.push(`node "${node.id}": shadowBlur must be >= 0`);
350
375
  if (node.type === "group") {
351
376
  const clip = node.props.clip;
352
377
  if (clip) {
@@ -1,5 +1,30 @@
1
1
  // ../renderer-canvas/src/index.ts
2
2
  import { evaluate } from "@reframe/core";
3
+ function resolvePaint(ctx, paint, box) {
4
+ if (typeof paint === "string") return paint;
5
+ const { x, y, w, h } = box;
6
+ let g;
7
+ if (paint.kind === "linear") {
8
+ const a = (paint.angle ?? 0) * Math.PI / 180;
9
+ const dx = Math.cos(a);
10
+ const dy = Math.sin(a);
11
+ const cx = x + w / 2;
12
+ const cy = y + h / 2;
13
+ const half = Math.abs(dx) * (w / 2) + Math.abs(dy) * (h / 2);
14
+ g = ctx.createLinearGradient(cx - dx * half, cy - dy * half, cx + dx * half, cy + dy * half);
15
+ } else if (paint.kind === "radial") {
16
+ const cx = x + (paint.cx ?? 0.5) * w;
17
+ const cy = y + (paint.cy ?? 0.5) * h;
18
+ const r = Math.max((paint.r ?? 0.5) * Math.max(w, h), 1e-4);
19
+ g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
20
+ } else {
21
+ const cx = x + (paint.cx ?? 0.5) * w;
22
+ const cy = y + (paint.cy ?? 0.5) * h;
23
+ g = ctx.createConicGradient((paint.angle ?? 0) * Math.PI / 180, cx, cy);
24
+ }
25
+ for (const s of paint.stops) g.addColorStop(Math.max(0, Math.min(1, s.offset)), s.color);
26
+ return g;
27
+ }
3
28
  function renderFrame(ctx, compiled, t, images) {
4
29
  const { size, background } = compiled.ir;
5
30
  ctx.setTransform(1, 0, 0, 1, 0, 0);
@@ -30,8 +55,16 @@ function drawDisplayList(ctx, ops, images) {
30
55
  }
31
56
  ctx.setTransform(...op.transform);
32
57
  ctx.globalAlpha = Math.max(0, Math.min(1, op.opacity));
58
+ if (op.blur) ctx.filter = `blur(${op.blur}px)`;
59
+ if (op.shadowColor) {
60
+ ctx.shadowColor = op.shadowColor;
61
+ ctx.shadowBlur = op.shadowBlur ?? 0;
62
+ ctx.shadowOffsetX = op.shadowX ?? 0;
63
+ ctx.shadowOffsetY = op.shadowY ?? 0;
64
+ }
33
65
  switch (op.type) {
34
66
  case "rect": {
67
+ const box = { x: op.offsetX, y: op.offsetY, w: op.width, h: op.height };
35
68
  ctx.beginPath();
36
69
  if (op.radius && op.radius > 0) {
37
70
  ctx.roundRect(op.offsetX, op.offsetY, op.width, op.height, op.radius);
@@ -39,17 +72,18 @@ function drawDisplayList(ctx, ops, images) {
39
72
  ctx.rect(op.offsetX, op.offsetY, op.width, op.height);
40
73
  }
41
74
  if (op.fill) {
42
- ctx.fillStyle = op.fill;
75
+ ctx.fillStyle = resolvePaint(ctx, op.fill, box);
43
76
  ctx.fill();
44
77
  }
45
78
  if (op.stroke) {
46
- ctx.strokeStyle = op.stroke;
79
+ ctx.strokeStyle = resolvePaint(ctx, op.stroke, box);
47
80
  ctx.lineWidth = op.strokeWidth ?? 1;
48
81
  ctx.stroke();
49
82
  }
50
83
  break;
51
84
  }
52
85
  case "ellipse": {
86
+ const box = { x: op.offsetX, y: op.offsetY, w: op.width, h: op.height };
53
87
  ctx.beginPath();
54
88
  ctx.ellipse(
55
89
  op.offsetX + op.width / 2,
@@ -61,11 +95,11 @@ function drawDisplayList(ctx, ops, images) {
61
95
  Math.PI * 2
62
96
  );
63
97
  if (op.fill) {
64
- ctx.fillStyle = op.fill;
98
+ ctx.fillStyle = resolvePaint(ctx, op.fill, box);
65
99
  ctx.fill();
66
100
  }
67
101
  if (op.stroke) {
68
- ctx.strokeStyle = op.stroke;
102
+ ctx.strokeStyle = resolvePaint(ctx, op.stroke, box);
69
103
  ctx.lineWidth = op.strokeWidth ?? 1;
70
104
  ctx.stroke();
71
105
  }
@@ -102,12 +136,13 @@ function drawDisplayList(ctx, ops, images) {
102
136
  }
103
137
  case "path": {
104
138
  const p = new Path2D(op.d);
139
+ const box = op.bbox ? { x: op.bbox[0], y: op.bbox[1], w: op.bbox[2], h: op.bbox[3] } : { x: 0, y: 0, w: 1, h: 1 };
105
140
  if (op.fill) {
106
- ctx.fillStyle = op.fill;
141
+ ctx.fillStyle = resolvePaint(ctx, op.fill, box);
107
142
  ctx.fill(p);
108
143
  }
109
144
  if (op.stroke && (op.strokeWidth ?? 1) > 0) {
110
- ctx.strokeStyle = op.stroke;
145
+ ctx.strokeStyle = resolvePaint(ctx, op.stroke, box);
111
146
  ctx.lineWidth = op.strokeWidth ?? 1;
112
147
  ctx.lineJoin = "round";
113
148
  ctx.lineCap = "round";
package/dist/trace-cli.js CHANGED
@@ -6,11 +6,12 @@ import { resolve as resolve2 } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
7
 
8
8
  // ../core/src/validate.ts
9
- var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
9
+ var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY"];
10
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
10
11
  var PROPS_BY_TYPE = {
11
12
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
12
13
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
13
- line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
14
+ line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
14
15
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
15
16
  image: [...COMMON_PROPS, "src", "width", "height"],
16
17
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Drop-shadow / outer-glow sugar. These return a partial-props object you spread
3
+ * into a shape node; the underlying `shadow*` props stay animatable (e.g. pulse a
4
+ * glow with `oscillate(id, "shadowBlur", …)`). Units are screen pixels.
5
+ */
6
+ import type { BaseProps } from "./ir.js";
7
+ type ShadowProps = Pick<BaseProps, "shadowColor" | "shadowBlur" | "shadowX" | "shadowY">;
8
+ /** An outer glow — a shadow with no offset. `rect({ …, ...glow("#FFD24B", 28) })`. */
9
+ export declare function glow(color: string, blur?: number): ShadowProps;
10
+ /** A drop shadow (offset downward by default). `rect({ …, ...dropShadow("#0008", 40, 0, 16) })`. */
11
+ export declare function dropShadow(color: string, blur?: number, x?: number, y?: number): ShadowProps;
12
+ export {};
@@ -4,7 +4,7 @@
4
4
  * always. Renderers only draw; they never compute animation.
5
5
  */
6
6
  import type { CompiledScene } from "./compile.js";
7
- import type { ClipShape, PropValue } from "./ir.js";
7
+ import type { ClipShape, Paint, PropValue } from "./ir.js";
8
8
  /** Canvas-style 2D affine matrix [a, b, c, d, e, f]. */
9
9
  export type Mat2D = [number, number, number, number, number, number];
10
10
  /** A clip from an ancestor group: its shape in the group's coordinate space,
@@ -24,6 +24,12 @@ interface OpBase {
24
24
  opacity: number;
25
25
  /** Clip regions from ancestor groups (intersected by the renderer). */
26
26
  clips?: ClipRegion[];
27
+ /** Paint effects (screen-pixel space). Present only when authored. */
28
+ blur?: number;
29
+ shadowColor?: string;
30
+ shadowBlur?: number;
31
+ shadowX?: number;
32
+ shadowY?: number;
27
33
  }
28
34
  export type DisplayOp = (OpBase & {
29
35
  type: "rect";
@@ -31,8 +37,8 @@ export type DisplayOp = (OpBase & {
31
37
  height: number;
32
38
  offsetX: number;
33
39
  offsetY: number;
34
- fill?: string;
35
- stroke?: string;
40
+ fill?: Paint;
41
+ stroke?: Paint;
36
42
  strokeWidth?: number;
37
43
  radius?: number;
38
44
  }) | (OpBase & {
@@ -41,8 +47,8 @@ export type DisplayOp = (OpBase & {
41
47
  height: number;
42
48
  offsetX: number;
43
49
  offsetY: number;
44
- fill?: string;
45
- stroke?: string;
50
+ fill?: Paint;
51
+ stroke?: Paint;
46
52
  strokeWidth?: number;
47
53
  }) | (OpBase & {
48
54
  type: "line";
@@ -76,9 +82,11 @@ export type DisplayOp = (OpBase & {
76
82
  d: string;
77
83
  /** 0..1 fraction of the stroke outline drawn (draw-on). */
78
84
  progress: number;
79
- fill?: string;
80
- stroke?: string;
85
+ fill?: Paint;
86
+ stroke?: Paint;
81
87
  strokeWidth?: number;
88
+ /** Local-space bbox [x,y,w,h] for mapping a gradient paint (set only when one is used). */
89
+ bbox?: [number, number, number, number];
82
90
  });
83
91
  export type DisplayList = DisplayOp[];
84
92
  /**
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Gradient paints — the structured alternative to a solid color `fill`/`stroke`.
3
+ *
4
+ * Coordinates are normalized to the node's bounding box (0..1), so a gradient is
5
+ * just an angle + color stops, independent of the node's size. The renderer maps
6
+ * 0..1 → the node's local box and the paint is applied in node-local space, so
7
+ * rotating/scaling the NODE moves the gradient with it (the "animated gradient"
8
+ * idiom — gradients themselves are static this version). Pure + deterministic.
9
+ */
10
+ import type { ColorStop, Gradient, Paint } from "./ir.js";
11
+ /** True when a paint is a gradient object (vs a plain color string). */
12
+ export declare function isGradient(p: Paint | undefined): p is Gradient;
13
+ /** A linear gradient. `angle` in degrees: 0 = left→right, 90 = top→bottom. */
14
+ export declare function linearGradient(stops: (string | ColorStop)[], opts?: {
15
+ angle?: number;
16
+ }): Gradient;
17
+ /** A radial gradient. `cx/cy` centre (0..1 of the box, default 0.5), `r` radius (0..1, default 0.5). */
18
+ export declare function radialGradient(stops: (string | ColorStop)[], opts?: {
19
+ cx?: number;
20
+ cy?: number;
21
+ r?: number;
22
+ }): Gradient;
23
+ /** A conic (angular sweep) gradient. `angle` start in degrees, `cx/cy` centre (0..1). */
24
+ export declare function conicGradient(stops: (string | ColorStop)[], opts?: {
25
+ angle?: number;
26
+ cx?: number;
27
+ cy?: number;
28
+ }): Gradient;
@@ -6,6 +6,8 @@ export { composeScene, formatComposeReport, type OverlayDoc, type ComposeReport,
6
6
  export { compileScene, type CompiledScene, type PropertySegment, type LabelSpan, type MotionDriver } from "./compile.js";
7
7
  export { pathPoint, pathTangentAngle, type Pt } from "./path.js";
8
8
  export { cameraTo, cameraMatrix, CAMERA_ID, CAMERA_PROPS } from "./camera.js";
9
+ export { linearGradient, radialGradient, conicGradient, isGradient } from "./gradient.js";
10
+ export { glow, dropShadow } from "./effects.js";
9
11
  export { motionPreset, PRESET_NAMES, type PresetName, type PresetRig, type PresetOpts } from "./presets.js";
10
12
  export { devicePreset, deviceScreen, deviceScreenCenter, deviceBounds, deviceScreenPoint, DEVICE_PRESET_NAMES, type DevicePresetName, type DevicePresetOpts } from "./devicePreset.js";
11
13
  export { cursor, cursorTo, cursorPath, cursorClick, cursorDouble, type CursorStyle, type CursorOpts, type CursorToOpts, type CursorPathOpts, type CursorClickOpts } from "./cursor.js";
@@ -36,20 +36,60 @@ export interface BaseProps {
36
36
  * for HUD / titles / watermark layers. No-op when the scene has no camera.
37
37
  */
38
38
  fixed?: boolean;
39
+ /**
40
+ * Paint effects (animatable scalars, in screen pixels — not transformed by the
41
+ * node's rotation/scale or the camera, so a shadow keeps a consistent light
42
+ * 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).
44
+ */
45
+ blur?: number;
46
+ shadowColor?: string;
47
+ shadowBlur?: number;
48
+ shadowX?: number;
49
+ shadowY?: number;
50
+ }
51
+ /**
52
+ * A paint is a solid color string OR a gradient. Coordinates are normalized to the
53
+ * node's bounding box (0..1, SVG `objectBoundingBox` style) so a gradient is just an
54
+ * angle + stops, size-independent. Applied in node-local space, so animating the
55
+ * node's transform (rotation/scale) moves the gradient with it. Build with
56
+ * `linearGradient`/`radialGradient`/`conicGradient` (`gradient.ts`).
57
+ */
58
+ export interface ColorStop {
59
+ offset: number;
60
+ color: string;
39
61
  }
62
+ export type Gradient = {
63
+ kind: "linear";
64
+ angle?: number;
65
+ stops: ColorStop[];
66
+ } | {
67
+ kind: "radial";
68
+ cx?: number;
69
+ cy?: number;
70
+ r?: number;
71
+ stops: ColorStop[];
72
+ } | {
73
+ kind: "conic";
74
+ angle?: number;
75
+ cx?: number;
76
+ cy?: number;
77
+ stops: ColorStop[];
78
+ };
79
+ export type Paint = string | Gradient;
40
80
  export interface RectProps extends BaseProps {
41
81
  width: number;
42
82
  height: number;
43
- fill?: string;
44
- stroke?: string;
83
+ fill?: Paint;
84
+ stroke?: Paint;
45
85
  strokeWidth?: number;
46
86
  radius?: number;
47
87
  }
48
88
  export interface EllipseProps extends BaseProps {
49
89
  width: number;
50
90
  height: number;
51
- fill?: string;
52
- stroke?: string;
91
+ fill?: Paint;
92
+ stroke?: Paint;
53
93
  strokeWidth?: number;
54
94
  }
55
95
  export interface LineProps {
@@ -64,6 +104,12 @@ export interface LineProps {
64
104
  progress?: number;
65
105
  /** Pin to the screen so the scene `camera` does not move it (top-level only). */
66
106
  fixed?: boolean;
107
+ /** Paint effects (px, screen-space) — see BaseProps. */
108
+ blur?: number;
109
+ shadowColor?: string;
110
+ shadowBlur?: number;
111
+ shadowX?: number;
112
+ shadowY?: number;
67
113
  }
68
114
  export interface TextProps extends BaseProps {
69
115
  /** Numbers interpolate (count-up) and render via toFixed(contentDecimals). */
@@ -104,8 +150,8 @@ export interface GroupProps extends BaseProps {
104
150
  export interface PathProps extends BaseProps {
105
151
  /** SVG path data (the `d` attribute). Drawn as a true vector — crisp at any zoom. */
106
152
  d: string;
107
- fill?: string;
108
- stroke?: string;
153
+ fill?: Paint;
154
+ stroke?: Paint;
109
155
  strokeWidth?: number;
110
156
  /**
111
157
  * 0..1 — fraction of the OUTLINE drawn, for a self-drawing "draw-on" effect
@@ -9,6 +9,12 @@
9
9
  * spacing ever overshoots.
10
10
  */
11
11
  export type Pt = [number, number];
12
+ /**
13
+ * A loose bounding box `[x, y, w, h]` from a path `d`'s coordinate extents — used
14
+ * only to map a gradient across the shape. Exact for M/L/C/Q/S/T paths (every
15
+ * number is an x/y coord, control points included); loose for the rare H/V/A.
16
+ */
17
+ export declare function pathBBox(d: string): [number, number, number, number];
12
18
  /**
13
19
  * Position on the spline at progress u in [0,1]. `curviness` scales the
14
20
  * Catmull-Rom tangents (GSAP's idea): 1 = standard smooth (the default and the
@@ -150,6 +150,50 @@ scene({
150
150
 
151
151
  See `examples/scenes/camera-demo.ts`.
152
152
 
153
+ ## Gradients (fill / stroke)
154
+
155
+ `fill` and `stroke` on **rect / ellipse / path** accept a gradient as well as a
156
+ color string. Coordinates are normalized to the node's bounding box (0..1), so a
157
+ gradient is just an angle + stops:
158
+
159
+ ```ts
160
+ rect({ id: "card", x, y, width: 300, height: 300, anchor: "center",
161
+ fill: linearGradient(["#FF5C3A", "#FFC24B"], { angle: 60 }) }) // 0=L→R, 90=T→B
162
+ ellipse({ id: "orb", /* … */ fill: radialGradient(["#9B7CFF", "#221A4A"], { cx: 0.4, cy: 0.4, r: 0.6 }) })
163
+ path({ id: "star", d, fill: conicGradient(["#00C2A8", "#3AA0FF", "#7C5CFF", "#00C2A8"], { angle: -90 }) })
164
+ ellipse({ id: "ring", /* … */ fill: "none", stroke: linearGradient(["#3AA0FF", "#46E5A0"]), strokeWidth: 10 })
165
+ ```
166
+
167
+ - `linearGradient(stops, { angle })`, `radialGradient(stops, { cx, cy, r })`,
168
+ `conicGradient(stops, { angle, cx, cy })`. `stops` is a color array (even offsets)
169
+ or `[{ offset, color }]`. `cx/cy/r` are 0..1 of the box (centre defaults to 0.5).
170
+ - **Gradients are static** (not keyframed). The gradient lives in the node's local
171
+ space, so **animate the NODE** (`tween(id, { rotation: 360 })`, scale, move) and the
172
+ gradient sweeps/stretches with it. Color-string fills still tween as today.
173
+ - text fill and line stroke are color-only for now. See `examples/scenes/gradient-demo.ts`.
174
+
175
+ ## Shadow, glow & blur
176
+
177
+ Drawable nodes (rect / ellipse / path / text / image / line) take animatable paint
178
+ effects, in **screen pixels** (not transformed by the node or camera, so a shadow
179
+ keeps a consistent light direction):
180
+
181
+ ```ts
182
+ rect({ id: "card", /* … */, ...dropShadow("#000000", 64, 0, 34) }) // drop shadow
183
+ ellipse({ id: "orb", /* … */, fill: radialGradient([...]), shadowColor: "#FFC24B", shadowBlur: 22 })
184
+ oscillate("orb", "shadowBlur", { amplitude: 16, frequency: 0.9 }) // PULSING glow
185
+ rect({ id: "card", /* … */, blur: 18 }); tween("card", { blur: 0 }, { duration: 1 }) // focus pull
186
+ ```
187
+
188
+ - Props: `blur` (gaussian blur of the shape), `shadowColor` (turns the shadow/glow
189
+ on), `shadowBlur`, `shadowX`, `shadowY`. All **animatable** — `tween`/`oscillate`
190
+ them for pulsing glows, focus pulls, etc. (set a base value first so there's
191
+ something to animate from).
192
+ - Sugar: `glow(color, blur)` (offset 0) and `dropShadow(color, blur, x, y)` return
193
+ a partial you spread into props (`...glow("#FFD24B", 28)`); still animatable.
194
+ - No-op on a `group` (apply to a child; group/composite blur is a later add). See
195
+ `examples/scenes/shadow-demo.ts`.
196
+
153
197
  ## Character rig (skeleton, poses, IK)
154
198
 
155
199
  A first-class, declarative character rig that **compiles to plain IR** (nested
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reframe-video",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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",