reframe-video 0.4.0 → 0.6.0

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;
@@ -361,6 +371,14 @@ function validateScene(ir) {
361
371
  };
362
372
  collect(ir.nodes);
363
373
  const checkProps = (where, nodeId, props) => {
374
+ if (nodeId === "camera" && !nodeById.has("camera")) {
375
+ for (const key2 of Object.keys(props)) {
376
+ if (!CAMERA_PROPS.includes(key2)) {
377
+ problems.push(`${where}: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
378
+ }
379
+ }
380
+ return;
381
+ }
364
382
  const node = nodeById.get(nodeId);
365
383
  if (!node) {
366
384
  problems.push(
@@ -428,12 +446,15 @@ function validateScene(ir) {
428
446
  break;
429
447
  case "motionPath": {
430
448
  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}"`);
449
+ const isSceneCamera = tl.target === "camera" && !node;
450
+ if (!isSceneCamera) {
451
+ if (!node) {
452
+ problems.push(
453
+ `${path2}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
454
+ );
455
+ } else if (node.type === "line") {
456
+ problems.push(`${path2}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
457
+ }
437
458
  }
438
459
  if (tl.points.length < 1) problems.push(`${path2}: motionPath "${tl.target}" needs at least 1 point`);
439
460
  if (tl.duration !== void 0 && tl.duration <= 0) {
@@ -478,6 +499,18 @@ function validateScene(ir) {
478
499
  if (ir.duration !== void 0 && ir.duration <= 0) {
479
500
  problems.push("scene duration must be > 0");
480
501
  }
502
+ if (ir.camera) {
503
+ if (nodeById.has("camera")) {
504
+ 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)`);
505
+ }
506
+ for (const [key2, value] of Object.entries(ir.camera)) {
507
+ if (!CAMERA_PROPS.includes(key2)) {
508
+ problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
509
+ } else if (typeof value !== "number") {
510
+ problems.push(`camera.${key2} must be a number`);
511
+ }
512
+ }
513
+ }
481
514
  const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
482
515
  for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
483
516
  if (typeof cue.at === "string" && !labels.has(cue.at)) {
@@ -536,16 +569,17 @@ function validateComposition(comp) {
536
569
  }
537
570
  if (problems.length > 0) throw new SceneValidationError(problems);
538
571
  }
539
- var COMMON_PROPS, PROPS_BY_TYPE, SceneValidationError, TRANSITIONS;
572
+ var COMMON_PROPS, CAMERA_PROPS, PROPS_BY_TYPE, SceneValidationError, TRANSITIONS;
540
573
  var init_validate = __esm({
541
574
  "../core/src/validate.ts"() {
542
575
  "use strict";
543
- COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor"];
576
+ COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
577
+ CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
544
578
  PROPS_BY_TYPE = {
545
579
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
546
580
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
547
581
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
548
- text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
582
+ text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
549
583
  image: [...COMMON_PROPS, "src", "width", "height"],
550
584
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
551
585
  group: COMMON_PROPS
@@ -857,6 +891,14 @@ var init_compose = __esm({
857
891
  }
858
892
  });
859
893
 
894
+ // ../core/src/camera.ts
895
+ var init_camera = __esm({
896
+ "../core/src/camera.ts"() {
897
+ "use strict";
898
+ init_dsl();
899
+ }
900
+ });
901
+
860
902
  // ../core/src/presets.ts
861
903
  function makeRng(seed) {
862
904
  let a = seed >>> 0 || 2654435769;
@@ -1044,6 +1086,14 @@ var init_devicePreset = __esm({
1044
1086
  }
1045
1087
  });
1046
1088
 
1089
+ // ../core/src/cursor.ts
1090
+ var init_cursor = __esm({
1091
+ "../core/src/cursor.ts"() {
1092
+ "use strict";
1093
+ init_dsl();
1094
+ }
1095
+ });
1096
+
1047
1097
  // ../core/src/rig.ts
1048
1098
  var init_rig = __esm({
1049
1099
  "../core/src/rig.ts"() {
@@ -1241,6 +1291,7 @@ var init_evaluate = __esm({
1241
1291
  "../core/src/evaluate.ts"() {
1242
1292
  "use strict";
1243
1293
  init_behaviors();
1294
+ init_camera();
1244
1295
  init_interpolate();
1245
1296
  init_path();
1246
1297
  }
@@ -1302,8 +1353,10 @@ var init_src = __esm({
1302
1353
  init_compose();
1303
1354
  init_compile();
1304
1355
  init_path();
1356
+ init_camera();
1305
1357
  init_presets();
1306
1358
  init_devicePreset();
1359
+ init_cursor();
1307
1360
  init_rig();
1308
1361
  init_characterPreset();
1309
1362
  init_figure();
@@ -2325,6 +2378,7 @@ var ROOT2 = PACKAGED ? resolve4(HERE2, "..") : resolve4(HERE2, "..", "..", "..")
2325
2378
  var USER_CWD = process.env.INIT_CWD ?? process.cwd();
2326
2379
  var RENDER_CLI = PACKAGED ? join6(ROOT2, "dist", "cli.js") : join6(ROOT2, "packages", "render-cli", "src", "cli.ts");
2327
2380
  var LABELS = PACKAGED ? join6(ROOT2, "dist", "labels.js") : join6(ROOT2, "packages", "render-cli", "src", "labels.ts");
2381
+ var PLAYER = PACKAGED ? join6(ROOT2, "dist", "player.js") : join6(ROOT2, "packages", "render-cli", "src", "player.ts");
2328
2382
  var ANALYZE = PACKAGED ? join6(ROOT2, "dist", "analyze.js") : join6(ROOT2, "benchmark", "harness", "motion", "analyze.ts");
2329
2383
  var TRACE = PACKAGED ? join6(ROOT2, "dist", "trace-cli.js") : join6(ROOT2, "benchmark", "harness", "motion", "trace-cli.ts");
2330
2384
  var CMD = PACKAGED ? "reframe" : "pnpm reframe";
@@ -2336,6 +2390,8 @@ usage:
2336
2390
  ${CMD} logo <logo.svg|brand-slug> ["Name"] [--motion <preset>] [--energy 0..1] [--seed N] [-o out.mp4]
2337
2391
  animate a logo into a sting (presets: draw-bloom, punch-in,
2338
2392
  rise-settle, slide-bank, reveal-orbit, spin-forge)
2393
+ ${CMD} player <scene.ts|.json> [-o out.html] bundle a scene into one self-contained HTML
2394
+ player (plays live in any browser or a Claude.ai artifact; visual only)
2339
2395
  ${CMD} preview open the scrub/edit UI (lists scenes in your directory)
2340
2396
  ${CMD} new <scene-name> scaffold <scene-name>.ts in your directory
2341
2397
  ${CMD} labels <scene.ts|.json> print the event clock (label \u2192 exact seconds; for sound design / timing)
@@ -2480,6 +2536,21 @@ ${USAGE}`);
2480
2536
  await (PACKAGED ? run(process.execPath, [LABELS, inputPath]) : run("npx", ["tsx", LABELS, inputPath]))
2481
2537
  );
2482
2538
  }
2539
+ case "player": {
2540
+ const input = rest[0];
2541
+ if (!input || input.startsWith("-")) fail(`player needs a scene file
2542
+
2543
+ ${USAGE}`);
2544
+ const inputPath = userPath(input);
2545
+ if (!existsSync4(inputPath)) fail(`no such file: ${inputPath}`);
2546
+ const oIdx = rest.indexOf("-o");
2547
+ const outBase = PACKAGED ? join6(USER_CWD, "out") : join6(ROOT2, "out");
2548
+ const outPath = oIdx >= 0 && rest[oIdx + 1] ? userPath(rest[oIdx + 1]) : join6(outBase, `${basename(input).replace(/\.[^.]+$/, "")}.html`);
2549
+ await mkdir4(dirname7(outPath), { recursive: true });
2550
+ process.exit(
2551
+ await (PACKAGED ? run(process.execPath, [PLAYER, inputPath, outPath]) : run("npx", ["tsx", PLAYER, inputPath, outPath]))
2552
+ );
2553
+ }
2483
2554
  case "logo": {
2484
2555
  const positional = [];
2485
2556
  const flags = {};
@@ -134,6 +134,14 @@
134
134
  }
135
135
  }
136
136
  }
137
+ const cameraIsNode = nodeById.has("camera");
138
+ if (!cameraIsNode) {
139
+ const cam = ir.camera ?? {};
140
+ initialValues.set(key("camera", "x"), cam.x ?? ir.size.width / 2);
141
+ initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
142
+ initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
143
+ initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
144
+ }
137
145
  const segments = /* @__PURE__ */ new Map();
138
146
  const motionPaths = /* @__PURE__ */ new Map();
139
147
  const current = new Map(initialValues);
@@ -294,6 +302,7 @@
294
302
  const inferredEnd = ir.timeline ? walk(ir.timeline, 0) : 0;
295
303
  for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
296
304
  for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
305
+ const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
297
306
  return {
298
307
  ir,
299
308
  duration: ir.duration ?? inferredEnd,
@@ -303,22 +312,38 @@
303
312
  nodeById,
304
313
  nodeOrder,
305
314
  labelTimes,
306
- beatTimes
315
+ beatTimes,
316
+ hasCamera
307
317
  };
308
318
  }
309
319
 
310
320
  // ../core/src/validate.ts
311
- var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor"];
321
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
312
322
  var PROPS_BY_TYPE = {
313
323
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
314
324
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
315
325
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
316
- text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
326
+ text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
317
327
  image: [...COMMON_PROPS, "src", "width", "height"],
318
328
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
319
329
  group: COMMON_PROPS
320
330
  };
321
331
 
332
+ // ../core/src/camera.ts
333
+ function cameraMatrix(cam, size) {
334
+ const W = size.width;
335
+ const H = size.height;
336
+ const x = cam.x ?? W / 2;
337
+ const y = cam.y ?? H / 2;
338
+ const zoom = cam.zoom ?? 1;
339
+ const r = (cam.rotation ?? 0) * Math.PI / 180;
340
+ const a = Math.cos(r) * zoom;
341
+ const b = Math.sin(r) * zoom;
342
+ const c = b === 0 ? 0 : -b;
343
+ const d = Math.cos(r) * zoom;
344
+ return [a, b, c, d, W / 2 - a * x - c * y, H / 2 - b * x - d * y];
345
+ }
346
+
322
347
  // ../core/src/presets.ts
323
348
  var SET = 1 / 120;
324
349
 
@@ -552,6 +577,17 @@
552
577
  };
553
578
  var TEXT_ALIGN = { 0: "left", 0.5: "center", 1: "right" };
554
579
  var TEXT_BASELINE = { 0: "top", 0.5: "middle", 1: "bottom" };
580
+ function formatNumber(value, decimals, thousands) {
581
+ const fixed = value.toFixed(decimals);
582
+ if (!thousands) return fixed;
583
+ const neg = fixed.startsWith("-");
584
+ const body = neg ? fixed.slice(1) : fixed;
585
+ const dot = body.indexOf(".");
586
+ const intPart = dot === -1 ? body : body.slice(0, dot);
587
+ const frac = dot === -1 ? "" : body.slice(dot);
588
+ const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
589
+ return (neg ? "-" : "") + grouped + frac;
590
+ }
555
591
  function behaviorEnvelope(b, t) {
556
592
  const from = b.from ?? Number.NEGATIVE_INFINITY;
557
593
  const until = b.until ?? Number.POSITIVE_INFINITY;
@@ -739,7 +775,7 @@
739
775
  id,
740
776
  transform: matrix,
741
777
  opacity,
742
- content: typeof raw === "number" ? raw.toFixed(decimals) : raw,
778
+ content: typeof raw === "number" ? formatNumber(raw, decimals, node.props.contentThousands === true) : raw,
743
779
  fontFamily: str(id, "fontFamily", node.props.fontFamily),
744
780
  fontSize: num(id, "fontSize", node.props.fontSize),
745
781
  fontWeight: num(id, "fontWeight", node.props.fontWeight ?? 400),
@@ -753,7 +789,19 @@
753
789
  }
754
790
  }
755
791
  };
756
- for (const node of compiled2.ir.nodes) walk(node, IDENTITY, 1, []);
792
+ const cameraRoot = compiled2.hasCamera ? cameraMatrix(
793
+ {
794
+ x: num("camera", "x", compiled2.ir.size.width / 2),
795
+ y: num("camera", "y", compiled2.ir.size.height / 2),
796
+ zoom: num("camera", "zoom", 1),
797
+ rotation: num("camera", "rotation", 0)
798
+ },
799
+ compiled2.ir.size
800
+ ) : IDENTITY;
801
+ for (const node of compiled2.ir.nodes) {
802
+ const root = compiled2.hasCamera && node.props.fixed ? IDENTITY : cameraRoot;
803
+ walk(node, root, 1, []);
804
+ }
757
805
  return ops;
758
806
  }
759
807
 
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
@@ -358,6 +369,14 @@ function validateScene(ir) {
358
369
  };
359
370
  collect(ir.nodes);
360
371
  const checkProps = (where, nodeId, props) => {
372
+ if (nodeId === "camera" && !nodeById.has("camera")) {
373
+ for (const key2 of Object.keys(props)) {
374
+ if (!CAMERA_PROPS.includes(key2)) {
375
+ problems.push(`${where}: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
376
+ }
377
+ }
378
+ return;
379
+ }
361
380
  const node = nodeById.get(nodeId);
362
381
  if (!node) {
363
382
  problems.push(
@@ -425,12 +444,15 @@ function validateScene(ir) {
425
444
  break;
426
445
  case "motionPath": {
427
446
  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}"`);
447
+ const isSceneCamera = tl.target === "camera" && !node;
448
+ if (!isSceneCamera) {
449
+ if (!node) {
450
+ problems.push(
451
+ `${path2}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
452
+ );
453
+ } else if (node.type === "line") {
454
+ problems.push(`${path2}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
455
+ }
434
456
  }
435
457
  if (tl.points.length < 1) problems.push(`${path2}: motionPath "${tl.target}" needs at least 1 point`);
436
458
  if (tl.duration !== void 0 && tl.duration <= 0) {
@@ -475,6 +497,18 @@ function validateScene(ir) {
475
497
  if (ir.duration !== void 0 && ir.duration <= 0) {
476
498
  problems.push("scene duration must be > 0");
477
499
  }
500
+ if (ir.camera) {
501
+ if (nodeById.has("camera")) {
502
+ 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)`);
503
+ }
504
+ for (const [key2, value] of Object.entries(ir.camera)) {
505
+ if (!CAMERA_PROPS.includes(key2)) {
506
+ problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
507
+ } else if (typeof value !== "number") {
508
+ problems.push(`camera.${key2} must be a number`);
509
+ }
510
+ }
511
+ }
478
512
  const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
479
513
  for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
480
514
  if (typeof cue.at === "string" && !labels.has(cue.at)) {
package/dist/index.js CHANGED
@@ -134,6 +134,14 @@ function compileScene(ir) {
134
134
  }
135
135
  }
136
136
  }
137
+ const cameraIsNode = nodeById.has("camera");
138
+ if (!cameraIsNode) {
139
+ const cam = ir.camera ?? {};
140
+ initialValues.set(key("camera", "x"), cam.x ?? ir.size.width / 2);
141
+ initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
142
+ initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
143
+ initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
144
+ }
137
145
  const segments = /* @__PURE__ */ new Map();
138
146
  const motionPaths = /* @__PURE__ */ new Map();
139
147
  const current = new Map(initialValues);
@@ -294,6 +302,7 @@ function compileScene(ir) {
294
302
  const inferredEnd = ir.timeline ? walk(ir.timeline, 0) : 0;
295
303
  for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
296
304
  for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
305
+ const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
297
306
  return {
298
307
  ir,
299
308
  duration: ir.duration ?? inferredEnd,
@@ -303,17 +312,19 @@ function compileScene(ir) {
303
312
  nodeById,
304
313
  nodeOrder,
305
314
  labelTimes,
306
- beatTimes
315
+ beatTimes,
316
+ hasCamera
307
317
  };
308
318
  }
309
319
 
310
320
  // ../core/src/validate.ts
311
- var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor"];
321
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
322
+ var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
312
323
  var PROPS_BY_TYPE = {
313
324
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
314
325
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
315
326
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
316
- text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
327
+ text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
317
328
  image: [...COMMON_PROPS, "src", "width", "height"],
318
329
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
319
330
  group: COMMON_PROPS
@@ -352,6 +363,14 @@ function validateScene(ir) {
352
363
  };
353
364
  collect(ir.nodes);
354
365
  const checkProps = (where, nodeId, props) => {
366
+ if (nodeId === "camera" && !nodeById.has("camera")) {
367
+ for (const key2 of Object.keys(props)) {
368
+ if (!CAMERA_PROPS.includes(key2)) {
369
+ problems.push(`${where}: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
370
+ }
371
+ }
372
+ return;
373
+ }
355
374
  const node = nodeById.get(nodeId);
356
375
  if (!node) {
357
376
  problems.push(
@@ -419,12 +438,15 @@ function validateScene(ir) {
419
438
  break;
420
439
  case "motionPath": {
421
440
  const node = nodeById.get(tl.target);
422
- if (!node) {
423
- problems.push(
424
- `${path2}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
425
- );
426
- } else if (node.type === "line") {
427
- problems.push(`${path2}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
441
+ const isSceneCamera = tl.target === "camera" && !node;
442
+ if (!isSceneCamera) {
443
+ if (!node) {
444
+ problems.push(
445
+ `${path2}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
446
+ );
447
+ } else if (node.type === "line") {
448
+ problems.push(`${path2}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
449
+ }
428
450
  }
429
451
  if (tl.points.length < 1) problems.push(`${path2}: motionPath "${tl.target}" needs at least 1 point`);
430
452
  if (tl.duration !== void 0 && tl.duration <= 0) {
@@ -469,6 +491,18 @@ function validateScene(ir) {
469
491
  if (ir.duration !== void 0 && ir.duration <= 0) {
470
492
  problems.push("scene duration must be > 0");
471
493
  }
494
+ if (ir.camera) {
495
+ if (nodeById.has("camera")) {
496
+ 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)`);
497
+ }
498
+ for (const [key2, value] of Object.entries(ir.camera)) {
499
+ if (!CAMERA_PROPS.includes(key2)) {
500
+ problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
501
+ } else if (typeof value !== "number") {
502
+ problems.push(`camera.${key2} must be a number`);
503
+ }
504
+ }
505
+ }
472
506
  const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
473
507
  for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
474
508
  if (typeof cue.at === "string" && !labels.has(cue.at)) {
@@ -863,6 +897,26 @@ function formatComposeReport(report) {
863
897
  return lines.join("\n");
864
898
  }
865
899
 
900
+ // ../core/src/camera.ts
901
+ var CAMERA_ID = "camera";
902
+ var CAMERA_PROPS2 = ["x", "y", "zoom", "rotation"];
903
+ function cameraMatrix(cam, size) {
904
+ const W = size.width;
905
+ const H = size.height;
906
+ const x = cam.x ?? W / 2;
907
+ const y = cam.y ?? H / 2;
908
+ const zoom = cam.zoom ?? 1;
909
+ const r = (cam.rotation ?? 0) * Math.PI / 180;
910
+ const a = Math.cos(r) * zoom;
911
+ const b = Math.sin(r) * zoom;
912
+ const c = b === 0 ? 0 : -b;
913
+ const d = Math.cos(r) * zoom;
914
+ return [a, b, c, d, W / 2 - a * x - c * y, H / 2 - b * x - d * y];
915
+ }
916
+ function cameraTo(props, opts = {}) {
917
+ return tween(CAMERA_ID, props, opts);
918
+ }
919
+
866
920
  // ../core/src/presets.ts
867
921
  var PRESET_NAMES = [
868
922
  "draw-bloom",
@@ -1089,6 +1143,11 @@ function deviceBounds(name, opts = {}) {
1089
1143
  const b = BOUNDS[name];
1090
1144
  return isLandscape(name, opts) ? { width: b.height, height: b.width } : { ...b };
1091
1145
  }
1146
+ function deviceScreenPoint(name, opts, local) {
1147
+ const c = deviceScreenCenter(name, opts);
1148
+ const s = opts.scale ?? 1;
1149
+ return [(opts.x ?? 0) + s * (c.x + local[0]), (opts.y ?? 0) + s * (c.y + local[1])];
1150
+ }
1092
1151
  function screenGroup(id, p, o, cx, cy, dims, content) {
1093
1152
  return group({ id: `${id}-screen`, x: cx, y: cy, clip: { kind: "rect", x: -dims.width / 2, y: -dims.height / 2, width: dims.width, height: dims.height, radius: dims.radius } }, [
1094
1153
  rect({ id: `${id}-screenbg`, x: 0, y: 0, anchor: "center", width: dims.width, height: dims.height, fill: o.screen ?? p.screen }),
@@ -1263,6 +1322,73 @@ function devicePreset(name, opts = {}) {
1263
1322
  );
1264
1323
  }
1265
1324
 
1325
+ // ../core/src/cursor.ts
1326
+ var ARROW_D = "M0 0 L0 30 L8 23 L12.6 33 L17 31 L12.4 21.4 L21 21.4 Z";
1327
+ function cursor(opts = {}) {
1328
+ const id = opts.id ?? "cursor";
1329
+ const style = opts.style ?? "arrow";
1330
+ const fill = opts.fill ?? "#FFFFFF";
1331
+ const accent = opts.accent ?? "#FF5A1F";
1332
+ const art = style === "arrow" ? [path({ id: `${id}-arrow`, d: ARROW_D, x: 0, y: 0, fill, stroke: "#15171E", strokeWidth: 2 })] : style === "dot" ? [ellipse({ id: `${id}-dot`, x: 0, y: 0, width: 18, height: 18, fill: accent, anchor: "center" })] : [ellipse({ id: `${id}-ring`, x: 0, y: 0, width: 22, height: 22, fill: "none", stroke: accent, strokeWidth: 3, anchor: "center" })];
1333
+ return group(
1334
+ { id, x: opts.x ?? 0, y: opts.y ?? 0, scale: opts.scale ?? 1, opacity: opts.opacity ?? 1 },
1335
+ [
1336
+ // ripple ring (behind the pointer), emanates from the hotspot on click
1337
+ ellipse({ id: `${id}-ripple`, x: 0, y: 0, width: 30, height: 30, fill: "none", stroke: accent, strokeWidth: 3, opacity: 0, scale: 0, anchor: "center" }),
1338
+ // the pointer art lives in its own group so a click "tap" can scale it
1339
+ // independently of the cursor's resting scale
1340
+ group({ id: `${id}-art`, x: 0, y: 0 }, art)
1341
+ ]
1342
+ );
1343
+ }
1344
+ var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
1345
+ function cursorTo(id, from, to2, opts = {}) {
1346
+ const dx = to2[0] - from[0], dy = to2[1] - from[1];
1347
+ const dist = Math.hypot(dx, dy) || 1;
1348
+ const arc = opts.arc ?? 0.12;
1349
+ const mid = [(from[0] + to2[0]) / 2 + -dy / dist * arc * dist, (from[1] + to2[1]) / 2 + dx / dist * arc * dist];
1350
+ const duration = opts.duration ?? clamp(dist / 1400, 0.4, 0.9);
1351
+ return motionPath(id, [from, mid, to2], { duration, ease: opts.ease ?? "easeInOutCubic", curviness: 1, ...opts.label && { label: opts.label } });
1352
+ }
1353
+ function cursorPath(id, points, opts = {}) {
1354
+ return motionPath(id, points, {
1355
+ duration: opts.duration ?? clamp(points.length * 0.5, 0.5, 4),
1356
+ ease: opts.ease ?? "easeInOutCubic",
1357
+ curviness: opts.curviness ?? 1,
1358
+ ...opts.label && { label: opts.label }
1359
+ });
1360
+ }
1361
+ function clickBody(id, o) {
1362
+ const sp = Math.max(0.25, o.speed ?? 1);
1363
+ const d = (b) => b / sp;
1364
+ const out = [
1365
+ // the pointer taps
1366
+ seq(tween(`${id}-art`, { scale: 0.82 }, { duration: d(0.08), ease: "easeOutQuad" }), tween(`${id}-art`, { scale: 1 }, { duration: d(0.1), ease: "easeOutBack" }))
1367
+ ];
1368
+ if (o.ripple !== false) {
1369
+ out.push(seq(
1370
+ tween(`${id}-ripple`, { scale: 0.2, opacity: 0.55 }, { duration: 1e-3 }),
1371
+ par(
1372
+ tween(`${id}-ripple`, { scale: 5 }, { duration: d(0.5), ease: "easeOutCubic" }),
1373
+ tween(`${id}-ripple`, { opacity: 0 }, { duration: d(0.5), ease: "easeOutQuad" })
1374
+ )
1375
+ ));
1376
+ }
1377
+ if (o.press) {
1378
+ out.push(seq(tween(o.press, { scale: 0.94 }, { duration: d(0.08), ease: "easeOutQuad" }), tween(o.press, { scale: 1 }, { duration: d(0.14), ease: "easeOutBack" })));
1379
+ }
1380
+ return out;
1381
+ }
1382
+ function cursorClick(id, opts = {}) {
1383
+ return beat(opts.label ?? "cursor-click", {}, [par(...clickBody(id, opts))]);
1384
+ }
1385
+ function cursorDouble(id, opts = {}) {
1386
+ const sp = Math.max(0.25, opts.speed ?? 1);
1387
+ return beat(opts.label ?? "cursor-double", {}, [
1388
+ seq(par(...clickBody(id, { ...opts, ripple: false })), wait(0.12 / sp), par(...clickBody(id, opts)))
1389
+ ]);
1390
+ }
1391
+
1266
1392
  // ../core/src/rig.ts
1267
1393
  var DEFAULT_LINE = "#FFE3D2";
1268
1394
  var DEFAULT_FILL = "#0E1424";
@@ -2008,7 +2134,7 @@ function splitText(textStr, opts) {
2008
2134
  chars.forEach((ch, i) => {
2009
2135
  total += advance(ch, weight, fontSize) + (i < chars.length - 1 ? ls : 0);
2010
2136
  });
2011
- let cursor = align === "center" ? x - total / 2 : x;
2137
+ let cursor2 = align === "center" ? x - total / 2 : x;
2012
2138
  const glyphs = [];
2013
2139
  const nodes = [];
2014
2140
  const mk = (ch, cx, adv, lsProp) => {
@@ -2034,13 +2160,13 @@ function splitText(textStr, opts) {
2034
2160
  let i = 0;
2035
2161
  while (i < chars.length) {
2036
2162
  if (chars[i] === " ") {
2037
- cursor += advance(" ", weight, fontSize) + ls;
2163
+ cursor2 += advance(" ", weight, fontSize) + ls;
2038
2164
  i++;
2039
2165
  continue;
2040
2166
  }
2041
2167
  let word = "";
2042
2168
  let w = 0;
2043
- const startCursor = cursor;
2169
+ const startCursor = cursor2;
2044
2170
  while (i < chars.length && chars[i] !== " ") {
2045
2171
  const a = advance(chars[i], weight, fontSize);
2046
2172
  word += chars[i];
@@ -2048,13 +2174,13 @@ function splitText(textStr, opts) {
2048
2174
  i++;
2049
2175
  }
2050
2176
  mk(word, startCursor + w / 2, w, ls);
2051
- cursor = startCursor + w + ls;
2177
+ cursor2 = startCursor + w + ls;
2052
2178
  }
2053
2179
  } else {
2054
2180
  chars.forEach((ch) => {
2055
2181
  const a = advance(ch, weight, fontSize);
2056
- if (ch !== " ") mk(ch, cursor + a / 2, a);
2057
- cursor += a + ls;
2182
+ if (ch !== " ") mk(ch, cursor2 + a / 2, a);
2183
+ cursor2 += a + ls;
2058
2184
  });
2059
2185
  }
2060
2186
  return { nodes, glyphs, ids: glyphs.map((g) => g.id), width: total, x, y, fontSize };
@@ -2629,6 +2755,17 @@ var ANCHOR_FACTORS = {
2629
2755
  };
2630
2756
  var TEXT_ALIGN = { 0: "left", 0.5: "center", 1: "right" };
2631
2757
  var TEXT_BASELINE = { 0: "top", 0.5: "middle", 1: "bottom" };
2758
+ function formatNumber(value, decimals, thousands) {
2759
+ const fixed = value.toFixed(decimals);
2760
+ if (!thousands) return fixed;
2761
+ const neg = fixed.startsWith("-");
2762
+ const body = neg ? fixed.slice(1) : fixed;
2763
+ const dot = body.indexOf(".");
2764
+ const intPart = dot === -1 ? body : body.slice(0, dot);
2765
+ const frac = dot === -1 ? "" : body.slice(dot);
2766
+ const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
2767
+ return (neg ? "-" : "") + grouped + frac;
2768
+ }
2632
2769
  function behaviorEnvelope(b, t) {
2633
2770
  const from = b.from ?? Number.NEGATIVE_INFINITY;
2634
2771
  const until = b.until ?? Number.POSITIVE_INFINITY;
@@ -2848,7 +2985,7 @@ function evaluate(compiled, t) {
2848
2985
  id,
2849
2986
  transform: matrix,
2850
2987
  opacity,
2851
- content: typeof raw === "number" ? raw.toFixed(decimals) : raw,
2988
+ content: typeof raw === "number" ? formatNumber(raw, decimals, node.props.contentThousands === true) : raw,
2852
2989
  fontFamily: str(id, "fontFamily", node.props.fontFamily),
2853
2990
  fontSize: num(id, "fontSize", node.props.fontSize),
2854
2991
  fontWeight: num(id, "fontWeight", node.props.fontWeight ?? 400),
@@ -2862,7 +2999,19 @@ function evaluate(compiled, t) {
2862
2999
  }
2863
3000
  }
2864
3001
  };
2865
- for (const node of compiled.ir.nodes) walk(node, IDENTITY, 1, []);
3002
+ const cameraRoot = compiled.hasCamera ? cameraMatrix(
3003
+ {
3004
+ x: num("camera", "x", compiled.ir.size.width / 2),
3005
+ y: num("camera", "y", compiled.ir.size.height / 2),
3006
+ zoom: num("camera", "zoom", 1),
3007
+ rotation: num("camera", "rotation", 0)
3008
+ },
3009
+ compiled.ir.size
3010
+ ) : IDENTITY;
3011
+ for (const node of compiled.ir.nodes) {
3012
+ const root = compiled.hasCamera && node.props.fixed ? IDENTITY : cameraRoot;
3013
+ walk(node, root, 1, []);
3014
+ }
2866
3015
  return ops;
2867
3016
  }
2868
3017
 
@@ -2943,6 +3092,8 @@ function sketchToTimeline(sketch, nodeIds) {
2943
3092
  return par(...steps);
2944
3093
  }
2945
3094
  export {
3095
+ CAMERA_ID,
3096
+ CAMERA_PROPS2 as CAMERA_PROPS,
2946
3097
  CHARACTER_PRESET_NAMES,
2947
3098
  DEFAULT_CROSSFADE,
2948
3099
  DEFAULT_FPS,
@@ -2957,16 +3108,24 @@ export {
2957
3108
  SFX_DURATION,
2958
3109
  SceneValidationError,
2959
3110
  beat,
3111
+ cameraMatrix,
3112
+ cameraTo,
2960
3113
  characterPreset,
2961
3114
  collectImageSrcs,
2962
3115
  compileComposition,
2963
3116
  compileScene,
2964
3117
  composeScene,
2965
3118
  composition,
3119
+ cursor,
3120
+ cursorClick,
3121
+ cursorDouble,
3122
+ cursorPath,
3123
+ cursorTo,
2966
3124
  deviceBounds,
2967
3125
  devicePreset,
2968
3126
  deviceScreen,
2969
3127
  deviceScreenCenter,
3128
+ deviceScreenPoint,
2970
3129
  ellipse,
2971
3130
  evaluate,
2972
3131
  figure,
package/dist/labels.js CHANGED
@@ -134,6 +134,14 @@ function compileScene(ir) {
134
134
  }
135
135
  }
136
136
  }
137
+ const cameraIsNode = nodeById.has("camera");
138
+ if (!cameraIsNode) {
139
+ const cam = ir.camera ?? {};
140
+ initialValues.set(key("camera", "x"), cam.x ?? ir.size.width / 2);
141
+ initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
142
+ initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
143
+ initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
144
+ }
137
145
  const segments = /* @__PURE__ */ new Map();
138
146
  const motionPaths = /* @__PURE__ */ new Map();
139
147
  const current = new Map(initialValues);
@@ -294,6 +302,7 @@ function compileScene(ir) {
294
302
  const inferredEnd = ir.timeline ? walk(ir.timeline, 0) : 0;
295
303
  for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
296
304
  for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
305
+ const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
297
306
  return {
298
307
  ir,
299
308
  duration: ir.duration ?? inferredEnd,
@@ -303,17 +312,19 @@ function compileScene(ir) {
303
312
  nodeById,
304
313
  nodeOrder,
305
314
  labelTimes,
306
- beatTimes
315
+ beatTimes,
316
+ hasCamera
307
317
  };
308
318
  }
309
319
 
310
320
  // ../core/src/validate.ts
311
- var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor"];
321
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
322
+ var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
312
323
  var PROPS_BY_TYPE = {
313
324
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
314
325
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
315
326
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
316
- text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
327
+ text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
317
328
  image: [...COMMON_PROPS, "src", "width", "height"],
318
329
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
319
330
  group: COMMON_PROPS
@@ -352,6 +363,14 @@ function validateScene(ir) {
352
363
  };
353
364
  collect(ir.nodes);
354
365
  const checkProps = (where, nodeId, props) => {
366
+ if (nodeId === "camera" && !nodeById.has("camera")) {
367
+ for (const key2 of Object.keys(props)) {
368
+ if (!CAMERA_PROPS.includes(key2)) {
369
+ problems.push(`${where}: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
370
+ }
371
+ }
372
+ return;
373
+ }
355
374
  const node = nodeById.get(nodeId);
356
375
  if (!node) {
357
376
  problems.push(
@@ -419,12 +438,15 @@ function validateScene(ir) {
419
438
  break;
420
439
  case "motionPath": {
421
440
  const node = nodeById.get(tl.target);
422
- if (!node) {
423
- problems.push(
424
- `${path3}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
425
- );
426
- } else if (node.type === "line") {
427
- problems.push(`${path3}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
441
+ const isSceneCamera = tl.target === "camera" && !node;
442
+ if (!isSceneCamera) {
443
+ if (!node) {
444
+ problems.push(
445
+ `${path3}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
446
+ );
447
+ } else if (node.type === "line") {
448
+ problems.push(`${path3}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
449
+ }
428
450
  }
429
451
  if (tl.points.length < 1) problems.push(`${path3}: motionPath "${tl.target}" needs at least 1 point`);
430
452
  if (tl.duration !== void 0 && tl.duration <= 0) {
@@ -469,6 +491,18 @@ function validateScene(ir) {
469
491
  if (ir.duration !== void 0 && ir.duration <= 0) {
470
492
  problems.push("scene duration must be > 0");
471
493
  }
494
+ if (ir.camera) {
495
+ if (nodeById.has("camera")) {
496
+ 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)`);
497
+ }
498
+ for (const [key2, value] of Object.entries(ir.camera)) {
499
+ if (!CAMERA_PROPS.includes(key2)) {
500
+ problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
501
+ } else if (typeof value !== "number") {
502
+ problems.push(`camera.${key2} must be a number`);
503
+ }
504
+ }
505
+ }
472
506
  const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
473
507
  for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
474
508
  if (typeof cue.at === "string" && !labels.has(cue.at)) {
package/dist/player.js ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env tsx
2
+
3
+ // ../render-cli/src/player.ts
4
+ import { build } from "esbuild";
5
+ import { mkdir, writeFile, readFile } from "node:fs/promises";
6
+ import { basename, dirname, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ var PACKAGED = true;
9
+ var HERE = dirname(fileURLToPath(import.meta.url));
10
+ var CORE = PACKAGED ? resolve(HERE, "index.js") : resolve(HERE, "..", "..", "core", "src", "index.ts");
11
+ var RENDERER = PACKAGED ? resolve(HERE, "renderer-canvas.js") : resolve(HERE, "..", "..", "renderer-canvas", "src", "index.ts");
12
+ var FONTS = PACKAGED ? resolve(HERE, "..", "assets", "fonts") : resolve(HERE, "..", "..", "..", "assets", "fonts");
13
+ async function fontFace(weight) {
14
+ try {
15
+ const b64 = (await readFile(resolve(FONTS, `inter-${weight}.woff2`))).toString("base64");
16
+ return `@font-face{font-family:Inter;font-style:normal;font-weight:${weight};font-display:block;src:url(data:font/woff2;base64,${b64}) format('woff2')}`;
17
+ } catch {
18
+ return "";
19
+ }
20
+ }
21
+ async function main() {
22
+ const [scenePath, outPath] = process.argv.slice(2);
23
+ if (!scenePath || !outPath) {
24
+ console.error("usage: player <scene.ts|.json> <out.html>");
25
+ process.exit(2);
26
+ }
27
+ const entry = `
28
+ import { compileScene } from "@reframe/core";
29
+ import { renderFrame } from "@reframe/renderer-canvas";
30
+ import sceneIR from ${JSON.stringify(scenePath)};
31
+ const compiled = compileScene(sceneIR);
32
+ const canvas = document.getElementById("c");
33
+ const ctx = canvas.getContext("2d");
34
+ canvas.width = sceneIR.size.width;
35
+ canvas.height = sceneIR.size.height;
36
+ const dur = compiled.duration || 1;
37
+ function frame(now){ renderFrame(ctx, compiled, (now / 1000) % dur); requestAnimationFrame(frame); }
38
+ const ff = document.fonts;
39
+ if (ff && ff.ready) ff.ready.then(() => requestAnimationFrame(frame));
40
+ else requestAnimationFrame(frame);
41
+ `;
42
+ let bundle;
43
+ try {
44
+ bundle = await build({
45
+ stdin: { contents: entry, resolveDir: dirname(scenePath), loader: "ts" },
46
+ bundle: true,
47
+ format: "iife",
48
+ platform: "browser",
49
+ target: "es2022",
50
+ write: false,
51
+ logLevel: "silent",
52
+ alias: { "@reframe/core": CORE, "@reframe/renderer-canvas": RENDERER, "reframe-video": CORE }
53
+ });
54
+ } catch (err) {
55
+ console.error(`failed to bundle ${scenePath}:
56
+ ${err instanceof Error ? err.message : String(err)}`);
57
+ process.exit(1);
58
+ }
59
+ const js = bundle.outputFiles[0].text;
60
+ const faces = (await Promise.all([400, 700, 800].map(fontFace))).join("");
61
+ const html = `<!doctype html>
62
+ <html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
63
+ <title>${basename(scenePath)} \xB7 reframe</title>
64
+ <style>${faces}
65
+ html,body{margin:0;height:100%;background:#06070b;display:grid;place-items:center;font-family:Inter,system-ui}
66
+ canvas{max-width:94vw;max-height:94vh;border-radius:16px;box-shadow:0 24px 90px rgba(0,0,0,.55)}</style></head>
67
+ <body><canvas id="c"></canvas><script>${js}</script></body></html>`;
68
+ await mkdir(dirname(outPath), { recursive: true });
69
+ await writeFile(outPath, html);
70
+ console.log(outPath);
71
+ }
72
+ void main();
package/dist/trace-cli.js CHANGED
@@ -6,12 +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"];
9
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
10
10
  var PROPS_BY_TYPE = {
11
11
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
12
12
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
13
13
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
14
- text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
14
+ text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
15
15
  image: [...COMMON_PROPS, "src", "width", "height"],
16
16
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
17
17
  group: COMMON_PROPS
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The scene camera — a single affine viewport applied to the whole scene.
3
+ *
4
+ * Semantics (look-at): `camera.{x,y}` is the scene point centered in frame,
5
+ * `zoom` scales about it, `rotation` (degrees) turns about it. Defaults
6
+ * (`x=W/2, y=H/2, zoom=1, rotation=0`) are the identity, so a scene without a
7
+ * camera renders byte-identically.
8
+ *
9
+ * The camera is animated through the SAME machinery as nodes: tween / motionPath /
10
+ * behaviors targeting the reserved id `"camera"` with props x/y/zoom/rotation.
11
+ * `cameraTo` is a thin readable wrapper. Because those are ordinary labeled
12
+ * timeline steps, camera keyframes are overlay-addressable for free, so human
13
+ * edits survive AI regeneration.
14
+ */
15
+ import type { CameraIR, Ease, Size, TimelineIR } from "./ir.js";
16
+ import type { Mat2D } from "./evaluate.js";
17
+ /** Reserved timeline/behavior target id for the camera. */
18
+ export declare const CAMERA_ID = "camera";
19
+ /** The animatable camera props (look-at point + zoom + rotation). */
20
+ export declare const CAMERA_PROPS: readonly ["x", "y", "zoom", "rotation"];
21
+ /**
22
+ * The camera's affine matrix: `T(W/2,H/2) · R(rotation) · S(zoom) · T(-x,-y)`,
23
+ * i.e. center the focal point, then zoom/rotate about the frame centre. Defaults
24
+ * collapse to the identity.
25
+ */
26
+ export declare function cameraMatrix(cam: CameraIR, size: Size): Mat2D;
27
+ /** Keyframe the camera: a `tween` on the reserved "camera" target. */
28
+ export declare function cameraTo(props: CameraIR, opts?: {
29
+ duration?: number;
30
+ ease?: Ease;
31
+ label?: string;
32
+ }): TimelineIR;
@@ -49,5 +49,7 @@ export interface CompiledScene {
49
49
  labelTimes: Map<string, LabelSpan>;
50
50
  /** The subset of label spans that come from beat nodes — keyed by beat name. */
51
51
  beatTimes: Map<string, LabelSpan>;
52
+ /** True iff the scene declares or animates a `camera` (gates the camera matrix). */
53
+ hasCamera: boolean;
52
54
  }
53
55
  export declare function compileScene(ir: SceneIR): CompiledScene;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Cursor / pointer motion — a vector mouse pointer that glides across the scene
3
+ * and clicks things (the UI-demo staple). `cursor()` returns a NodeIR (like
4
+ * `devicePreset`); `cursorTo` / `cursorPath` / `cursorClick` return TimelineIR
5
+ * (like `characterPreset`). The pointer's HOTSPOT is the group origin (0,0), so a
6
+ * move lands the tip exactly on a target. Pairs with `deviceScreenPoint` to click
7
+ * UI inside a `devicePreset` screen.
8
+ *
9
+ * nodes: [devicePreset("browser", { id: "d", x, y, scale, content }), cursor({ id: "cur" })]
10
+ * timeline: seq(cursorTo("cur", [start], deviceScreenPoint("browser", dOpts, [lx, ly])),
11
+ * cursorClick("cur", { press: "d-ui-cta" }))
12
+ */
13
+ import type { Ease, NodeIR, TimelineIR } from "./ir.js";
14
+ export type CursorStyle = "arrow" | "dot" | "ring";
15
+ export interface CursorOpts {
16
+ id?: string;
17
+ x?: number;
18
+ y?: number;
19
+ scale?: number;
20
+ opacity?: number;
21
+ style?: CursorStyle;
22
+ /** Pointer body colour (default white for arrow). */
23
+ fill?: string;
24
+ /** Accent for dot/ring body and the click ripple. */
25
+ accent?: string;
26
+ }
27
+ export declare function cursor(opts?: CursorOpts): NodeIR;
28
+ export interface CursorToOpts {
29
+ duration?: number;
30
+ ease?: Ease;
31
+ /** perpendicular bow as a fraction of distance (default 0.12; 0 = straight). */
32
+ arc?: number;
33
+ label?: string;
34
+ }
35
+ /** Glide the cursor from `from` to `to` along a gentle human arc. */
36
+ export declare function cursorTo(id: string, from: [number, number], to: [number, number], opts?: CursorToOpts): TimelineIR;
37
+ export interface CursorPathOpts {
38
+ duration?: number;
39
+ ease?: Ease;
40
+ curviness?: number;
41
+ label?: string;
42
+ }
43
+ /** Move the cursor through a tour of waypoints (one smooth path). */
44
+ export declare function cursorPath(id: string, points: [number, number][], opts?: CursorPathOpts): TimelineIR;
45
+ export interface CursorClickOpts {
46
+ /** overall click duration scale (default 1). */
47
+ speed?: number;
48
+ /** node id to "press" (a quick scale dip) when the cursor clicks it. */
49
+ press?: string;
50
+ /** show the expanding ripple ring (default true). */
51
+ ripple?: boolean;
52
+ label?: string;
53
+ }
54
+ /** A click: the pointer taps, a ripple ring expands, and an optional target presses. */
55
+ export declare function cursorClick(id: string, opts?: CursorClickOpts): TimelineIR;
56
+ /** Two quick clicks. */
57
+ export declare function cursorDouble(id: string, opts?: CursorClickOpts): TimelineIR;
@@ -61,5 +61,9 @@ export declare function deviceBounds(name: DevicePresetName, opts?: DevicePreset
61
61
  width: number;
62
62
  height: number;
63
63
  };
64
+ /** Map a SCREEN-LOCAL point (origin = screen centre, the coords `content` is
65
+ * authored in) to absolute SCENE coords, given the same `opts` passed to
66
+ * `devicePreset`. For aiming a `cursor` at on-screen UI. */
67
+ export declare function deviceScreenPoint(name: DevicePresetName, opts: DevicePresetOpts, local: [number, number]): [number, number];
64
68
  /** Build a device-mockup frame (a group) with a clipped screen content slot. */
65
69
  export declare function devicePreset(name: DevicePresetName, opts?: DevicePresetOpts): NodeIR;
@@ -2,7 +2,7 @@
2
2
  * The eDSL surface: thin factories that build plain IR objects.
3
3
  * `scene()` validates and returns the IR — the return value is the document.
4
4
  */
5
- import type { AudioIR, BehaviorIR, CompositionIR, CompositionSceneEntry, Ease, EllipseProps, GroupProps, ImageProps, LineProps, NodeIR, PathProps, PropValue, RectProps, SceneIR, Size, StateOverride, TextProps, TimelineIR } from "./ir.js";
5
+ import type { AudioIR, BehaviorIR, CameraIR, CompositionIR, CompositionSceneEntry, Ease, EllipseProps, GroupProps, ImageProps, LineProps, NodeIR, PathProps, PropValue, RectProps, SceneIR, Size, StateOverride, TextProps, TimelineIR } from "./ir.js";
6
6
  export interface SceneInput {
7
7
  id: string;
8
8
  size: Size;
@@ -10,6 +10,7 @@ export interface SceneInput {
10
10
  duration?: number;
11
11
  background?: string;
12
12
  nodes: NodeIR[];
13
+ camera?: CameraIR;
13
14
  states?: Record<string, StateOverride>;
14
15
  initial?: string;
15
16
  timeline?: TimelineIR;
@@ -5,8 +5,10 @@ export { compileComposition, type CompiledComposition, type ScenePlacement, } fr
5
5
  export { composeScene, formatComposeReport, type OverlayDoc, type ComposeReport, } from "./compose.js";
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
+ export { cameraTo, cameraMatrix, CAMERA_ID, CAMERA_PROPS } from "./camera.js";
8
9
  export { motionPreset, PRESET_NAMES, type PresetName, type PresetRig, type PresetOpts } from "./presets.js";
9
- export { devicePreset, deviceScreen, deviceScreenCenter, deviceBounds, DEVICE_PRESET_NAMES, type DevicePresetName, type DevicePresetOpts } from "./devicePreset.js";
10
+ export { devicePreset, deviceScreen, deviceScreenCenter, deviceBounds, deviceScreenPoint, DEVICE_PRESET_NAMES, type DevicePresetName, type DevicePresetOpts } from "./devicePreset.js";
11
+ export { cursor, cursorTo, cursorPath, cursorClick, cursorDouble, type CursorStyle, type CursorOpts, type CursorToOpts, type CursorPathOpts, type CursorClickOpts } from "./cursor.js";
10
12
  export { rig, rigPose, poseTo, ikReach, humanoid, ovalPath, type Bone, type RigOpts, type Pose, type HumanoidOpts } from "./rig.js";
11
13
  export { characterPreset, CHARACTER_PRESET_NAMES, type CharacterPresetName, type CharacterPresetOpts } from "./characterPreset.js";
12
14
  export { figure, type FigureStyle, type FigureOpts, type FigurePalette } from "./figure.js";
@@ -31,6 +31,11 @@ export interface BaseProps {
31
31
  skewX?: number;
32
32
  skewY?: number;
33
33
  anchor?: Anchor;
34
+ /**
35
+ * Pin a TOP-LEVEL node to the screen so the scene `camera` does not move it —
36
+ * for HUD / titles / watermark layers. No-op when the scene has no camera.
37
+ */
38
+ fixed?: boolean;
34
39
  }
35
40
  export interface RectProps extends BaseProps {
36
41
  width: number;
@@ -57,12 +62,16 @@ export interface LineProps {
57
62
  opacity?: number;
58
63
  /** 0..1 — how much of the line is drawn (for draw-on effects). */
59
64
  progress?: number;
65
+ /** Pin to the screen so the scene `camera` does not move it (top-level only). */
66
+ fixed?: boolean;
60
67
  }
61
68
  export interface TextProps extends BaseProps {
62
69
  /** Numbers interpolate (count-up) and render via toFixed(contentDecimals). */
63
70
  content: string | number;
64
71
  /** Decimal places when content is numeric (default 0). */
65
72
  contentDecimals?: number;
73
+ /** Group the integer part with thousands separators (e.g. 35,786). */
74
+ contentThousands?: boolean;
66
75
  fontFamily: string;
67
76
  fontSize: number;
68
77
  fontWeight?: number;
@@ -302,6 +311,19 @@ export interface AudioIR {
302
311
  };
303
312
  cues?: AudioCueIR[];
304
313
  }
314
+ /**
315
+ * The scene camera: a viewport over the whole scene. `(x,y)` is the scene point
316
+ * centered in frame (defaults to the frame centre), `zoom` scales about it,
317
+ * `rotation` (degrees) turns about it. Defaults (`x=W/2, y=H/2, zoom=1, rotation=0`)
318
+ * are the identity. Animate it by tweening the reserved target `"camera"`
319
+ * (or the `cameraTo` helper); pin layers out of it with a node's `fixed` flag.
320
+ */
321
+ export interface CameraIR {
322
+ x?: number;
323
+ y?: number;
324
+ zoom?: number;
325
+ rotation?: number;
326
+ }
305
327
  export interface SceneIR {
306
328
  version: 1;
307
329
  id: string;
@@ -312,6 +334,8 @@ export interface SceneIR {
312
334
  duration?: number;
313
335
  background?: string;
314
336
  nodes: NodeIR[];
337
+ /** A viewport over the scene, keyframable via the reserved target "camera". */
338
+ camera?: CameraIR;
315
339
  states?: Record<string, StateOverride>;
316
340
  /** State applied at t=0. */
317
341
  initial?: string;
@@ -121,6 +121,35 @@ bound — e.g. a pulse only during the hold:
121
121
  `oscillate("title", "scale", { amplitude: 0.04, frequency: 1.2 }, { from: 1.5, until: 3.5 })`.
122
122
  Omit the window to run for the whole scene.
123
123
 
124
+ ## Camera (one keyframable viewport)
125
+
126
+ A scene-level camera moves the whole scene at once: a look-at point + zoom +
127
+ rotation, animated over the timeline. Add it as a top-level `camera` field and
128
+ keyframe it with `cameraTo` (or by tweening the reserved target `"camera"`):
129
+
130
+ ```ts
131
+ scene({
132
+ // ...
133
+ camera: { x: W/2, y: H/2, zoom: 1, rotation: 0 }, // (x,y) = scene point centred in frame; defaults = frame centre, zoom 1, rot 0 (= no camera)
134
+ timeline: seq(
135
+ cameraTo({ x: 300, y: 400, zoom: 4 }, { duration: 1.5, ease: "easeInOutCubic", label: "push-in" }), // zoom into a detail
136
+ cameraTo({ x: 800, y: 200, zoom: 2, rotation: -5 }, { duration: 1.2 }), // pan + slight bank
137
+ cameraTo({ x: W/2, y: H/2, zoom: 1, rotation: 0 }, { duration: 1.6 }), // pull back
138
+ ),
139
+ })
140
+ ```
141
+
142
+ - `cameraTo(props, { duration?, ease?, label? })` keyframes the camera; it is a
143
+ `tween` on the `"camera"` target, so `motionPath("camera", pts, …)` (pan along
144
+ a curve) and `oscillate/wiggle("camera", "rotation"|"x"|…)` (handheld drift)
145
+ also work.
146
+ - **Pin HUD/titles to the screen** with `fixed: true` on a TOP-LEVEL node — the
147
+ camera won't move it (for overlays, watermarks, captions).
148
+ - Defaults are the identity, so a scene without a camera is unchanged. Don't name
149
+ a node `"camera"` if you use the scene camera (the id can't be both).
150
+
151
+ See `examples/scenes/camera-demo.ts`.
152
+
124
153
  ## Character rig (skeleton, poses, IK)
125
154
 
126
155
  A first-class, declarative character rig that **compiles to plain IR** (nested
@@ -199,6 +228,33 @@ const T = splitText("MOTION IS DATA", { id: "t", x: 960, y: 470, fontSize: 130 }
199
228
  Every effect is seeded (same `seed` → identical) and pure keyframes. To time a
200
229
  `textLoop` window, add up the `textIn` beat length (≈ `(n-1)·stagger + glyphDur`).
201
230
 
231
+ ## Cursor (UI demos)
232
+
233
+ A vector mouse pointer that glides across the scene and clicks things — for app
234
+ walkthroughs. `cursor()` returns a node; the moves/clicks return timeline steps.
235
+ The pointer's **hotspot is the group origin**, so a move lands the tip on a target.
236
+
237
+ - `cursor({ id, x, y, scale?, opacity?, style?, accent? }) → NodeIR` — styles
238
+ `arrow` (default), `dot`, `ring`. Draw it LAST so it sits on top. Carries a
239
+ hidden `${id}-ripple` ring for clicks.
240
+ - `cursorTo(id, from, to, { duration?, ease?, arc? }) → TimelineIR` — glide along
241
+ a gentle human arc (`arc` is the bow, default 0.12). Thread the position: start
242
+ = the node's `x/y`, each `to` becomes the next `from`.
243
+ - `cursorPath(id, points, opts)` — a multi-stop tour through waypoints.
244
+ - `cursorClick(id, { press?, ripple?, label? })` / `cursorDouble(...)` — the
245
+ pointer taps, a ripple ring expands, and the `press` node (a button) dips. Pass
246
+ a unique `label` when you click more than once in a scene.
247
+ - `deviceScreenPoint(name, deviceOpts, [lx, ly]) → [x, y]` — map a UI element's
248
+ screen-local coords (the coords `devicePreset` `content` is authored in) to
249
+ scene coords, so the cursor clicks on-screen UI precisely (account for the
250
+ device's `scale` at click time and any `slot` offset).
251
+
252
+ ```ts
253
+ // nodes: devicePreset("browser", { id:"d", x, y, scale:0.88, content }), cursor({ id:"cur" })
254
+ const cta = deviceScreenPoint("browser", { x, y, scale: 0.88 }, [lx, ly]);
255
+ seq(cursorTo("cur", [sx, sy], cta), cursorClick("cur", { press: "browser-ui-cta" }))
256
+ ```
257
+
202
258
  ## Audio (optional)
203
259
 
204
260
  Label-anchored sound design — cues follow retiming and regeneration:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reframe-video",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
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",