reframe-video 0.6.4 → 0.6.5

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
@@ -380,6 +380,7 @@ function validateScene(ir) {
380
380
  if (typeof props.blur === "number" && props.blur < 0) problems.push(`node "${node.id}": blur must be >= 0`);
381
381
  if (typeof props.shadowBlur === "number" && props.shadowBlur < 0) problems.push(`node "${node.id}": shadowBlur must be >= 0`);
382
382
  if (typeof props.blend === "string" && !BLEND_MODES.has(props.blend)) problems.push(`node "${node.id}": unknown blend "${props.blend}" \u2014 use ${[...BLEND_MODES].join(", ")}`);
383
+ if (typeof props.fit === "string" && !IMAGE_FITS.has(props.fit)) problems.push(`node "${node.id}": unknown fit "${props.fit}" \u2014 use ${[...IMAGE_FITS].join(", ")}`);
383
384
  if (node.type === "group") {
384
385
  const clip = node.props.clip;
385
386
  if (clip) {
@@ -594,7 +595,7 @@ function validateComposition(comp) {
594
595
  }
595
596
  if (problems.length > 0) throw new SceneValidationError(problems);
596
597
  }
597
- var FX_PROPS, BLEND_MODES, COMMON_PROPS, CAMERA_PROPS, PROPS_BY_TYPE, SceneValidationError, TRANSITIONS;
598
+ var FX_PROPS, BLEND_MODES, IMAGE_FITS, COMMON_PROPS, CAMERA_PROPS, PROPS_BY_TYPE, SceneValidationError, TRANSITIONS;
598
599
  var init_validate = __esm({
599
600
  "../core/src/validate.ts"() {
600
601
  "use strict";
@@ -612,6 +613,7 @@ var init_validate = __esm({
612
613
  "hard-light",
613
614
  "difference"
614
615
  ]);
616
+ IMAGE_FITS = /* @__PURE__ */ new Set(["fill", "cover"]);
615
617
  COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
616
618
  CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
617
619
  PROPS_BY_TYPE = {
@@ -619,7 +621,7 @@ var init_validate = __esm({
619
621
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
620
622
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
621
623
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
622
- image: [...COMMON_PROPS, "src", "width", "height"],
624
+ image: [...COMMON_PROPS, "src", "width", "height", "fit"],
623
625
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
624
626
  group: COMMON_PROPS
625
627
  };
@@ -341,7 +341,7 @@
341
341
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
342
342
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
343
343
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
344
- image: [...COMMON_PROPS, "src", "width", "height"],
344
+ image: [...COMMON_PROPS, "src", "width", "height", "fit"],
345
345
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
346
346
  group: COMMON_PROPS
347
347
  };
@@ -780,6 +780,7 @@
780
780
  height,
781
781
  offsetX: -width * ax,
782
782
  offsetY: -height * ay,
783
+ ...node.props.fit && node.props.fit !== "fill" ? { fit: node.props.fit } : {},
783
784
  ...fx,
784
785
  ...clipSpread
785
786
  });
@@ -972,7 +973,13 @@
972
973
  case "image": {
973
974
  const img = images2?.get(op.src);
974
975
  if (img) {
975
- ctx2.drawImage(img, op.offsetX, op.offsetY, op.width, op.height);
976
+ if (op.fit === "cover") {
977
+ const [iw, ih] = intrinsicSize(img);
978
+ const { sx, sy, sw, sh } = coverRect(iw, ih, op.width, op.height);
979
+ ctx2.drawImage(img, sx, sy, sw, sh, op.offsetX, op.offsetY, op.width, op.height);
980
+ } else {
981
+ ctx2.drawImage(img, op.offsetX, op.offsetY, op.width, op.height);
982
+ }
976
983
  } else {
977
984
  ctx2.fillStyle = "#2A2A30";
978
985
  ctx2.fillRect(op.offsetX, op.offsetY, op.width, op.height);
@@ -1029,6 +1036,17 @@
1029
1036
  function mapBlend(blend) {
1030
1037
  return blend === "add" ? "lighter" : blend;
1031
1038
  }
1039
+ function coverRect(iw, ih, dw, dh) {
1040
+ if (iw <= 0 || ih <= 0 || dw <= 0 || dh <= 0) return { sx: 0, sy: 0, sw: iw, sh: ih };
1041
+ const s = Math.max(dw / iw, dh / ih);
1042
+ const sw = dw / s;
1043
+ const sh = dh / s;
1044
+ return { sx: (iw - sw) / 2, sy: (ih - sh) / 2, sw, sh };
1045
+ }
1046
+ function intrinsicSize(img) {
1047
+ const a = img;
1048
+ return [a.naturalWidth || a.width || 0, a.naturalHeight || a.height || 0];
1049
+ }
1032
1050
  function quoteFamily(family) {
1033
1051
  return family.includes(" ") && !family.includes('"') ? `"${family}"` : family;
1034
1052
  }
package/dist/cli.js CHANGED
@@ -338,6 +338,7 @@ var BLEND_MODES = /* @__PURE__ */ new Set([
338
338
  "hard-light",
339
339
  "difference"
340
340
  ]);
341
+ var IMAGE_FITS = /* @__PURE__ */ new Set(["fill", "cover"]);
341
342
  var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
342
343
  var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
343
344
  var PROPS_BY_TYPE = {
@@ -345,7 +346,7 @@ var PROPS_BY_TYPE = {
345
346
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
346
347
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
347
348
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
348
- image: [...COMMON_PROPS, "src", "width", "height"],
349
+ image: [...COMMON_PROPS, "src", "width", "height", "fit"],
349
350
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
350
351
  group: COMMON_PROPS
351
352
  };
@@ -392,6 +393,7 @@ function validateScene(ir) {
392
393
  if (typeof props.blur === "number" && props.blur < 0) problems.push(`node "${node.id}": blur must be >= 0`);
393
394
  if (typeof props.shadowBlur === "number" && props.shadowBlur < 0) problems.push(`node "${node.id}": shadowBlur must be >= 0`);
394
395
  if (typeof props.blend === "string" && !BLEND_MODES.has(props.blend)) problems.push(`node "${node.id}": unknown blend "${props.blend}" \u2014 use ${[...BLEND_MODES].join(", ")}`);
396
+ if (typeof props.fit === "string" && !IMAGE_FITS.has(props.fit)) problems.push(`node "${node.id}": unknown fit "${props.fit}" \u2014 use ${[...IMAGE_FITS].join(", ")}`);
395
397
  if (node.type === "group") {
396
398
  const clip = node.props.clip;
397
399
  if (clip) {
package/dist/index.js CHANGED
@@ -348,6 +348,7 @@ var BLEND_MODES = /* @__PURE__ */ new Set([
348
348
  "hard-light",
349
349
  "difference"
350
350
  ]);
351
+ var IMAGE_FITS = /* @__PURE__ */ new Set(["fill", "cover"]);
351
352
  var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
352
353
  var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
353
354
  var PROPS_BY_TYPE = {
@@ -355,7 +356,7 @@ var PROPS_BY_TYPE = {
355
356
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
356
357
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
357
358
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
358
- image: [...COMMON_PROPS, "src", "width", "height"],
359
+ image: [...COMMON_PROPS, "src", "width", "height", "fit"],
359
360
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
360
361
  group: COMMON_PROPS
361
362
  };
@@ -402,6 +403,7 @@ function validateScene(ir) {
402
403
  if (typeof props.blur === "number" && props.blur < 0) problems.push(`node "${node.id}": blur must be >= 0`);
403
404
  if (typeof props.shadowBlur === "number" && props.shadowBlur < 0) problems.push(`node "${node.id}": shadowBlur must be >= 0`);
404
405
  if (typeof props.blend === "string" && !BLEND_MODES.has(props.blend)) problems.push(`node "${node.id}": unknown blend "${props.blend}" \u2014 use ${[...BLEND_MODES].join(", ")}`);
406
+ if (typeof props.fit === "string" && !IMAGE_FITS.has(props.fit)) problems.push(`node "${node.id}": unknown fit "${props.fit}" \u2014 use ${[...IMAGE_FITS].join(", ")}`);
405
407
  if (node.type === "group") {
406
408
  const clip = node.props.clip;
407
409
  if (clip) {
@@ -1072,6 +1074,7 @@ function photoMontage(images, opts = {}) {
1072
1074
  width: W,
1073
1075
  height: H,
1074
1076
  anchor: "center",
1077
+ fit: "cover",
1075
1078
  scale: kA,
1076
1079
  opacity: i === 0 ? 1 : 0
1077
1080
  })
@@ -3178,6 +3181,7 @@ function evaluate(compiled, t) {
3178
3181
  height,
3179
3182
  offsetX: -width * ax,
3180
3183
  offsetY: -height * ay,
3184
+ ...node.props.fit && node.props.fit !== "fill" ? { fit: node.props.fit } : {},
3181
3185
  ...fx,
3182
3186
  ...clipSpread
3183
3187
  });
package/dist/labels.js CHANGED
@@ -332,6 +332,7 @@ var BLEND_MODES = /* @__PURE__ */ new Set([
332
332
  "hard-light",
333
333
  "difference"
334
334
  ]);
335
+ var IMAGE_FITS = /* @__PURE__ */ new Set(["fill", "cover"]);
335
336
  var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
336
337
  var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
337
338
  var PROPS_BY_TYPE = {
@@ -339,7 +340,7 @@ var PROPS_BY_TYPE = {
339
340
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
340
341
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
341
342
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
342
- image: [...COMMON_PROPS, "src", "width", "height"],
343
+ image: [...COMMON_PROPS, "src", "width", "height", "fit"],
343
344
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
344
345
  group: COMMON_PROPS
345
346
  };
@@ -386,6 +387,7 @@ function validateScene(ir) {
386
387
  if (typeof props.blur === "number" && props.blur < 0) problems.push(`node "${node.id}": blur must be >= 0`);
387
388
  if (typeof props.shadowBlur === "number" && props.shadowBlur < 0) problems.push(`node "${node.id}": shadowBlur must be >= 0`);
388
389
  if (typeof props.blend === "string" && !BLEND_MODES.has(props.blend)) problems.push(`node "${node.id}": unknown blend "${props.blend}" \u2014 use ${[...BLEND_MODES].join(", ")}`);
390
+ if (typeof props.fit === "string" && !IMAGE_FITS.has(props.fit)) problems.push(`node "${node.id}": unknown fit "${props.fit}" \u2014 use ${[...IMAGE_FITS].join(", ")}`);
389
391
  if (node.type === "group") {
390
392
  const clip = node.props.clip;
391
393
  if (clip) {
@@ -119,7 +119,13 @@ function drawDisplayList(ctx, ops, images) {
119
119
  case "image": {
120
120
  const img = images?.get(op.src);
121
121
  if (img) {
122
- ctx.drawImage(img, op.offsetX, op.offsetY, op.width, op.height);
122
+ if (op.fit === "cover") {
123
+ const [iw, ih] = intrinsicSize(img);
124
+ const { sx, sy, sw, sh } = coverRect(iw, ih, op.width, op.height);
125
+ ctx.drawImage(img, sx, sy, sw, sh, op.offsetX, op.offsetY, op.width, op.height);
126
+ } else {
127
+ ctx.drawImage(img, op.offsetX, op.offsetY, op.width, op.height);
128
+ }
123
129
  } else {
124
130
  ctx.fillStyle = "#2A2A30";
125
131
  ctx.fillRect(op.offsetX, op.offsetY, op.width, op.height);
@@ -176,6 +182,17 @@ function drawDisplayList(ctx, ops, images) {
176
182
  function mapBlend(blend) {
177
183
  return blend === "add" ? "lighter" : blend;
178
184
  }
185
+ function coverRect(iw, ih, dw, dh) {
186
+ if (iw <= 0 || ih <= 0 || dw <= 0 || dh <= 0) return { sx: 0, sy: 0, sw: iw, sh: ih };
187
+ const s = Math.max(dw / iw, dh / ih);
188
+ const sw = dw / s;
189
+ const sh = dh / s;
190
+ return { sx: (iw - sw) / 2, sy: (ih - sh) / 2, sw, sh };
191
+ }
192
+ function intrinsicSize(img) {
193
+ const a = img;
194
+ return [a.naturalWidth || a.width || 0, a.naturalHeight || a.height || 0];
195
+ }
179
196
  function quoteFamily(family) {
180
197
  return family.includes(" ") && !family.includes('"') ? `"${family}"` : family;
181
198
  }
@@ -193,6 +210,7 @@ function pathLength(d) {
193
210
  return len;
194
211
  }
195
212
  export {
213
+ coverRect,
196
214
  drawDisplayList,
197
215
  renderFrame
198
216
  };
package/dist/trace-cli.js CHANGED
@@ -13,7 +13,7 @@ var PROPS_BY_TYPE = {
13
13
  ellipse: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth"],
14
14
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
15
15
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
16
- image: [...COMMON_PROPS, "src", "width", "height"],
16
+ image: [...COMMON_PROPS, "src", "width", "height", "fit"],
17
17
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
18
18
  group: COMMON_PROPS
19
19
  };
@@ -4,7 +4,7 @@
4
4
  * always. Renderers only draw; they never compute animation.
5
5
  */
6
6
  import type { CompiledScene } from "./compile.js";
7
- import type { BlendMode, ClipShape, Paint, PropValue } from "./ir.js";
7
+ import type { BlendMode, ClipShape, ImageFit, Paint, PropValue } from "./ir.js";
8
8
  /** Canvas-style 2D affine matrix [a, b, c, d, e, f]. */
9
9
  export type Mat2D = [number, number, number, number, number, number];
10
10
  /** A clip from an ancestor group: its shape in the group's coordinate space,
@@ -78,6 +78,8 @@ export type DisplayOp = (OpBase & {
78
78
  height: number;
79
79
  offsetX: number;
80
80
  offsetY: number;
81
+ /** Box-fit; present only when authored and not "fill". */
82
+ fit?: ImageFit;
81
83
  }) | (OpBase & {
82
84
  type: "path";
83
85
  /** SVG path data, drawn via Path2D. */
@@ -182,7 +182,16 @@ export interface ImageProps extends BaseProps {
182
182
  src: string;
183
183
  width: number;
184
184
  height: number;
185
+ /**
186
+ * How the image maps into its width×height box. `"fill"` (default) stretches to
187
+ * the box (today's behavior); `"cover"` crops the image to fill the box at its
188
+ * natural aspect (centered) — no distortion, no pre-cropping. Discrete (not
189
+ * keyframed); the cover crop is done by the renderer, which knows the decoded size.
190
+ */
191
+ fit?: ImageFit;
185
192
  }
193
+ /** Image box-fit mode. `cover` = crop-to-fill at the image's aspect (centered). */
194
+ export type ImageFit = "fill" | "cover";
186
195
  export type NodeIR = {
187
196
  type: "rect";
188
197
  id: string;
@@ -9,10 +9,10 @@
9
9
  * a seeded PRNG (mulberry32) — same (images, opts) → identical IR; a different
10
10
  * `seed` re-frames within the same family. No Math.random / Date.
11
11
  *
12
- * Constraint it works around: the `image` node draws STRETCHED to width×height
13
- * (no object-fit). So images must already be the frame's aspect ratio; each layer
14
- * is sized to the frame and the Ken Burns keeps `scale >= 1` with the pan bounded
15
- * to the scale's slack, so an edge is never revealed.
12
+ * Each layer is sized to the frame and uses `fit: "cover"`, so images of ANY aspect
13
+ * ratio fill the frame (cropped, centered) with no distortion no pre-cropping. The
14
+ * Ken Burns keeps `scale >= 1` with the pan bounded to the scale's slack, so an edge
15
+ * is never revealed.
16
16
  */
17
17
  import type { NodeIR, TimelineIR } from "./ir.js";
18
18
  export type KenBurns = "in" | "out" | "pan";
@@ -47,9 +47,11 @@ Factories return plain data. Every node needs a unique `id`.
47
47
  same structure (e.g. both 4-cubic ovals). Arcs (`A`) can't morph (their 0/1
48
48
  flags aren't interpolable) and incompatible shapes snap at the midpoint; build
49
49
  morph targets from `M/L/C/Q/Z` only.
50
- - `image({ id, src, x, y, width, height, opacity?, rotation?, scale?, anchor? })` —
51
- `src` is a file path, absolute or relative to the scene file; drawn stretched
52
- to `width`×`height` (png/jpg/webp). `src` switches discretely (no crossfade)
50
+ - `image({ id, src, x, y, width, height, fit?, opacity?, rotation?, scale?, anchor? })` —
51
+ `src` is a file path, absolute or relative to the scene file (png/jpg/webp).
52
+ `fit` controls how it maps into `width`×`height`: `"fill"` (default) stretches;
53
+ `"cover"` crops to fill the box at the image's natural aspect, centered (no
54
+ distortion — drop in any-aspect photos). `src` switches discretely (no crossfade) —
53
55
  for hard-cut frame sequences stack image nodes and step their `opacity`; for
54
56
  a dissolve, crossfade two nodes' opacity.
55
57
  - `group({ id, x, y, opacity?, rotation?, scale?, anchor? }, children)` — children's
@@ -307,9 +309,10 @@ scene({ size, nodes: [...m.nodes, ...titles], timeline: par(m.timeline, titleTra
307
309
  the stacked image layers (+ `${id}-vignette` / `${id}-scrim` grade overlays);
308
310
  `timeline` is a retimable `beat("montage", …)`. Stable addresses: `${id}-${i}`,
309
311
  labels `shot-${i}` / `cross-${i}`.
310
- - **Images must be the frame's aspect ratio** the `image` node draws stretched
311
- (no object-fit), so cover-crop your photos to `size` first. The Ken Burns keeps
312
- `scale ≥ 1` with the pan bounded to its slack, so an edge is never revealed.
312
+ - **Any-aspect photos work** each layer uses `fit: "cover"`, so the renderer
313
+ crops to fill the frame at the image's aspect (no pre-cropping, no distortion).
314
+ The Ken Burns keeps `scale ≥ 1` with the pan bounded to its slack, so an edge is
315
+ never revealed.
313
316
  - Per-slide overrides: `{ src, hold?, ken? }` where `ken` is `"in" | "out" | "pan"`.
314
317
  - Seeded + pure (same `(images, opts)` → identical IR). Note: image-node sources do
315
318
  not render in `reframe player` / artifacts — montage ships as mp4.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reframe-video",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
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",