reframe-video 0.5.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
@@ -153,6 +153,14 @@ function compileScene(ir) {
153
153
  }
154
154
  }
155
155
  }
156
+ const cameraIsNode = nodeById.has("camera");
157
+ if (!cameraIsNode) {
158
+ const cam = ir.camera ?? {};
159
+ initialValues.set(key("camera", "x"), cam.x ?? ir.size.width / 2);
160
+ initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
161
+ initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
162
+ initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
163
+ }
156
164
  const segments = /* @__PURE__ */ new Map();
157
165
  const motionPaths = /* @__PURE__ */ new Map();
158
166
  const current = new Map(initialValues);
@@ -313,6 +321,7 @@ function compileScene(ir) {
313
321
  const inferredEnd = ir.timeline ? walk(ir.timeline, 0) : 0;
314
322
  for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
315
323
  for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
324
+ const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
316
325
  return {
317
326
  ir,
318
327
  duration: ir.duration ?? inferredEnd,
@@ -322,7 +331,8 @@ function compileScene(ir) {
322
331
  nodeById,
323
332
  nodeOrder,
324
333
  labelTimes,
325
- beatTimes
334
+ beatTimes,
335
+ hasCamera
326
336
  };
327
337
  }
328
338
  var key;
@@ -339,12 +349,34 @@ var init_compile = __esm({
339
349
  function validateScene(ir) {
340
350
  const problems = [];
341
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
+ };
342
371
  const collect = (nodes) => {
343
372
  for (const node of nodes) {
344
373
  if (nodeById.has(node.id)) {
345
374
  problems.push(`duplicate node id "${node.id}" \u2014 every node id must be unique`);
346
375
  }
347
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);
348
380
  if (node.type === "group") {
349
381
  const clip = node.props.clip;
350
382
  if (clip) {
@@ -361,6 +393,14 @@ function validateScene(ir) {
361
393
  };
362
394
  collect(ir.nodes);
363
395
  const checkProps = (where, nodeId, props) => {
396
+ if (nodeId === "camera" && !nodeById.has("camera")) {
397
+ for (const key2 of Object.keys(props)) {
398
+ if (!CAMERA_PROPS.includes(key2)) {
399
+ problems.push(`${where}: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
400
+ }
401
+ }
402
+ return;
403
+ }
364
404
  const node = nodeById.get(nodeId);
365
405
  if (!node) {
366
406
  problems.push(
@@ -428,12 +468,15 @@ function validateScene(ir) {
428
468
  break;
429
469
  case "motionPath": {
430
470
  const node = nodeById.get(tl.target);
431
- if (!node) {
432
- problems.push(
433
- `${path2}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
434
- );
435
- } else if (node.type === "line") {
436
- problems.push(`${path2}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
471
+ const isSceneCamera = tl.target === "camera" && !node;
472
+ if (!isSceneCamera) {
473
+ if (!node) {
474
+ problems.push(
475
+ `${path2}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
476
+ );
477
+ } else if (node.type === "line") {
478
+ problems.push(`${path2}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
479
+ }
437
480
  }
438
481
  if (tl.points.length < 1) problems.push(`${path2}: motionPath "${tl.target}" needs at least 1 point`);
439
482
  if (tl.duration !== void 0 && tl.duration <= 0) {
@@ -478,6 +521,18 @@ function validateScene(ir) {
478
521
  if (ir.duration !== void 0 && ir.duration <= 0) {
479
522
  problems.push("scene duration must be > 0");
480
523
  }
524
+ if (ir.camera) {
525
+ if (nodeById.has("camera")) {
526
+ problems.push(`camera: a node is already named "camera" \u2014 rename that node or drop the scene camera (the id "camera" can't be both)`);
527
+ }
528
+ for (const [key2, value] of Object.entries(ir.camera)) {
529
+ if (!CAMERA_PROPS.includes(key2)) {
530
+ problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
531
+ } else if (typeof value !== "number") {
532
+ problems.push(`camera.${key2} must be a number`);
533
+ }
534
+ }
535
+ }
481
536
  const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
482
537
  for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
483
538
  if (typeof cue.at === "string" && !labels.has(cue.at)) {
@@ -536,16 +591,17 @@ function validateComposition(comp) {
536
591
  }
537
592
  if (problems.length > 0) throw new SceneValidationError(problems);
538
593
  }
539
- var COMMON_PROPS, PROPS_BY_TYPE, SceneValidationError, TRANSITIONS;
594
+ var COMMON_PROPS, CAMERA_PROPS, PROPS_BY_TYPE, SceneValidationError, TRANSITIONS;
540
595
  var init_validate = __esm({
541
596
  "../core/src/validate.ts"() {
542
597
  "use strict";
543
- COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor"];
598
+ COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
599
+ CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
544
600
  PROPS_BY_TYPE = {
545
601
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
546
602
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
547
603
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
548
- text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
604
+ text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
549
605
  image: [...COMMON_PROPS, "src", "width", "height"],
550
606
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
551
607
  group: COMMON_PROPS
@@ -857,6 +913,21 @@ var init_compose = __esm({
857
913
  }
858
914
  });
859
915
 
916
+ // ../core/src/camera.ts
917
+ var init_camera = __esm({
918
+ "../core/src/camera.ts"() {
919
+ "use strict";
920
+ init_dsl();
921
+ }
922
+ });
923
+
924
+ // ../core/src/gradient.ts
925
+ var init_gradient = __esm({
926
+ "../core/src/gradient.ts"() {
927
+ "use strict";
928
+ }
929
+ });
930
+
860
931
  // ../core/src/presets.ts
861
932
  function makeRng(seed) {
862
933
  let a = seed >>> 0 || 2654435769;
@@ -1249,6 +1320,8 @@ var init_evaluate = __esm({
1249
1320
  "../core/src/evaluate.ts"() {
1250
1321
  "use strict";
1251
1322
  init_behaviors();
1323
+ init_camera();
1324
+ init_gradient();
1252
1325
  init_interpolate();
1253
1326
  init_path();
1254
1327
  }
@@ -1310,6 +1383,8 @@ var init_src = __esm({
1310
1383
  init_compose();
1311
1384
  init_compile();
1312
1385
  init_path();
1386
+ init_camera();
1387
+ init_gradient();
1313
1388
  init_presets();
1314
1389
  init_devicePreset();
1315
1390
  init_cursor();
@@ -2334,6 +2409,7 @@ var ROOT2 = PACKAGED ? resolve4(HERE2, "..") : resolve4(HERE2, "..", "..", "..")
2334
2409
  var USER_CWD = process.env.INIT_CWD ?? process.cwd();
2335
2410
  var RENDER_CLI = PACKAGED ? join6(ROOT2, "dist", "cli.js") : join6(ROOT2, "packages", "render-cli", "src", "cli.ts");
2336
2411
  var LABELS = PACKAGED ? join6(ROOT2, "dist", "labels.js") : join6(ROOT2, "packages", "render-cli", "src", "labels.ts");
2412
+ var PLAYER = PACKAGED ? join6(ROOT2, "dist", "player.js") : join6(ROOT2, "packages", "render-cli", "src", "player.ts");
2337
2413
  var ANALYZE = PACKAGED ? join6(ROOT2, "dist", "analyze.js") : join6(ROOT2, "benchmark", "harness", "motion", "analyze.ts");
2338
2414
  var TRACE = PACKAGED ? join6(ROOT2, "dist", "trace-cli.js") : join6(ROOT2, "benchmark", "harness", "motion", "trace-cli.ts");
2339
2415
  var CMD = PACKAGED ? "reframe" : "pnpm reframe";
@@ -2345,6 +2421,8 @@ usage:
2345
2421
  ${CMD} logo <logo.svg|brand-slug> ["Name"] [--motion <preset>] [--energy 0..1] [--seed N] [-o out.mp4]
2346
2422
  animate a logo into a sting (presets: draw-bloom, punch-in,
2347
2423
  rise-settle, slide-bank, reveal-orbit, spin-forge)
2424
+ ${CMD} player <scene.ts|.json> [-o out.html] bundle a scene into one self-contained HTML
2425
+ player (plays live in any browser or a Claude.ai artifact; visual only)
2348
2426
  ${CMD} preview open the scrub/edit UI (lists scenes in your directory)
2349
2427
  ${CMD} new <scene-name> scaffold <scene-name>.ts in your directory
2350
2428
  ${CMD} labels <scene.ts|.json> print the event clock (label \u2192 exact seconds; for sound design / timing)
@@ -2489,6 +2567,21 @@ ${USAGE}`);
2489
2567
  await (PACKAGED ? run(process.execPath, [LABELS, inputPath]) : run("npx", ["tsx", LABELS, inputPath]))
2490
2568
  );
2491
2569
  }
2570
+ case "player": {
2571
+ const input = rest[0];
2572
+ if (!input || input.startsWith("-")) fail(`player needs a scene file
2573
+
2574
+ ${USAGE}`);
2575
+ const inputPath = userPath(input);
2576
+ if (!existsSync4(inputPath)) fail(`no such file: ${inputPath}`);
2577
+ const oIdx = rest.indexOf("-o");
2578
+ const outBase = PACKAGED ? join6(USER_CWD, "out") : join6(ROOT2, "out");
2579
+ const outPath = oIdx >= 0 && rest[oIdx + 1] ? userPath(rest[oIdx + 1]) : join6(outBase, `${basename(input).replace(/\.[^.]+$/, "")}.html`);
2580
+ await mkdir4(dirname7(outPath), { recursive: true });
2581
+ process.exit(
2582
+ await (PACKAGED ? run(process.execPath, [PLAYER, inputPath, outPath]) : run("npx", ["tsx", PLAYER, inputPath, outPath]))
2583
+ );
2584
+ }
2492
2585
  case "logo": {
2493
2586
  const positional = [];
2494
2587
  const flags = {};
@@ -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));
@@ -134,6 +150,14 @@
134
150
  }
135
151
  }
136
152
  }
153
+ const cameraIsNode = nodeById.has("camera");
154
+ if (!cameraIsNode) {
155
+ const cam = ir.camera ?? {};
156
+ initialValues.set(key("camera", "x"), cam.x ?? ir.size.width / 2);
157
+ initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
158
+ initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
159
+ initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
160
+ }
137
161
  const segments = /* @__PURE__ */ new Map();
138
162
  const motionPaths = /* @__PURE__ */ new Map();
139
163
  const current = new Map(initialValues);
@@ -294,6 +318,7 @@
294
318
  const inferredEnd = ir.timeline ? walk(ir.timeline, 0) : 0;
295
319
  for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
296
320
  for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
321
+ const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
297
322
  return {
298
323
  ir,
299
324
  duration: ir.duration ?? inferredEnd,
@@ -303,22 +328,43 @@
303
328
  nodeById,
304
329
  nodeOrder,
305
330
  labelTimes,
306
- beatTimes
331
+ beatTimes,
332
+ hasCamera
307
333
  };
308
334
  }
309
335
 
310
336
  // ../core/src/validate.ts
311
- var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor"];
337
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
312
338
  var PROPS_BY_TYPE = {
313
339
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
314
340
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
315
341
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
316
- text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
342
+ text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
317
343
  image: [...COMMON_PROPS, "src", "width", "height"],
318
344
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
319
345
  group: COMMON_PROPS
320
346
  };
321
347
 
348
+ // ../core/src/camera.ts
349
+ function cameraMatrix(cam, size) {
350
+ const W = size.width;
351
+ const H = size.height;
352
+ const x = cam.x ?? W / 2;
353
+ const y = cam.y ?? H / 2;
354
+ const zoom = cam.zoom ?? 1;
355
+ const r = (cam.rotation ?? 0) * Math.PI / 180;
356
+ const a = Math.cos(r) * zoom;
357
+ const b = Math.sin(r) * zoom;
358
+ const c = b === 0 ? 0 : -b;
359
+ const d = Math.cos(r) * zoom;
360
+ return [a, b, c, d, W / 2 - a * x - c * y, H / 2 - b * x - d * y];
361
+ }
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
+
322
368
  // ../core/src/presets.ts
323
369
  var SET = 1 / 120;
324
370
 
@@ -552,6 +598,17 @@
552
598
  };
553
599
  var TEXT_ALIGN = { 0: "left", 0.5: "center", 1: "right" };
554
600
  var TEXT_BASELINE = { 0: "top", 0.5: "middle", 1: "bottom" };
601
+ function formatNumber(value, decimals, thousands) {
602
+ const fixed = value.toFixed(decimals);
603
+ if (!thousands) return fixed;
604
+ const neg = fixed.startsWith("-");
605
+ const body = neg ? fixed.slice(1) : fixed;
606
+ const dot = body.indexOf(".");
607
+ const intPart = dot === -1 ? body : body.slice(0, dot);
608
+ const frac = dot === -1 ? "" : body.slice(dot);
609
+ const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
610
+ return (neg ? "-" : "") + grouped + frac;
611
+ }
555
612
  function behaviorEnvelope(b, t) {
556
613
  const from = b.from ?? Number.NEGATIVE_INFINITY;
557
614
  const until = b.until ?? Number.POSITIVE_INFINITY;
@@ -673,8 +730,10 @@
673
730
  const height = num(id, "height", node.props.height);
674
731
  const [ax, ay] = ANCHOR_FACTORS[node.props.anchor ?? "top-left"];
675
732
  const strokeWidth = num(id, "strokeWidth", node.props.strokeWidth ?? 1);
676
- const fill = opt(id, "fill", node.props.fill);
677
- 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);
678
737
  ops.push({
679
738
  type: node.type,
680
739
  id,
@@ -712,17 +771,22 @@
712
771
  case "path": {
713
772
  const ox = num(id, "originX", node.props.originX ?? 0);
714
773
  const oy = num(id, "originY", node.props.originY ?? 0);
715
- const fill = opt(id, "fill", node.props.fill);
716
- 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);
717
780
  ops.push({
718
781
  type: "path",
719
782
  id,
720
783
  transform: ox === 0 && oy === 0 ? matrix : multiply(matrix, [1, 0, 0, 1, -ox, -oy]),
721
784
  opacity,
722
- d: str(id, "d", node.props.d),
785
+ d: dStr,
723
786
  progress: Math.max(0, Math.min(1, num(id, "progress", node.props.progress ?? 1))),
724
787
  ...fill !== void 0 && { fill },
725
788
  ...stroke !== void 0 && { stroke, strokeWidth: num(id, "strokeWidth", node.props.strokeWidth ?? 1) },
789
+ ...needsBox && { bbox: pathBBox(dStr) },
726
790
  ...clipSpread
727
791
  });
728
792
  return;
@@ -739,7 +803,7 @@
739
803
  id,
740
804
  transform: matrix,
741
805
  opacity,
742
- content: typeof raw === "number" ? raw.toFixed(decimals) : raw,
806
+ content: typeof raw === "number" ? formatNumber(raw, decimals, node.props.contentThousands === true) : raw,
743
807
  fontFamily: str(id, "fontFamily", node.props.fontFamily),
744
808
  fontSize: num(id, "fontSize", node.props.fontSize),
745
809
  fontWeight: num(id, "fontWeight", node.props.fontWeight ?? 400),
@@ -753,11 +817,48 @@
753
817
  }
754
818
  }
755
819
  };
756
- for (const node of compiled2.ir.nodes) walk(node, IDENTITY, 1, []);
820
+ const cameraRoot = compiled2.hasCamera ? cameraMatrix(
821
+ {
822
+ x: num("camera", "x", compiled2.ir.size.width / 2),
823
+ y: num("camera", "y", compiled2.ir.size.height / 2),
824
+ zoom: num("camera", "zoom", 1),
825
+ rotation: num("camera", "rotation", 0)
826
+ },
827
+ compiled2.ir.size
828
+ ) : IDENTITY;
829
+ for (const node of compiled2.ir.nodes) {
830
+ const root = compiled2.hasCamera && node.props.fixed ? IDENTITY : cameraRoot;
831
+ walk(node, root, 1, []);
832
+ }
757
833
  return ops;
758
834
  }
759
835
 
760
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
+ }
761
862
  function renderFrame(ctx2, compiled2, t, images2) {
762
863
  const { size, background } = compiled2.ir;
763
864
  ctx2.setTransform(1, 0, 0, 1, 0, 0);
@@ -790,6 +891,7 @@
790
891
  ctx2.globalAlpha = Math.max(0, Math.min(1, op.opacity));
791
892
  switch (op.type) {
792
893
  case "rect": {
894
+ const box = { x: op.offsetX, y: op.offsetY, w: op.width, h: op.height };
793
895
  ctx2.beginPath();
794
896
  if (op.radius && op.radius > 0) {
795
897
  ctx2.roundRect(op.offsetX, op.offsetY, op.width, op.height, op.radius);
@@ -797,17 +899,18 @@
797
899
  ctx2.rect(op.offsetX, op.offsetY, op.width, op.height);
798
900
  }
799
901
  if (op.fill) {
800
- ctx2.fillStyle = op.fill;
902
+ ctx2.fillStyle = resolvePaint(ctx2, op.fill, box);
801
903
  ctx2.fill();
802
904
  }
803
905
  if (op.stroke) {
804
- ctx2.strokeStyle = op.stroke;
906
+ ctx2.strokeStyle = resolvePaint(ctx2, op.stroke, box);
805
907
  ctx2.lineWidth = op.strokeWidth ?? 1;
806
908
  ctx2.stroke();
807
909
  }
808
910
  break;
809
911
  }
810
912
  case "ellipse": {
913
+ const box = { x: op.offsetX, y: op.offsetY, w: op.width, h: op.height };
811
914
  ctx2.beginPath();
812
915
  ctx2.ellipse(
813
916
  op.offsetX + op.width / 2,
@@ -819,11 +922,11 @@
819
922
  Math.PI * 2
820
923
  );
821
924
  if (op.fill) {
822
- ctx2.fillStyle = op.fill;
925
+ ctx2.fillStyle = resolvePaint(ctx2, op.fill, box);
823
926
  ctx2.fill();
824
927
  }
825
928
  if (op.stroke) {
826
- ctx2.strokeStyle = op.stroke;
929
+ ctx2.strokeStyle = resolvePaint(ctx2, op.stroke, box);
827
930
  ctx2.lineWidth = op.strokeWidth ?? 1;
828
931
  ctx2.stroke();
829
932
  }
@@ -860,12 +963,13 @@
860
963
  }
861
964
  case "path": {
862
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 };
863
967
  if (op.fill) {
864
- ctx2.fillStyle = op.fill;
968
+ ctx2.fillStyle = resolvePaint(ctx2, op.fill, box);
865
969
  ctx2.fill(p);
866
970
  }
867
971
  if (op.stroke && (op.strokeWidth ?? 1) > 0) {
868
- ctx2.strokeStyle = op.stroke;
972
+ ctx2.strokeStyle = resolvePaint(ctx2, op.stroke, box);
869
973
  ctx2.lineWidth = op.strokeWidth ?? 1;
870
974
  ctx2.lineJoin = "round";
871
975
  ctx2.lineCap = "round";
package/dist/cli.js CHANGED
@@ -140,6 +140,14 @@ function compileScene(ir) {
140
140
  }
141
141
  }
142
142
  }
143
+ const cameraIsNode = nodeById.has("camera");
144
+ if (!cameraIsNode) {
145
+ const cam = ir.camera ?? {};
146
+ initialValues.set(key("camera", "x"), cam.x ?? ir.size.width / 2);
147
+ initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
148
+ initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
149
+ initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
150
+ }
143
151
  const segments = /* @__PURE__ */ new Map();
144
152
  const motionPaths = /* @__PURE__ */ new Map();
145
153
  const current = new Map(initialValues);
@@ -300,6 +308,7 @@ function compileScene(ir) {
300
308
  const inferredEnd = ir.timeline ? walk(ir.timeline, 0) : 0;
301
309
  for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
302
310
  for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
311
+ const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
303
312
  return {
304
313
  ir,
305
314
  duration: ir.duration ?? inferredEnd,
@@ -309,17 +318,19 @@ function compileScene(ir) {
309
318
  nodeById,
310
319
  nodeOrder,
311
320
  labelTimes,
312
- beatTimes
321
+ beatTimes,
322
+ hasCamera
313
323
  };
314
324
  }
315
325
 
316
326
  // ../core/src/validate.ts
317
- var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor"];
327
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
328
+ var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
318
329
  var PROPS_BY_TYPE = {
319
330
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
320
331
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
321
332
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
322
- text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
333
+ text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
323
334
  image: [...COMMON_PROPS, "src", "width", "height"],
324
335
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
325
336
  group: COMMON_PROPS
@@ -336,12 +347,34 @@ ${problems.map((p) => ` - ${p}`).join("\n")}`);
336
347
  function validateScene(ir) {
337
348
  const problems = [];
338
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
+ };
339
369
  const collect = (nodes) => {
340
370
  for (const node of nodes) {
341
371
  if (nodeById.has(node.id)) {
342
372
  problems.push(`duplicate node id "${node.id}" \u2014 every node id must be unique`);
343
373
  }
344
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);
345
378
  if (node.type === "group") {
346
379
  const clip = node.props.clip;
347
380
  if (clip) {
@@ -358,6 +391,14 @@ function validateScene(ir) {
358
391
  };
359
392
  collect(ir.nodes);
360
393
  const checkProps = (where, nodeId, props) => {
394
+ if (nodeId === "camera" && !nodeById.has("camera")) {
395
+ for (const key2 of Object.keys(props)) {
396
+ if (!CAMERA_PROPS.includes(key2)) {
397
+ problems.push(`${where}: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
398
+ }
399
+ }
400
+ return;
401
+ }
361
402
  const node = nodeById.get(nodeId);
362
403
  if (!node) {
363
404
  problems.push(
@@ -425,12 +466,15 @@ function validateScene(ir) {
425
466
  break;
426
467
  case "motionPath": {
427
468
  const node = nodeById.get(tl.target);
428
- if (!node) {
429
- problems.push(
430
- `${path2}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
431
- );
432
- } else if (node.type === "line") {
433
- problems.push(`${path2}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
469
+ const isSceneCamera = tl.target === "camera" && !node;
470
+ if (!isSceneCamera) {
471
+ if (!node) {
472
+ problems.push(
473
+ `${path2}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
474
+ );
475
+ } else if (node.type === "line") {
476
+ problems.push(`${path2}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
477
+ }
434
478
  }
435
479
  if (tl.points.length < 1) problems.push(`${path2}: motionPath "${tl.target}" needs at least 1 point`);
436
480
  if (tl.duration !== void 0 && tl.duration <= 0) {
@@ -475,6 +519,18 @@ function validateScene(ir) {
475
519
  if (ir.duration !== void 0 && ir.duration <= 0) {
476
520
  problems.push("scene duration must be > 0");
477
521
  }
522
+ if (ir.camera) {
523
+ if (nodeById.has("camera")) {
524
+ problems.push(`camera: a node is already named "camera" \u2014 rename that node or drop the scene camera (the id "camera" can't be both)`);
525
+ }
526
+ for (const [key2, value] of Object.entries(ir.camera)) {
527
+ if (!CAMERA_PROPS.includes(key2)) {
528
+ problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
529
+ } else if (typeof value !== "number") {
530
+ problems.push(`camera.${key2} must be a number`);
531
+ }
532
+ }
533
+ }
478
534
  const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
479
535
  for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
480
536
  if (typeof cue.at === "string" && !labels.has(cue.at)) {