reframe-video 0.5.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;
@@ -1249,6 +1291,7 @@ var init_evaluate = __esm({
1249
1291
  "../core/src/evaluate.ts"() {
1250
1292
  "use strict";
1251
1293
  init_behaviors();
1294
+ init_camera();
1252
1295
  init_interpolate();
1253
1296
  init_path();
1254
1297
  }
@@ -1310,6 +1353,7 @@ var init_src = __esm({
1310
1353
  init_compose();
1311
1354
  init_compile();
1312
1355
  init_path();
1356
+ init_camera();
1313
1357
  init_presets();
1314
1358
  init_devicePreset();
1315
1359
  init_cursor();
@@ -2334,6 +2378,7 @@ var ROOT2 = PACKAGED ? resolve4(HERE2, "..") : resolve4(HERE2, "..", "..", "..")
2334
2378
  var USER_CWD = process.env.INIT_CWD ?? process.cwd();
2335
2379
  var RENDER_CLI = PACKAGED ? join6(ROOT2, "dist", "cli.js") : join6(ROOT2, "packages", "render-cli", "src", "cli.ts");
2336
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");
2337
2382
  var ANALYZE = PACKAGED ? join6(ROOT2, "dist", "analyze.js") : join6(ROOT2, "benchmark", "harness", "motion", "analyze.ts");
2338
2383
  var TRACE = PACKAGED ? join6(ROOT2, "dist", "trace-cli.js") : join6(ROOT2, "benchmark", "harness", "motion", "trace-cli.ts");
2339
2384
  var CMD = PACKAGED ? "reframe" : "pnpm reframe";
@@ -2345,6 +2390,8 @@ usage:
2345
2390
  ${CMD} logo <logo.svg|brand-slug> ["Name"] [--motion <preset>] [--energy 0..1] [--seed N] [-o out.mp4]
2346
2391
  animate a logo into a sting (presets: draw-bloom, punch-in,
2347
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)
2348
2395
  ${CMD} preview open the scrub/edit UI (lists scenes in your directory)
2349
2396
  ${CMD} new <scene-name> scaffold <scene-name>.ts in your directory
2350
2397
  ${CMD} labels <scene.ts|.json> print the event clock (label \u2192 exact seconds; for sound design / timing)
@@ -2489,6 +2536,21 @@ ${USAGE}`);
2489
2536
  await (PACKAGED ? run(process.execPath, [LABELS, inputPath]) : run("npx", ["tsx", LABELS, inputPath]))
2490
2537
  );
2491
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
+ }
2492
2554
  case "logo": {
2493
2555
  const positional = [];
2494
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",
@@ -2701,6 +2755,17 @@ var ANCHOR_FACTORS = {
2701
2755
  };
2702
2756
  var TEXT_ALIGN = { 0: "left", 0.5: "center", 1: "right" };
2703
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
+ }
2704
2769
  function behaviorEnvelope(b, t) {
2705
2770
  const from = b.from ?? Number.NEGATIVE_INFINITY;
2706
2771
  const until = b.until ?? Number.POSITIVE_INFINITY;
@@ -2920,7 +2985,7 @@ function evaluate(compiled, t) {
2920
2985
  id,
2921
2986
  transform: matrix,
2922
2987
  opacity,
2923
- content: typeof raw === "number" ? raw.toFixed(decimals) : raw,
2988
+ content: typeof raw === "number" ? formatNumber(raw, decimals, node.props.contentThousands === true) : raw,
2924
2989
  fontFamily: str(id, "fontFamily", node.props.fontFamily),
2925
2990
  fontSize: num(id, "fontSize", node.props.fontSize),
2926
2991
  fontWeight: num(id, "fontWeight", node.props.fontWeight ?? 400),
@@ -2934,7 +2999,19 @@ function evaluate(compiled, t) {
2934
2999
  }
2935
3000
  }
2936
3001
  };
2937
- 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
+ }
2938
3015
  return ops;
2939
3016
  }
2940
3017
 
@@ -3015,6 +3092,8 @@ function sketchToTimeline(sketch, nodeIds) {
3015
3092
  return par(...steps);
3016
3093
  }
3017
3094
  export {
3095
+ CAMERA_ID,
3096
+ CAMERA_PROPS2 as CAMERA_PROPS,
3018
3097
  CHARACTER_PRESET_NAMES,
3019
3098
  DEFAULT_CROSSFADE,
3020
3099
  DEFAULT_FPS,
@@ -3029,6 +3108,8 @@ export {
3029
3108
  SFX_DURATION,
3030
3109
  SceneValidationError,
3031
3110
  beat,
3111
+ cameraMatrix,
3112
+ cameraTo,
3032
3113
  characterPreset,
3033
3114
  collectImageSrcs,
3034
3115
  compileComposition,
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;
@@ -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,6 +5,7 @@ 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
10
  export { devicePreset, deviceScreen, deviceScreenCenter, deviceBounds, deviceScreenPoint, DEVICE_PRESET_NAMES, type DevicePresetName, type DevicePresetOpts } from "./devicePreset.js";
10
11
  export { cursor, cursorTo, cursorPath, cursorClick, cursorDouble, type CursorStyle, type CursorOpts, type CursorToOpts, type CursorPathOpts, type CursorClickOpts } from "./cursor.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reframe-video",
3
- "version": "0.5.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",