reframe-video 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,6 +6,22 @@ var DEFAULT_MOTIONPATH_DURATION = 1;
6
6
  var DEFAULT_FPS = 30;
7
7
 
8
8
  // ../core/src/path.ts
9
+ function pathBBox(d) {
10
+ const nums = d.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi);
11
+ if (!nums || nums.length < 2) return [0, 0, 1, 1];
12
+ let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
13
+ for (let i = 0; i + 1 < nums.length; i += 2) {
14
+ const x = parseFloat(nums[i]);
15
+ const y = parseFloat(nums[i + 1]);
16
+ if (x < minx) minx = x;
17
+ if (x > maxx) maxx = x;
18
+ if (y < miny) miny = y;
19
+ if (y > maxy) maxy = y;
20
+ }
21
+ const w = maxx - minx;
22
+ const h = maxy - miny;
23
+ return [minx, miny, w > 0 ? w : 1, h > 0 ? h : 1];
24
+ }
9
25
  function locate(segCount, u) {
10
26
  if (segCount <= 0) return { i: 0, t: 0 };
11
27
  const clamped = Math.max(0, Math.min(1, u));
@@ -134,6 +150,14 @@ function compileScene(ir) {
134
150
  }
135
151
  }
136
152
  }
153
+ const cameraIsNode = nodeById.has("camera");
154
+ if (!cameraIsNode) {
155
+ const cam = ir.camera ?? {};
156
+ initialValues.set(key("camera", "x"), cam.x ?? ir.size.width / 2);
157
+ initialValues.set(key("camera", "y"), cam.y ?? ir.size.height / 2);
158
+ initialValues.set(key("camera", "zoom"), cam.zoom ?? 1);
159
+ initialValues.set(key("camera", "rotation"), cam.rotation ?? 0);
160
+ }
137
161
  const segments = /* @__PURE__ */ new Map();
138
162
  const motionPaths = /* @__PURE__ */ new Map();
139
163
  const current = new Map(initialValues);
@@ -294,6 +318,7 @@ function compileScene(ir) {
294
318
  const inferredEnd = ir.timeline ? walk(ir.timeline, 0) : 0;
295
319
  for (const list of segments.values()) list.sort((a, b) => a.t0 - b.t0);
296
320
  for (const list of motionPaths.values()) list.sort((a, b) => a.t0 - b.t0);
321
+ const hasCamera = !cameraIsNode && (ir.camera !== void 0 || motionPaths.has("camera") || [...segments.keys()].some((k) => k.startsWith("camera.")));
297
322
  return {
298
323
  ir,
299
324
  duration: ir.duration ?? inferredEnd,
@@ -303,17 +328,19 @@ function compileScene(ir) {
303
328
  nodeById,
304
329
  nodeOrder,
305
330
  labelTimes,
306
- beatTimes
331
+ beatTimes,
332
+ hasCamera
307
333
  };
308
334
  }
309
335
 
310
336
  // ../core/src/validate.ts
311
- var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor"];
337
+ var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed"];
338
+ var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
312
339
  var PROPS_BY_TYPE = {
313
340
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
314
341
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
315
342
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress"],
316
- text: [...COMMON_PROPS, "content", "contentDecimals", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
343
+ text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
317
344
  image: [...COMMON_PROPS, "src", "width", "height"],
318
345
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
319
346
  group: COMMON_PROPS
@@ -330,12 +357,34 @@ ${problems.map((p) => ` - ${p}`).join("\n")}`);
330
357
  function validateScene(ir) {
331
358
  const problems = [];
332
359
  const nodeById = /* @__PURE__ */ new Map();
360
+ const checkPaint = (where, value) => {
361
+ if (typeof value !== "object" || value === null) return;
362
+ const g = value;
363
+ if (g.kind !== "linear" && g.kind !== "radial" && g.kind !== "conic") {
364
+ problems.push(`${where}: a paint object must be a gradient with kind "linear" / "radial" / "conic"`);
365
+ return;
366
+ }
367
+ if (!Array.isArray(g.stops) || g.stops.length === 0) {
368
+ problems.push(`${where}: gradient "${g.kind}" needs at least one color stop`);
369
+ return;
370
+ }
371
+ g.stops.forEach((s, i) => {
372
+ const st = s;
373
+ if (typeof st?.color !== "string") problems.push(`${where}: gradient stop ${i} needs a color string`);
374
+ if (typeof st?.offset !== "number" || st.offset < 0 || st.offset > 1) {
375
+ problems.push(`${where}: gradient stop ${i} "offset" must be a number in 0..1`);
376
+ }
377
+ });
378
+ };
333
379
  const collect = (nodes) => {
334
380
  for (const node of nodes) {
335
381
  if (nodeById.has(node.id)) {
336
382
  problems.push(`duplicate node id "${node.id}" \u2014 every node id must be unique`);
337
383
  }
338
384
  nodeById.set(node.id, node);
385
+ const props = node.props;
386
+ checkPaint(`node "${node.id}" fill`, props.fill);
387
+ checkPaint(`node "${node.id}" stroke`, props.stroke);
339
388
  if (node.type === "group") {
340
389
  const clip = node.props.clip;
341
390
  if (clip) {
@@ -352,6 +401,14 @@ function validateScene(ir) {
352
401
  };
353
402
  collect(ir.nodes);
354
403
  const checkProps = (where, nodeId, props) => {
404
+ if (nodeId === "camera" && !nodeById.has("camera")) {
405
+ for (const key2 of Object.keys(props)) {
406
+ if (!CAMERA_PROPS.includes(key2)) {
407
+ problems.push(`${where}: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
408
+ }
409
+ }
410
+ return;
411
+ }
355
412
  const node = nodeById.get(nodeId);
356
413
  if (!node) {
357
414
  problems.push(
@@ -419,12 +476,15 @@ function validateScene(ir) {
419
476
  break;
420
477
  case "motionPath": {
421
478
  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}"`);
479
+ const isSceneCamera = tl.target === "camera" && !node;
480
+ if (!isSceneCamera) {
481
+ if (!node) {
482
+ problems.push(
483
+ `${path2}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
484
+ );
485
+ } else if (node.type === "line") {
486
+ problems.push(`${path2}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
487
+ }
428
488
  }
429
489
  if (tl.points.length < 1) problems.push(`${path2}: motionPath "${tl.target}" needs at least 1 point`);
430
490
  if (tl.duration !== void 0 && tl.duration <= 0) {
@@ -469,6 +529,18 @@ function validateScene(ir) {
469
529
  if (ir.duration !== void 0 && ir.duration <= 0) {
470
530
  problems.push("scene duration must be > 0");
471
531
  }
532
+ if (ir.camera) {
533
+ if (nodeById.has("camera")) {
534
+ 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)`);
535
+ }
536
+ for (const [key2, value] of Object.entries(ir.camera)) {
537
+ if (!CAMERA_PROPS.includes(key2)) {
538
+ problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
539
+ } else if (typeof value !== "number") {
540
+ problems.push(`camera.${key2} must be a number`);
541
+ }
542
+ }
543
+ }
472
544
  const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
473
545
  for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
474
546
  if (typeof cue.at === "string" && !labels.has(cue.at)) {
@@ -863,6 +935,58 @@ function formatComposeReport(report) {
863
935
  return lines.join("\n");
864
936
  }
865
937
 
938
+ // ../core/src/camera.ts
939
+ var CAMERA_ID = "camera";
940
+ var CAMERA_PROPS2 = ["x", "y", "zoom", "rotation"];
941
+ function cameraMatrix(cam, size) {
942
+ const W = size.width;
943
+ const H = size.height;
944
+ const x = cam.x ?? W / 2;
945
+ const y = cam.y ?? H / 2;
946
+ const zoom = cam.zoom ?? 1;
947
+ const r = (cam.rotation ?? 0) * Math.PI / 180;
948
+ const a = Math.cos(r) * zoom;
949
+ const b = Math.sin(r) * zoom;
950
+ const c = b === 0 ? 0 : -b;
951
+ const d = Math.cos(r) * zoom;
952
+ return [a, b, c, d, W / 2 - a * x - c * y, H / 2 - b * x - d * y];
953
+ }
954
+ function cameraTo(props, opts = {}) {
955
+ return tween(CAMERA_ID, props, opts);
956
+ }
957
+
958
+ // ../core/src/gradient.ts
959
+ function isGradient(p) {
960
+ return typeof p === "object" && p !== null && (p.kind === "linear" || p.kind === "radial" || p.kind === "conic");
961
+ }
962
+ function toStops(stops) {
963
+ if (stops.length > 0 && typeof stops[0] === "object") return stops;
964
+ const cs = stops;
965
+ const n3 = cs.length;
966
+ return cs.map((color, i) => ({ offset: n3 <= 1 ? 0 : i / (n3 - 1), color }));
967
+ }
968
+ function linearGradient(stops, opts = {}) {
969
+ return { kind: "linear", ...opts.angle !== void 0 && { angle: opts.angle }, stops: toStops(stops) };
970
+ }
971
+ function radialGradient(stops, opts = {}) {
972
+ return {
973
+ kind: "radial",
974
+ ...opts.cx !== void 0 && { cx: opts.cx },
975
+ ...opts.cy !== void 0 && { cy: opts.cy },
976
+ ...opts.r !== void 0 && { r: opts.r },
977
+ stops: toStops(stops)
978
+ };
979
+ }
980
+ function conicGradient(stops, opts = {}) {
981
+ return {
982
+ kind: "conic",
983
+ ...opts.angle !== void 0 && { angle: opts.angle },
984
+ ...opts.cx !== void 0 && { cx: opts.cx },
985
+ ...opts.cy !== void 0 && { cy: opts.cy },
986
+ stops: toStops(stops)
987
+ };
988
+ }
989
+
866
990
  // ../core/src/presets.ts
867
991
  var PRESET_NAMES = [
868
992
  "draw-bloom",
@@ -2701,6 +2825,17 @@ var ANCHOR_FACTORS = {
2701
2825
  };
2702
2826
  var TEXT_ALIGN = { 0: "left", 0.5: "center", 1: "right" };
2703
2827
  var TEXT_BASELINE = { 0: "top", 0.5: "middle", 1: "bottom" };
2828
+ function formatNumber(value, decimals, thousands) {
2829
+ const fixed = value.toFixed(decimals);
2830
+ if (!thousands) return fixed;
2831
+ const neg = fixed.startsWith("-");
2832
+ const body = neg ? fixed.slice(1) : fixed;
2833
+ const dot = body.indexOf(".");
2834
+ const intPart = dot === -1 ? body : body.slice(0, dot);
2835
+ const frac = dot === -1 ? "" : body.slice(dot);
2836
+ const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
2837
+ return (neg ? "-" : "") + grouped + frac;
2838
+ }
2704
2839
  function behaviorEnvelope(b, t) {
2705
2840
  const from = b.from ?? Number.NEGATIVE_INFINITY;
2706
2841
  const until = b.until ?? Number.POSITIVE_INFINITY;
@@ -2854,8 +2989,10 @@ function evaluate(compiled, t) {
2854
2989
  const height = num(id, "height", node.props.height);
2855
2990
  const [ax, ay] = ANCHOR_FACTORS[node.props.anchor ?? "top-left"];
2856
2991
  const strokeWidth = num(id, "strokeWidth", node.props.strokeWidth ?? 1);
2857
- const fill = opt(id, "fill", node.props.fill);
2858
- const stroke = opt(id, "stroke", node.props.stroke);
2992
+ const fillP = node.props.fill;
2993
+ const strokeP = node.props.stroke;
2994
+ const fill = isGradient(fillP) ? fillP : opt(id, "fill", fillP);
2995
+ const stroke = isGradient(strokeP) ? strokeP : opt(id, "stroke", strokeP);
2859
2996
  ops.push({
2860
2997
  type: node.type,
2861
2998
  id,
@@ -2893,17 +3030,22 @@ function evaluate(compiled, t) {
2893
3030
  case "path": {
2894
3031
  const ox = num(id, "originX", node.props.originX ?? 0);
2895
3032
  const oy = num(id, "originY", node.props.originY ?? 0);
2896
- const fill = opt(id, "fill", node.props.fill);
2897
- const stroke = opt(id, "stroke", node.props.stroke);
3033
+ const fillP = node.props.fill;
3034
+ const strokeP = node.props.stroke;
3035
+ const fill = isGradient(fillP) ? fillP : opt(id, "fill", fillP);
3036
+ const stroke = isGradient(strokeP) ? strokeP : opt(id, "stroke", strokeP);
3037
+ const dStr = str(id, "d", node.props.d);
3038
+ const needsBox = isGradient(fill) || isGradient(stroke);
2898
3039
  ops.push({
2899
3040
  type: "path",
2900
3041
  id,
2901
3042
  transform: ox === 0 && oy === 0 ? matrix : multiply(matrix, [1, 0, 0, 1, -ox, -oy]),
2902
3043
  opacity,
2903
- d: str(id, "d", node.props.d),
3044
+ d: dStr,
2904
3045
  progress: Math.max(0, Math.min(1, num(id, "progress", node.props.progress ?? 1))),
2905
3046
  ...fill !== void 0 && { fill },
2906
3047
  ...stroke !== void 0 && { stroke, strokeWidth: num(id, "strokeWidth", node.props.strokeWidth ?? 1) },
3048
+ ...needsBox && { bbox: pathBBox(dStr) },
2907
3049
  ...clipSpread
2908
3050
  });
2909
3051
  return;
@@ -2920,7 +3062,7 @@ function evaluate(compiled, t) {
2920
3062
  id,
2921
3063
  transform: matrix,
2922
3064
  opacity,
2923
- content: typeof raw === "number" ? raw.toFixed(decimals) : raw,
3065
+ content: typeof raw === "number" ? formatNumber(raw, decimals, node.props.contentThousands === true) : raw,
2924
3066
  fontFamily: str(id, "fontFamily", node.props.fontFamily),
2925
3067
  fontSize: num(id, "fontSize", node.props.fontSize),
2926
3068
  fontWeight: num(id, "fontWeight", node.props.fontWeight ?? 400),
@@ -2934,7 +3076,19 @@ function evaluate(compiled, t) {
2934
3076
  }
2935
3077
  }
2936
3078
  };
2937
- for (const node of compiled.ir.nodes) walk(node, IDENTITY, 1, []);
3079
+ const cameraRoot = compiled.hasCamera ? cameraMatrix(
3080
+ {
3081
+ x: num("camera", "x", compiled.ir.size.width / 2),
3082
+ y: num("camera", "y", compiled.ir.size.height / 2),
3083
+ zoom: num("camera", "zoom", 1),
3084
+ rotation: num("camera", "rotation", 0)
3085
+ },
3086
+ compiled.ir.size
3087
+ ) : IDENTITY;
3088
+ for (const node of compiled.ir.nodes) {
3089
+ const root = compiled.hasCamera && node.props.fixed ? IDENTITY : cameraRoot;
3090
+ walk(node, root, 1, []);
3091
+ }
2938
3092
  return ops;
2939
3093
  }
2940
3094
 
@@ -3015,6 +3169,8 @@ function sketchToTimeline(sketch, nodeIds) {
3015
3169
  return par(...steps);
3016
3170
  }
3017
3171
  export {
3172
+ CAMERA_ID,
3173
+ CAMERA_PROPS2 as CAMERA_PROPS,
3018
3174
  CHARACTER_PRESET_NAMES,
3019
3175
  DEFAULT_CROSSFADE,
3020
3176
  DEFAULT_FPS,
@@ -3029,12 +3185,15 @@ export {
3029
3185
  SFX_DURATION,
3030
3186
  SceneValidationError,
3031
3187
  beat,
3188
+ cameraMatrix,
3189
+ cameraTo,
3032
3190
  characterPreset,
3033
3191
  collectImageSrcs,
3034
3192
  compileComposition,
3035
3193
  compileScene,
3036
3194
  composeScene,
3037
3195
  composition,
3196
+ conicGradient,
3038
3197
  cursor,
3039
3198
  cursorClick,
3040
3199
  cursorDouble,
@@ -3054,8 +3213,10 @@ export {
3054
3213
  ikReach,
3055
3214
  image,
3056
3215
  isColor,
3216
+ isGradient,
3057
3217
  lerpValue,
3058
3218
  line,
3219
+ linearGradient,
3059
3220
  motionOp,
3060
3221
  motionOpLabel,
3061
3222
  motionPath,
@@ -3068,6 +3229,7 @@ export {
3068
3229
  pathPoint,
3069
3230
  pathTangentAngle,
3070
3231
  poseTo,
3232
+ radialGradient,
3071
3233
  rect,
3072
3234
  resolveAudioPlan,
3073
3235
  resolveCompositionAudioPlan,
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
@@ -330,12 +341,34 @@ ${problems.map((p) => ` - ${p}`).join("\n")}`);
330
341
  function validateScene(ir) {
331
342
  const problems = [];
332
343
  const nodeById = /* @__PURE__ */ new Map();
344
+ const checkPaint = (where, value) => {
345
+ if (typeof value !== "object" || value === null) return;
346
+ const g = value;
347
+ if (g.kind !== "linear" && g.kind !== "radial" && g.kind !== "conic") {
348
+ problems.push(`${where}: a paint object must be a gradient with kind "linear" / "radial" / "conic"`);
349
+ return;
350
+ }
351
+ if (!Array.isArray(g.stops) || g.stops.length === 0) {
352
+ problems.push(`${where}: gradient "${g.kind}" needs at least one color stop`);
353
+ return;
354
+ }
355
+ g.stops.forEach((s, i) => {
356
+ const st = s;
357
+ if (typeof st?.color !== "string") problems.push(`${where}: gradient stop ${i} needs a color string`);
358
+ if (typeof st?.offset !== "number" || st.offset < 0 || st.offset > 1) {
359
+ problems.push(`${where}: gradient stop ${i} "offset" must be a number in 0..1`);
360
+ }
361
+ });
362
+ };
333
363
  const collect = (nodes) => {
334
364
  for (const node of nodes) {
335
365
  if (nodeById.has(node.id)) {
336
366
  problems.push(`duplicate node id "${node.id}" \u2014 every node id must be unique`);
337
367
  }
338
368
  nodeById.set(node.id, node);
369
+ const props = node.props;
370
+ checkPaint(`node "${node.id}" fill`, props.fill);
371
+ checkPaint(`node "${node.id}" stroke`, props.stroke);
339
372
  if (node.type === "group") {
340
373
  const clip = node.props.clip;
341
374
  if (clip) {
@@ -352,6 +385,14 @@ function validateScene(ir) {
352
385
  };
353
386
  collect(ir.nodes);
354
387
  const checkProps = (where, nodeId, props) => {
388
+ if (nodeId === "camera" && !nodeById.has("camera")) {
389
+ for (const key2 of Object.keys(props)) {
390
+ if (!CAMERA_PROPS.includes(key2)) {
391
+ problems.push(`${where}: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
392
+ }
393
+ }
394
+ return;
395
+ }
355
396
  const node = nodeById.get(nodeId);
356
397
  if (!node) {
357
398
  problems.push(
@@ -419,12 +460,15 @@ function validateScene(ir) {
419
460
  break;
420
461
  case "motionPath": {
421
462
  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}"`);
463
+ const isSceneCamera = tl.target === "camera" && !node;
464
+ if (!isSceneCamera) {
465
+ if (!node) {
466
+ problems.push(
467
+ `${path3}: motionPath targets unknown node "${tl.target}" \u2014 known ids: ${[...nodeById.keys()].join(", ")}`
468
+ );
469
+ } else if (node.type === "line") {
470
+ problems.push(`${path3}: motionPath cannot target a line (no x/y) \u2014 "${tl.target}"`);
471
+ }
428
472
  }
429
473
  if (tl.points.length < 1) problems.push(`${path3}: motionPath "${tl.target}" needs at least 1 point`);
430
474
  if (tl.duration !== void 0 && tl.duration <= 0) {
@@ -469,6 +513,18 @@ function validateScene(ir) {
469
513
  if (ir.duration !== void 0 && ir.duration <= 0) {
470
514
  problems.push("scene duration must be > 0");
471
515
  }
516
+ if (ir.camera) {
517
+ if (nodeById.has("camera")) {
518
+ 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)`);
519
+ }
520
+ for (const [key2, value] of Object.entries(ir.camera)) {
521
+ if (!CAMERA_PROPS.includes(key2)) {
522
+ problems.push(`camera: "${key2}" is not a camera prop \u2014 valid props: ${CAMERA_PROPS.join(", ")}`);
523
+ } else if (typeof value !== "number") {
524
+ problems.push(`camera.${key2} must be a number`);
525
+ }
526
+ }
527
+ }
472
528
  const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
473
529
  for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
474
530
  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();
@@ -1,5 +1,30 @@
1
1
  // ../renderer-canvas/src/index.ts
2
2
  import { evaluate } from "@reframe/core";
3
+ function resolvePaint(ctx, paint, box) {
4
+ if (typeof paint === "string") return paint;
5
+ const { x, y, w, h } = box;
6
+ let g;
7
+ if (paint.kind === "linear") {
8
+ const a = (paint.angle ?? 0) * Math.PI / 180;
9
+ const dx = Math.cos(a);
10
+ const dy = Math.sin(a);
11
+ const cx = x + w / 2;
12
+ const cy = y + h / 2;
13
+ const half = Math.abs(dx) * (w / 2) + Math.abs(dy) * (h / 2);
14
+ g = ctx.createLinearGradient(cx - dx * half, cy - dy * half, cx + dx * half, cy + dy * half);
15
+ } else if (paint.kind === "radial") {
16
+ const cx = x + (paint.cx ?? 0.5) * w;
17
+ const cy = y + (paint.cy ?? 0.5) * h;
18
+ const r = Math.max((paint.r ?? 0.5) * Math.max(w, h), 1e-4);
19
+ g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
20
+ } else {
21
+ const cx = x + (paint.cx ?? 0.5) * w;
22
+ const cy = y + (paint.cy ?? 0.5) * h;
23
+ g = ctx.createConicGradient((paint.angle ?? 0) * Math.PI / 180, cx, cy);
24
+ }
25
+ for (const s of paint.stops) g.addColorStop(Math.max(0, Math.min(1, s.offset)), s.color);
26
+ return g;
27
+ }
3
28
  function renderFrame(ctx, compiled, t, images) {
4
29
  const { size, background } = compiled.ir;
5
30
  ctx.setTransform(1, 0, 0, 1, 0, 0);
@@ -32,6 +57,7 @@ function drawDisplayList(ctx, ops, images) {
32
57
  ctx.globalAlpha = Math.max(0, Math.min(1, op.opacity));
33
58
  switch (op.type) {
34
59
  case "rect": {
60
+ const box = { x: op.offsetX, y: op.offsetY, w: op.width, h: op.height };
35
61
  ctx.beginPath();
36
62
  if (op.radius && op.radius > 0) {
37
63
  ctx.roundRect(op.offsetX, op.offsetY, op.width, op.height, op.radius);
@@ -39,17 +65,18 @@ function drawDisplayList(ctx, ops, images) {
39
65
  ctx.rect(op.offsetX, op.offsetY, op.width, op.height);
40
66
  }
41
67
  if (op.fill) {
42
- ctx.fillStyle = op.fill;
68
+ ctx.fillStyle = resolvePaint(ctx, op.fill, box);
43
69
  ctx.fill();
44
70
  }
45
71
  if (op.stroke) {
46
- ctx.strokeStyle = op.stroke;
72
+ ctx.strokeStyle = resolvePaint(ctx, op.stroke, box);
47
73
  ctx.lineWidth = op.strokeWidth ?? 1;
48
74
  ctx.stroke();
49
75
  }
50
76
  break;
51
77
  }
52
78
  case "ellipse": {
79
+ const box = { x: op.offsetX, y: op.offsetY, w: op.width, h: op.height };
53
80
  ctx.beginPath();
54
81
  ctx.ellipse(
55
82
  op.offsetX + op.width / 2,
@@ -61,11 +88,11 @@ function drawDisplayList(ctx, ops, images) {
61
88
  Math.PI * 2
62
89
  );
63
90
  if (op.fill) {
64
- ctx.fillStyle = op.fill;
91
+ ctx.fillStyle = resolvePaint(ctx, op.fill, box);
65
92
  ctx.fill();
66
93
  }
67
94
  if (op.stroke) {
68
- ctx.strokeStyle = op.stroke;
95
+ ctx.strokeStyle = resolvePaint(ctx, op.stroke, box);
69
96
  ctx.lineWidth = op.strokeWidth ?? 1;
70
97
  ctx.stroke();
71
98
  }
@@ -102,12 +129,13 @@ function drawDisplayList(ctx, ops, images) {
102
129
  }
103
130
  case "path": {
104
131
  const p = new Path2D(op.d);
132
+ const box = op.bbox ? { x: op.bbox[0], y: op.bbox[1], w: op.bbox[2], h: op.bbox[3] } : { x: 0, y: 0, w: 1, h: 1 };
105
133
  if (op.fill) {
106
- ctx.fillStyle = op.fill;
134
+ ctx.fillStyle = resolvePaint(ctx, op.fill, box);
107
135
  ctx.fill(p);
108
136
  }
109
137
  if (op.stroke && (op.strokeWidth ?? 1) > 0) {
110
- ctx.strokeStyle = op.stroke;
138
+ ctx.strokeStyle = resolvePaint(ctx, op.stroke, box);
111
139
  ctx.lineWidth = op.strokeWidth ?? 1;
112
140
  ctx.lineJoin = "round";
113
141
  ctx.lineCap = "round";
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