reframe-video 0.6.3 → 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
  };
@@ -952,6 +954,15 @@ var init_effects = __esm({
952
954
  }
953
955
  });
954
956
 
957
+ // ../core/src/montage.ts
958
+ var init_montage = __esm({
959
+ "../core/src/montage.ts"() {
960
+ "use strict";
961
+ init_dsl();
962
+ init_gradient();
963
+ }
964
+ });
965
+
955
966
  // ../core/src/presets.ts
956
967
  function makeRng(seed) {
957
968
  let a = seed >>> 0 || 2654435769;
@@ -1410,6 +1421,7 @@ var init_src = __esm({
1410
1421
  init_camera();
1411
1422
  init_gradient();
1412
1423
  init_effects();
1424
+ init_montage();
1413
1425
  init_presets();
1414
1426
  init_devicePreset();
1415
1427
  init_cursor();
@@ -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) {
@@ -1012,6 +1014,122 @@ function dropShadow(color, blur = 24, x = 0, y = 12) {
1012
1014
  return { shadowColor: color, shadowBlur: blur, shadowX: x, shadowY: y };
1013
1015
  }
1014
1016
 
1017
+ // ../core/src/montage.ts
1018
+ function makeRng(seed) {
1019
+ let a = seed >>> 0 || 2654435769;
1020
+ return () => {
1021
+ a = a + 1831565813 | 0;
1022
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
1023
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
1024
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
1025
+ };
1026
+ }
1027
+ var norm = (img) => typeof img === "string" ? { src: img } : img;
1028
+ function photoMontage(images, opts = {}) {
1029
+ const id = opts.id ?? "shot";
1030
+ const W = opts.size?.width ?? 1920;
1031
+ const H = opts.size?.height ?? 1080;
1032
+ const hold = Math.max(0.5, opts.hold ?? 3.2);
1033
+ const zoom = Math.max(1.001, opts.zoom ?? 1.18);
1034
+ const grade = opts.grade !== false;
1035
+ const rand2 = makeRng((opts.seed ?? 0) + 1);
1036
+ const slides = images.map(norm);
1037
+ const cx = W / 2;
1038
+ const cy = H / 2;
1039
+ const nodes = [];
1040
+ const shots = [];
1041
+ slides.forEach((slide, i) => {
1042
+ const nid = `${id}-${i}`;
1043
+ const slideHold = Math.max(0.5, slide.hold ?? hold);
1044
+ const transition = Math.min(opts.transition ?? 0.6, slideHold * 0.9);
1045
+ const kind = slide.ken ?? ["in", "out", "pan"][Math.floor(rand2() * 3)] ?? "in";
1046
+ const angle = rand2() * Math.PI * 2;
1047
+ const panFrac = 0.4 + rand2() * 0.35;
1048
+ const dx = Math.cos(angle);
1049
+ const dy = Math.sin(angle);
1050
+ let kA, kB;
1051
+ let xA, xB, yA, yB;
1052
+ if (kind === "pan") {
1053
+ kA = kB = zoom;
1054
+ const sx = dx * (zoom - 1) * (W / 2) * panFrac;
1055
+ const sy = dy * (zoom - 1) * (H / 2) * panFrac;
1056
+ xA = cx - sx;
1057
+ xB = cx + sx;
1058
+ yA = cy - sy;
1059
+ yB = cy + sy;
1060
+ } else {
1061
+ kA = kind === "in" ? 1 : zoom;
1062
+ kB = kind === "in" ? zoom : 1;
1063
+ xA = cx + dx * (kA - 1) * (W / 2) * panFrac;
1064
+ xB = cx + dx * (kB - 1) * (W / 2) * panFrac;
1065
+ yA = cy + dy * (kA - 1) * (H / 2) * panFrac;
1066
+ yB = cy + dy * (kB - 1) * (H / 2) * panFrac;
1067
+ }
1068
+ nodes.push(
1069
+ image({
1070
+ id: nid,
1071
+ src: slide.src,
1072
+ x: xA,
1073
+ y: yA,
1074
+ width: W,
1075
+ height: H,
1076
+ anchor: "center",
1077
+ fit: "cover",
1078
+ scale: kA,
1079
+ opacity: i === 0 ? 1 : 0
1080
+ })
1081
+ );
1082
+ const ken = tween(
1083
+ nid,
1084
+ { scale: kB, x: xB, y: yB },
1085
+ { duration: slideHold, ease: "easeInOutQuad", label: `shot-${i}` }
1086
+ );
1087
+ const shot = i === 0 ? par(ken) : par(
1088
+ ken,
1089
+ tween(`${id}-${i - 1}`, { opacity: 0 }, { duration: transition, ease: "linear", label: `cross-${i}` }),
1090
+ tween(nid, { opacity: 1 }, { duration: transition, ease: "linear" })
1091
+ );
1092
+ shots.push(shot);
1093
+ });
1094
+ if (grade) {
1095
+ nodes.push(
1096
+ rect({
1097
+ id: `${id}-vignette`,
1098
+ x: 0,
1099
+ y: 0,
1100
+ width: W,
1101
+ height: H,
1102
+ fill: radialGradient(
1103
+ [
1104
+ { offset: 0.55, color: "#FFFFFF" },
1105
+ { offset: 1, color: "#6E6E6E" }
1106
+ ],
1107
+ { cx: 0.5, cy: 0.5, r: 0.72 }
1108
+ ),
1109
+ blend: "multiply"
1110
+ })
1111
+ );
1112
+ nodes.push(
1113
+ rect({
1114
+ id: `${id}-scrim`,
1115
+ x: 0,
1116
+ y: 0,
1117
+ width: W,
1118
+ height: H,
1119
+ fill: linearGradient(
1120
+ [
1121
+ { offset: 0, color: "#00000000" },
1122
+ { offset: 0.62, color: "#00000000" },
1123
+ { offset: 1, color: "#000000B0" }
1124
+ ],
1125
+ { angle: 90 }
1126
+ )
1127
+ })
1128
+ );
1129
+ }
1130
+ return { nodes, timeline: beat("montage", { nodes: nodes.map((n3) => n3.id) }, [seq(...shots)]) };
1131
+ }
1132
+
1015
1133
  // ../core/src/presets.ts
1016
1134
  var PRESET_NAMES = [
1017
1135
  "draw-bloom",
@@ -1021,7 +1139,7 @@ var PRESET_NAMES = [
1021
1139
  "reveal-orbit",
1022
1140
  "spin-forge"
1023
1141
  ];
1024
- function makeRng(seed) {
1142
+ function makeRng2(seed) {
1025
1143
  let a = seed >>> 0 || 2654435769;
1026
1144
  return () => {
1027
1145
  a = a + 1831565813 | 0;
@@ -1033,7 +1151,7 @@ function makeRng(seed) {
1033
1151
  var clamp01 = (x) => Math.max(0, Math.min(1, x));
1034
1152
  var SET = 1 / 120;
1035
1153
  function ctx(o) {
1036
- const rand2 = makeRng((o.seed ?? 0) + 1);
1154
+ const rand2 = makeRng2((o.seed ?? 0) + 1);
1037
1155
  return {
1038
1156
  e: clamp01(o.energy ?? 0.5),
1039
1157
  sp: Math.max(0.25, o.speed ?? 1),
@@ -1588,7 +1706,7 @@ var CHARACTER_PRESET_NAMES = ["walk", "run", "jump", "dance", "wave", "cheer"];
1588
1706
  var THIGH = 76;
1589
1707
  var SHIN = 72;
1590
1708
  var clamp012 = (x) => Math.max(0, Math.min(1, x));
1591
- function makeRng2(seed) {
1709
+ function makeRng3(seed) {
1592
1710
  let a = seed >>> 0 || 2654435769;
1593
1711
  return () => {
1594
1712
  a = a + 1831565813 | 0;
@@ -1599,7 +1717,7 @@ function makeRng2(seed) {
1599
1717
  }
1600
1718
  var dur2 = (base, sp) => base / sp;
1601
1719
  function ctx2(o) {
1602
- const rand2 = makeRng2((o.seed ?? 0) + 1);
1720
+ const rand2 = makeRng3((o.seed ?? 0) + 1);
1603
1721
  return {
1604
1722
  g: o.target,
1605
1723
  label: o.label,
@@ -3063,6 +3181,7 @@ function evaluate(compiled, t) {
3063
3181
  height,
3064
3182
  offsetX: -width * ax,
3065
3183
  offsetY: -height * ay,
3184
+ ...node.props.fit && node.props.fit !== "fill" ? { fit: node.props.fit } : {},
3066
3185
  ...fx,
3067
3186
  ...clipSpread
3068
3187
  });
@@ -3273,6 +3392,7 @@ export {
3273
3392
  path,
3274
3393
  pathPoint,
3275
3394
  pathTangentAngle,
3395
+ photoMontage,
3276
3396
  poseTo,
3277
3397
  radialGradient,
3278
3398
  rect,
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. */
@@ -8,6 +8,7 @@ export { pathPoint, pathTangentAngle, type Pt } from "./path.js";
8
8
  export { cameraTo, cameraMatrix, CAMERA_ID, CAMERA_PROPS } from "./camera.js";
9
9
  export { linearGradient, radialGradient, conicGradient, isGradient } from "./gradient.js";
10
10
  export { glow, dropShadow } from "./effects.js";
11
+ export { photoMontage, type MontageImage, type MontageOpts, type MontageResult, type KenBurns } from "./montage.js";
11
12
  export { motionPreset, PRESET_NAMES, type PresetName, type PresetRig, type PresetOpts } from "./presets.js";
12
13
  export { devicePreset, deviceScreen, deviceScreenCenter, deviceBounds, deviceScreenPoint, DEVICE_PRESET_NAMES, type DevicePresetName, type DevicePresetOpts } from "./devicePreset.js";
13
14
  export { cursor, cursorTo, cursorPath, cursorClick, cursorDouble, type CursorStyle, type CursorOpts, type CursorToOpts, type CursorPathOpts, type CursorClickOpts } from "./cursor.js";
@@ -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;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Photo montage — a SEEDED GENERATOR that turns a list of images into a polished
3
+ * slideshow: layered image nodes + a retimable `beat` that crossfades between
4
+ * slides and pans/zooms each (Ken Burns), with an optional cinematic grade
5
+ * (vignette + bottom scrim) built from gradients + blend modes. The photo analog
6
+ * of `motionPreset` / `splitText`.
7
+ *
8
+ * Pure and deterministic: the per-slide Ken Burns direction / framing is chosen by
9
+ * a seeded PRNG (mulberry32) — same (images, opts) → identical IR; a different
10
+ * `seed` re-frames within the same family. No Math.random / Date.
11
+ *
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
+ */
17
+ import type { NodeIR, TimelineIR } from "./ir.js";
18
+ export type KenBurns = "in" | "out" | "pan";
19
+ /** One slide: a bare src, or a src with per-slide overrides. */
20
+ export type MontageImage = string | {
21
+ src: string;
22
+ hold?: number;
23
+ ken?: KenBurns;
24
+ };
25
+ export interface MontageOpts {
26
+ /** Node-id prefix → stable regen addresses `${id}-${i}`. Default "shot". */
27
+ id?: string;
28
+ /** Frame size; must match the scene size. Default 1920×1080. */
29
+ size?: {
30
+ width: number;
31
+ height: number;
32
+ };
33
+ /** Seconds each slide is held (incl. its incoming crossfade). Default 3.2. */
34
+ hold?: number;
35
+ /** Crossfade seconds between slides. Default 0.6. */
36
+ transition?: number;
37
+ /** Max Ken Burns zoom (>1). Default 1.18. */
38
+ zoom?: number;
39
+ /** Emit the vignette + bottom-scrim grade overlays. Default true. */
40
+ grade?: boolean;
41
+ /** Deterministic framing. Same seed → identical IR. Default 0. */
42
+ seed?: number;
43
+ }
44
+ export interface MontageResult {
45
+ /** Image layers (+ grade overlays) — place these in `scene({ nodes })`. */
46
+ nodes: NodeIR[];
47
+ /** The montage beat — place in `scene({ timeline })` (compose with `seq`). */
48
+ timeline: TimelineIR;
49
+ }
50
+ /**
51
+ * Build a montage from a list of frame-aspect images.
52
+ *
53
+ * const m = photoMontage(["a.jpg", "b.jpg", "c.jpg"], { seed: 7 });
54
+ * scene({ size, nodes: [...m.nodes, ...titles], timeline: seq(m.timeline) });
55
+ */
56
+ export declare function photoMontage(images: MontageImage[], opts?: MontageOpts): MontageResult;
@@ -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
@@ -289,6 +291,32 @@ const T = splitText("MOTION IS DATA", { id: "t", x: 960, y: 470, fontSize: 130 }
289
291
  Every effect is seeded (same `seed` → identical) and pure keyframes. To time a
290
292
  `textLoop` window, add up the `textIn` beat length (≈ `(n-1)·stagger + glyphDur`).
291
293
 
294
+ ## Photo montage (`photoMontage`)
295
+
296
+ Turn a list of images into a polished slideshow — crossfades + seeded Ken Burns
297
+ (pan/zoom) + an optional cinematic grade (vignette + bottom scrim via gradients +
298
+ blend) — without hand-wiring each move. The photo analog of `motionPreset`.
299
+
300
+ ```ts
301
+ const m = photoMontage(["a.jpg", "b.jpg", "c.jpg"], {
302
+ id: "shot", size: { width: 1920, height: 1080 },
303
+ hold: 3.4, transition: 0.7, zoom: 1.16, seed: 7,
304
+ });
305
+ scene({ size, nodes: [...m.nodes, ...titles], timeline: par(m.timeline, titleTrack) });
306
+ ```
307
+
308
+ - Returns `{ nodes, timeline }` (like `splitText` owns its glyph nodes). `nodes` are
309
+ the stacked image layers (+ `${id}-vignette` / `${id}-scrim` grade overlays);
310
+ `timeline` is a retimable `beat("montage", …)`. Stable addresses: `${id}-${i}`,
311
+ labels `shot-${i}` / `cross-${i}`.
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.
316
+ - Per-slide overrides: `{ src, hold?, ken? }` where `ken` is `"in" | "out" | "pan"`.
317
+ - Seeded + pure (same `(images, opts)` → identical IR). Note: image-node sources do
318
+ not render in `reframe player` / artifacts — montage ships as mp4.
319
+
292
320
  ## Cursor (UI demos)
293
321
 
294
322
  A vector mouse pointer that glides across the scene and clicks things — for app
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reframe-video",
3
- "version": "0.6.3",
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",