reframe-video 0.6.0 → 0.6.1

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,34 @@ 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);
358
380
  if (node.type === "group") {
359
381
  const clip = node.props.clip;
360
382
  if (clip) {
@@ -899,6 +921,13 @@ var init_camera = __esm({
899
921
  }
900
922
  });
901
923
 
924
+ // ../core/src/gradient.ts
925
+ var init_gradient = __esm({
926
+ "../core/src/gradient.ts"() {
927
+ "use strict";
928
+ }
929
+ });
930
+
902
931
  // ../core/src/presets.ts
903
932
  function makeRng(seed) {
904
933
  let a = seed >>> 0 || 2654435769;
@@ -1292,6 +1321,7 @@ var init_evaluate = __esm({
1292
1321
  "use strict";
1293
1322
  init_behaviors();
1294
1323
  init_camera();
1324
+ init_gradient();
1295
1325
  init_interpolate();
1296
1326
  init_path();
1297
1327
  }
@@ -1354,6 +1384,7 @@ var init_src = __esm({
1354
1384
  init_compile();
1355
1385
  init_path();
1356
1386
  init_camera();
1387
+ init_gradient();
1357
1388
  init_presets();
1358
1389
  init_devicePreset();
1359
1390
  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));
@@ -344,6 +360,11 @@
344
360
  return [a, b, c, d, W / 2 - a * x - c * y, H / 2 - b * x - d * y];
345
361
  }
346
362
 
363
+ // ../core/src/gradient.ts
364
+ function isGradient(p) {
365
+ return typeof p === "object" && p !== null && (p.kind === "linear" || p.kind === "radial" || p.kind === "conic");
366
+ }
367
+
347
368
  // ../core/src/presets.ts
348
369
  var SET = 1 / 120;
349
370
 
@@ -709,8 +730,10 @@
709
730
  const height = num(id, "height", node.props.height);
710
731
  const [ax, ay] = ANCHOR_FACTORS[node.props.anchor ?? "top-left"];
711
732
  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);
733
+ const fillP = node.props.fill;
734
+ const strokeP = node.props.stroke;
735
+ const fill = isGradient(fillP) ? fillP : opt(id, "fill", fillP);
736
+ const stroke = isGradient(strokeP) ? strokeP : opt(id, "stroke", strokeP);
714
737
  ops.push({
715
738
  type: node.type,
716
739
  id,
@@ -748,17 +771,22 @@
748
771
  case "path": {
749
772
  const ox = num(id, "originX", node.props.originX ?? 0);
750
773
  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);
774
+ const fillP = node.props.fill;
775
+ const strokeP = node.props.stroke;
776
+ const fill = isGradient(fillP) ? fillP : opt(id, "fill", fillP);
777
+ const stroke = isGradient(strokeP) ? strokeP : opt(id, "stroke", strokeP);
778
+ const dStr = str(id, "d", node.props.d);
779
+ const needsBox = isGradient(fill) || isGradient(stroke);
753
780
  ops.push({
754
781
  type: "path",
755
782
  id,
756
783
  transform: ox === 0 && oy === 0 ? matrix : multiply(matrix, [1, 0, 0, 1, -ox, -oy]),
757
784
  opacity,
758
- d: str(id, "d", node.props.d),
785
+ d: dStr,
759
786
  progress: Math.max(0, Math.min(1, num(id, "progress", node.props.progress ?? 1))),
760
787
  ...fill !== void 0 && { fill },
761
788
  ...stroke !== void 0 && { stroke, strokeWidth: num(id, "strokeWidth", node.props.strokeWidth ?? 1) },
789
+ ...needsBox && { bbox: pathBBox(dStr) },
762
790
  ...clipSpread
763
791
  });
764
792
  return;
@@ -806,6 +834,31 @@
806
834
  }
807
835
 
808
836
  // ../renderer-canvas/src/index.ts
837
+ function resolvePaint(ctx2, paint, box) {
838
+ if (typeof paint === "string") return paint;
839
+ const { x, y, w, h } = box;
840
+ let g;
841
+ if (paint.kind === "linear") {
842
+ const a = (paint.angle ?? 0) * Math.PI / 180;
843
+ const dx = Math.cos(a);
844
+ const dy = Math.sin(a);
845
+ const cx = x + w / 2;
846
+ const cy = y + h / 2;
847
+ const half = Math.abs(dx) * (w / 2) + Math.abs(dy) * (h / 2);
848
+ g = ctx2.createLinearGradient(cx - dx * half, cy - dy * half, cx + dx * half, cy + dy * half);
849
+ } else if (paint.kind === "radial") {
850
+ const cx = x + (paint.cx ?? 0.5) * w;
851
+ const cy = y + (paint.cy ?? 0.5) * h;
852
+ const r = Math.max((paint.r ?? 0.5) * Math.max(w, h), 1e-4);
853
+ g = ctx2.createRadialGradient(cx, cy, 0, cx, cy, r);
854
+ } else {
855
+ const cx = x + (paint.cx ?? 0.5) * w;
856
+ const cy = y + (paint.cy ?? 0.5) * h;
857
+ g = ctx2.createConicGradient((paint.angle ?? 0) * Math.PI / 180, cx, cy);
858
+ }
859
+ for (const s of paint.stops) g.addColorStop(Math.max(0, Math.min(1, s.offset)), s.color);
860
+ return g;
861
+ }
809
862
  function renderFrame(ctx2, compiled2, t, images2) {
810
863
  const { size, background } = compiled2.ir;
811
864
  ctx2.setTransform(1, 0, 0, 1, 0, 0);
@@ -838,6 +891,7 @@
838
891
  ctx2.globalAlpha = Math.max(0, Math.min(1, op.opacity));
839
892
  switch (op.type) {
840
893
  case "rect": {
894
+ const box = { x: op.offsetX, y: op.offsetY, w: op.width, h: op.height };
841
895
  ctx2.beginPath();
842
896
  if (op.radius && op.radius > 0) {
843
897
  ctx2.roundRect(op.offsetX, op.offsetY, op.width, op.height, op.radius);
@@ -845,17 +899,18 @@
845
899
  ctx2.rect(op.offsetX, op.offsetY, op.width, op.height);
846
900
  }
847
901
  if (op.fill) {
848
- ctx2.fillStyle = op.fill;
902
+ ctx2.fillStyle = resolvePaint(ctx2, op.fill, box);
849
903
  ctx2.fill();
850
904
  }
851
905
  if (op.stroke) {
852
- ctx2.strokeStyle = op.stroke;
906
+ ctx2.strokeStyle = resolvePaint(ctx2, op.stroke, box);
853
907
  ctx2.lineWidth = op.strokeWidth ?? 1;
854
908
  ctx2.stroke();
855
909
  }
856
910
  break;
857
911
  }
858
912
  case "ellipse": {
913
+ const box = { x: op.offsetX, y: op.offsetY, w: op.width, h: op.height };
859
914
  ctx2.beginPath();
860
915
  ctx2.ellipse(
861
916
  op.offsetX + op.width / 2,
@@ -867,11 +922,11 @@
867
922
  Math.PI * 2
868
923
  );
869
924
  if (op.fill) {
870
- ctx2.fillStyle = op.fill;
925
+ ctx2.fillStyle = resolvePaint(ctx2, op.fill, box);
871
926
  ctx2.fill();
872
927
  }
873
928
  if (op.stroke) {
874
- ctx2.strokeStyle = op.stroke;
929
+ ctx2.strokeStyle = resolvePaint(ctx2, op.stroke, box);
875
930
  ctx2.lineWidth = op.strokeWidth ?? 1;
876
931
  ctx2.stroke();
877
932
  }
@@ -908,12 +963,13 @@
908
963
  }
909
964
  case "path": {
910
965
  const p = new Path2D(op.d);
966
+ 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
967
  if (op.fill) {
912
- ctx2.fillStyle = op.fill;
968
+ ctx2.fillStyle = resolvePaint(ctx2, op.fill, box);
913
969
  ctx2.fill(p);
914
970
  }
915
971
  if (op.stroke && (op.strokeWidth ?? 1) > 0) {
916
- ctx2.strokeStyle = op.stroke;
972
+ ctx2.strokeStyle = resolvePaint(ctx2, op.stroke, box);
917
973
  ctx2.lineWidth = op.strokeWidth ?? 1;
918
974
  ctx2.lineJoin = "round";
919
975
  ctx2.lineCap = "round";
package/dist/cli.js CHANGED
@@ -347,12 +347,34 @@ ${problems.map((p) => ` - ${p}`).join("\n")}`);
347
347
  function validateScene(ir) {
348
348
  const problems = [];
349
349
  const nodeById = /* @__PURE__ */ new Map();
350
+ const checkPaint = (where, value) => {
351
+ if (typeof value !== "object" || value === null) return;
352
+ const g = value;
353
+ if (g.kind !== "linear" && g.kind !== "radial" && g.kind !== "conic") {
354
+ problems.push(`${where}: a paint object must be a gradient with kind "linear" / "radial" / "conic"`);
355
+ return;
356
+ }
357
+ if (!Array.isArray(g.stops) || g.stops.length === 0) {
358
+ problems.push(`${where}: gradient "${g.kind}" needs at least one color stop`);
359
+ return;
360
+ }
361
+ g.stops.forEach((s, i) => {
362
+ const st = s;
363
+ if (typeof st?.color !== "string") problems.push(`${where}: gradient stop ${i} needs a color string`);
364
+ if (typeof st?.offset !== "number" || st.offset < 0 || st.offset > 1) {
365
+ problems.push(`${where}: gradient stop ${i} "offset" must be a number in 0..1`);
366
+ }
367
+ });
368
+ };
350
369
  const collect = (nodes) => {
351
370
  for (const node of nodes) {
352
371
  if (nodeById.has(node.id)) {
353
372
  problems.push(`duplicate node id "${node.id}" \u2014 every node id must be unique`);
354
373
  }
355
374
  nodeById.set(node.id, node);
375
+ const props = node.props;
376
+ checkPaint(`node "${node.id}" fill`, props.fill);
377
+ checkPaint(`node "${node.id}" stroke`, props.stroke);
356
378
  if (node.type === "group") {
357
379
  const clip = node.props.clip;
358
380
  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));
@@ -341,12 +357,34 @@ ${problems.map((p) => ` - ${p}`).join("\n")}`);
341
357
  function validateScene(ir) {
342
358
  const problems = [];
343
359
  const nodeById = /* @__PURE__ */ new Map();
360
+ const checkPaint = (where, value) => {
361
+ if (typeof value !== "object" || value === null) return;
362
+ const g = value;
363
+ if (g.kind !== "linear" && g.kind !== "radial" && g.kind !== "conic") {
364
+ problems.push(`${where}: a paint object must be a gradient with kind "linear" / "radial" / "conic"`);
365
+ return;
366
+ }
367
+ if (!Array.isArray(g.stops) || g.stops.length === 0) {
368
+ problems.push(`${where}: gradient "${g.kind}" needs at least one color stop`);
369
+ return;
370
+ }
371
+ g.stops.forEach((s, i) => {
372
+ const st = s;
373
+ if (typeof st?.color !== "string") problems.push(`${where}: gradient stop ${i} needs a color string`);
374
+ if (typeof st?.offset !== "number" || st.offset < 0 || st.offset > 1) {
375
+ problems.push(`${where}: gradient stop ${i} "offset" must be a number in 0..1`);
376
+ }
377
+ });
378
+ };
344
379
  const collect = (nodes) => {
345
380
  for (const node of nodes) {
346
381
  if (nodeById.has(node.id)) {
347
382
  problems.push(`duplicate node id "${node.id}" \u2014 every node id must be unique`);
348
383
  }
349
384
  nodeById.set(node.id, node);
385
+ const props = node.props;
386
+ checkPaint(`node "${node.id}" fill`, props.fill);
387
+ checkPaint(`node "${node.id}" stroke`, props.stroke);
350
388
  if (node.type === "group") {
351
389
  const clip = node.props.clip;
352
390
  if (clip) {
@@ -917,6 +955,38 @@ function cameraTo(props, opts = {}) {
917
955
  return tween(CAMERA_ID, props, opts);
918
956
  }
919
957
 
958
+ // ../core/src/gradient.ts
959
+ function isGradient(p) {
960
+ return typeof p === "object" && p !== null && (p.kind === "linear" || p.kind === "radial" || p.kind === "conic");
961
+ }
962
+ function toStops(stops) {
963
+ if (stops.length > 0 && typeof stops[0] === "object") return stops;
964
+ const cs = stops;
965
+ const n3 = cs.length;
966
+ return cs.map((color, i) => ({ offset: n3 <= 1 ? 0 : i / (n3 - 1), color }));
967
+ }
968
+ function linearGradient(stops, opts = {}) {
969
+ return { kind: "linear", ...opts.angle !== void 0 && { angle: opts.angle }, stops: toStops(stops) };
970
+ }
971
+ function radialGradient(stops, opts = {}) {
972
+ return {
973
+ kind: "radial",
974
+ ...opts.cx !== void 0 && { cx: opts.cx },
975
+ ...opts.cy !== void 0 && { cy: opts.cy },
976
+ ...opts.r !== void 0 && { r: opts.r },
977
+ stops: toStops(stops)
978
+ };
979
+ }
980
+ function conicGradient(stops, opts = {}) {
981
+ return {
982
+ kind: "conic",
983
+ ...opts.angle !== void 0 && { angle: opts.angle },
984
+ ...opts.cx !== void 0 && { cx: opts.cx },
985
+ ...opts.cy !== void 0 && { cy: opts.cy },
986
+ stops: toStops(stops)
987
+ };
988
+ }
989
+
920
990
  // ../core/src/presets.ts
921
991
  var PRESET_NAMES = [
922
992
  "draw-bloom",
@@ -2919,8 +2989,10 @@ function evaluate(compiled, t) {
2919
2989
  const height = num(id, "height", node.props.height);
2920
2990
  const [ax, ay] = ANCHOR_FACTORS[node.props.anchor ?? "top-left"];
2921
2991
  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);
2992
+ const fillP = node.props.fill;
2993
+ const strokeP = node.props.stroke;
2994
+ const fill = isGradient(fillP) ? fillP : opt(id, "fill", fillP);
2995
+ const stroke = isGradient(strokeP) ? strokeP : opt(id, "stroke", strokeP);
2924
2996
  ops.push({
2925
2997
  type: node.type,
2926
2998
  id,
@@ -2958,17 +3030,22 @@ function evaluate(compiled, t) {
2958
3030
  case "path": {
2959
3031
  const ox = num(id, "originX", node.props.originX ?? 0);
2960
3032
  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);
3033
+ const fillP = node.props.fill;
3034
+ const strokeP = node.props.stroke;
3035
+ const fill = isGradient(fillP) ? fillP : opt(id, "fill", fillP);
3036
+ const stroke = isGradient(strokeP) ? strokeP : opt(id, "stroke", strokeP);
3037
+ const dStr = str(id, "d", node.props.d);
3038
+ const needsBox = isGradient(fill) || isGradient(stroke);
2963
3039
  ops.push({
2964
3040
  type: "path",
2965
3041
  id,
2966
3042
  transform: ox === 0 && oy === 0 ? matrix : multiply(matrix, [1, 0, 0, 1, -ox, -oy]),
2967
3043
  opacity,
2968
- d: str(id, "d", node.props.d),
3044
+ d: dStr,
2969
3045
  progress: Math.max(0, Math.min(1, num(id, "progress", node.props.progress ?? 1))),
2970
3046
  ...fill !== void 0 && { fill },
2971
3047
  ...stroke !== void 0 && { stroke, strokeWidth: num(id, "strokeWidth", node.props.strokeWidth ?? 1) },
3048
+ ...needsBox && { bbox: pathBBox(dStr) },
2972
3049
  ...clipSpread
2973
3050
  });
2974
3051
  return;
@@ -3116,6 +3193,7 @@ export {
3116
3193
  compileScene,
3117
3194
  composeScene,
3118
3195
  composition,
3196
+ conicGradient,
3119
3197
  cursor,
3120
3198
  cursorClick,
3121
3199
  cursorDouble,
@@ -3135,8 +3213,10 @@ export {
3135
3213
  ikReach,
3136
3214
  image,
3137
3215
  isColor,
3216
+ isGradient,
3138
3217
  lerpValue,
3139
3218
  line,
3219
+ linearGradient,
3140
3220
  motionOp,
3141
3221
  motionOpLabel,
3142
3222
  motionPath,
@@ -3149,6 +3229,7 @@ export {
3149
3229
  pathPoint,
3150
3230
  pathTangentAngle,
3151
3231
  poseTo,
3232
+ radialGradient,
3152
3233
  rect,
3153
3234
  resolveAudioPlan,
3154
3235
  resolveCompositionAudioPlan,
package/dist/labels.js CHANGED
@@ -341,12 +341,34 @@ ${problems.map((p) => ` - ${p}`).join("\n")}`);
341
341
  function validateScene(ir) {
342
342
  const problems = [];
343
343
  const nodeById = /* @__PURE__ */ new Map();
344
+ const checkPaint = (where, value) => {
345
+ if (typeof value !== "object" || value === null) return;
346
+ const g = value;
347
+ if (g.kind !== "linear" && g.kind !== "radial" && g.kind !== "conic") {
348
+ problems.push(`${where}: a paint object must be a gradient with kind "linear" / "radial" / "conic"`);
349
+ return;
350
+ }
351
+ if (!Array.isArray(g.stops) || g.stops.length === 0) {
352
+ problems.push(`${where}: gradient "${g.kind}" needs at least one color stop`);
353
+ return;
354
+ }
355
+ g.stops.forEach((s, i) => {
356
+ const st = s;
357
+ if (typeof st?.color !== "string") problems.push(`${where}: gradient stop ${i} needs a color string`);
358
+ if (typeof st?.offset !== "number" || st.offset < 0 || st.offset > 1) {
359
+ problems.push(`${where}: gradient stop ${i} "offset" must be a number in 0..1`);
360
+ }
361
+ });
362
+ };
344
363
  const collect = (nodes) => {
345
364
  for (const node of nodes) {
346
365
  if (nodeById.has(node.id)) {
347
366
  problems.push(`duplicate node id "${node.id}" \u2014 every node id must be unique`);
348
367
  }
349
368
  nodeById.set(node.id, node);
369
+ const props = node.props;
370
+ checkPaint(`node "${node.id}" fill`, props.fill);
371
+ checkPaint(`node "${node.id}" stroke`, props.stroke);
350
372
  if (node.type === "group") {
351
373
  const clip = node.props.clip;
352
374
  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);
@@ -32,6 +57,7 @@ function drawDisplayList(ctx, ops, images) {
32
57
  ctx.globalAlpha = Math.max(0, Math.min(1, op.opacity));
33
58
  switch (op.type) {
34
59
  case "rect": {
60
+ const box = { x: op.offsetX, y: op.offsetY, w: op.width, h: op.height };
35
61
  ctx.beginPath();
36
62
  if (op.radius && op.radius > 0) {
37
63
  ctx.roundRect(op.offsetX, op.offsetY, op.width, op.height, op.radius);
@@ -39,17 +65,18 @@ function drawDisplayList(ctx, ops, images) {
39
65
  ctx.rect(op.offsetX, op.offsetY, op.width, op.height);
40
66
  }
41
67
  if (op.fill) {
42
- ctx.fillStyle = op.fill;
68
+ ctx.fillStyle = resolvePaint(ctx, op.fill, box);
43
69
  ctx.fill();
44
70
  }
45
71
  if (op.stroke) {
46
- ctx.strokeStyle = op.stroke;
72
+ ctx.strokeStyle = resolvePaint(ctx, op.stroke, box);
47
73
  ctx.lineWidth = op.strokeWidth ?? 1;
48
74
  ctx.stroke();
49
75
  }
50
76
  break;
51
77
  }
52
78
  case "ellipse": {
79
+ const box = { x: op.offsetX, y: op.offsetY, w: op.width, h: op.height };
53
80
  ctx.beginPath();
54
81
  ctx.ellipse(
55
82
  op.offsetX + op.width / 2,
@@ -61,11 +88,11 @@ function drawDisplayList(ctx, ops, images) {
61
88
  Math.PI * 2
62
89
  );
63
90
  if (op.fill) {
64
- ctx.fillStyle = op.fill;
91
+ ctx.fillStyle = resolvePaint(ctx, op.fill, box);
65
92
  ctx.fill();
66
93
  }
67
94
  if (op.stroke) {
68
- ctx.strokeStyle = op.stroke;
95
+ ctx.strokeStyle = resolvePaint(ctx, op.stroke, box);
69
96
  ctx.lineWidth = op.strokeWidth ?? 1;
70
97
  ctx.stroke();
71
98
  }
@@ -102,12 +129,13 @@ function drawDisplayList(ctx, ops, images) {
102
129
  }
103
130
  case "path": {
104
131
  const p = new Path2D(op.d);
132
+ 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
133
  if (op.fill) {
106
- ctx.fillStyle = op.fill;
134
+ ctx.fillStyle = resolvePaint(ctx, op.fill, box);
107
135
  ctx.fill(p);
108
136
  }
109
137
  if (op.stroke && (op.strokeWidth ?? 1) > 0) {
110
- ctx.strokeStyle = op.stroke;
138
+ ctx.strokeStyle = resolvePaint(ctx, op.stroke, box);
111
139
  ctx.lineWidth = op.strokeWidth ?? 1;
112
140
  ctx.lineJoin = "round";
113
141
  ctx.lineCap = "round";
@@ -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,
@@ -31,8 +31,8 @@ export type DisplayOp = (OpBase & {
31
31
  height: number;
32
32
  offsetX: number;
33
33
  offsetY: number;
34
- fill?: string;
35
- stroke?: string;
34
+ fill?: Paint;
35
+ stroke?: Paint;
36
36
  strokeWidth?: number;
37
37
  radius?: number;
38
38
  }) | (OpBase & {
@@ -41,8 +41,8 @@ export type DisplayOp = (OpBase & {
41
41
  height: number;
42
42
  offsetX: number;
43
43
  offsetY: number;
44
- fill?: string;
45
- stroke?: string;
44
+ fill?: Paint;
45
+ stroke?: Paint;
46
46
  strokeWidth?: number;
47
47
  }) | (OpBase & {
48
48
  type: "line";
@@ -76,9 +76,11 @@ export type DisplayOp = (OpBase & {
76
76
  d: string;
77
77
  /** 0..1 fraction of the stroke outline drawn (draw-on). */
78
78
  progress: number;
79
- fill?: string;
80
- stroke?: string;
79
+ fill?: Paint;
80
+ stroke?: Paint;
81
81
  strokeWidth?: number;
82
+ /** Local-space bbox [x,y,w,h] for mapping a gradient paint (set only when one is used). */
83
+ bbox?: [number, number, number, number];
82
84
  });
83
85
  export type DisplayList = DisplayOp[];
84
86
  /**
@@ -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,7 @@ 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";
9
10
  export { motionPreset, PRESET_NAMES, type PresetName, type PresetRig, type PresetOpts } from "./presets.js";
10
11
  export { devicePreset, deviceScreen, deviceScreenCenter, deviceBounds, deviceScreenPoint, DEVICE_PRESET_NAMES, type DevicePresetName, type DevicePresetOpts } from "./devicePreset.js";
11
12
  export { cursor, cursorTo, cursorPath, cursorClick, cursorDouble, type CursorStyle, type CursorOpts, type CursorToOpts, type CursorPathOpts, type CursorClickOpts } from "./cursor.js";
@@ -37,19 +37,48 @@ export interface BaseProps {
37
37
  */
38
38
  fixed?: boolean;
39
39
  }
40
+ /**
41
+ * A paint is a solid color string OR a gradient. Coordinates are normalized to the
42
+ * node's bounding box (0..1, SVG `objectBoundingBox` style) so a gradient is just an
43
+ * angle + stops, size-independent. Applied in node-local space, so animating the
44
+ * node's transform (rotation/scale) moves the gradient with it. Build with
45
+ * `linearGradient`/`radialGradient`/`conicGradient` (`gradient.ts`).
46
+ */
47
+ export interface ColorStop {
48
+ offset: number;
49
+ color: string;
50
+ }
51
+ export type Gradient = {
52
+ kind: "linear";
53
+ angle?: number;
54
+ stops: ColorStop[];
55
+ } | {
56
+ kind: "radial";
57
+ cx?: number;
58
+ cy?: number;
59
+ r?: number;
60
+ stops: ColorStop[];
61
+ } | {
62
+ kind: "conic";
63
+ angle?: number;
64
+ cx?: number;
65
+ cy?: number;
66
+ stops: ColorStop[];
67
+ };
68
+ export type Paint = string | Gradient;
40
69
  export interface RectProps extends BaseProps {
41
70
  width: number;
42
71
  height: number;
43
- fill?: string;
44
- stroke?: string;
72
+ fill?: Paint;
73
+ stroke?: Paint;
45
74
  strokeWidth?: number;
46
75
  radius?: number;
47
76
  }
48
77
  export interface EllipseProps extends BaseProps {
49
78
  width: number;
50
79
  height: number;
51
- fill?: string;
52
- stroke?: string;
80
+ fill?: Paint;
81
+ stroke?: Paint;
53
82
  strokeWidth?: number;
54
83
  }
55
84
  export interface LineProps {
@@ -104,8 +133,8 @@ export interface GroupProps extends BaseProps {
104
133
  export interface PathProps extends BaseProps {
105
134
  /** SVG path data (the `d` attribute). Drawn as a true vector — crisp at any zoom. */
106
135
  d: string;
107
- fill?: string;
108
- stroke?: string;
136
+ fill?: Paint;
137
+ stroke?: Paint;
109
138
  strokeWidth?: number;
110
139
  /**
111
140
  * 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,28 @@ 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
+
153
175
  ## Character rig (skeleton, poses, IK)
154
176
 
155
177
  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.1",
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",