reframe-video 0.6.2 → 0.6.4

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
@@ -379,6 +379,7 @@ function validateScene(ir) {
379
379
  checkPaint(`node "${node.id}" stroke`, props.stroke);
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
+ if (typeof props.blend === "string" && !BLEND_MODES.has(props.blend)) problems.push(`node "${node.id}": unknown blend "${props.blend}" \u2014 use ${[...BLEND_MODES].join(", ")}`);
382
383
  if (node.type === "group") {
383
384
  const clip = node.props.clip;
384
385
  if (clip) {
@@ -593,11 +594,24 @@ function validateComposition(comp) {
593
594
  }
594
595
  if (problems.length > 0) throw new SceneValidationError(problems);
595
596
  }
596
- var FX_PROPS, COMMON_PROPS, CAMERA_PROPS, PROPS_BY_TYPE, SceneValidationError, TRANSITIONS;
597
+ var FX_PROPS, BLEND_MODES, COMMON_PROPS, CAMERA_PROPS, PROPS_BY_TYPE, SceneValidationError, TRANSITIONS;
597
598
  var init_validate = __esm({
598
599
  "../core/src/validate.ts"() {
599
600
  "use strict";
600
- FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY"];
601
+ FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY", "blend"];
602
+ BLEND_MODES = /* @__PURE__ */ new Set([
603
+ "normal",
604
+ "multiply",
605
+ "screen",
606
+ "overlay",
607
+ "lighten",
608
+ "darken",
609
+ "add",
610
+ "color-dodge",
611
+ "soft-light",
612
+ "hard-light",
613
+ "difference"
614
+ ]);
601
615
  COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
602
616
  CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
603
617
  PROPS_BY_TYPE = {
@@ -938,6 +952,15 @@ var init_effects = __esm({
938
952
  }
939
953
  });
940
954
 
955
+ // ../core/src/montage.ts
956
+ var init_montage = __esm({
957
+ "../core/src/montage.ts"() {
958
+ "use strict";
959
+ init_dsl();
960
+ init_gradient();
961
+ }
962
+ });
963
+
941
964
  // ../core/src/presets.ts
942
965
  function makeRng(seed) {
943
966
  let a = seed >>> 0 || 2654435769;
@@ -1396,6 +1419,7 @@ var init_src = __esm({
1396
1419
  init_camera();
1397
1420
  init_gradient();
1398
1421
  init_effects();
1422
+ init_montage();
1399
1423
  init_presets();
1400
1424
  init_devicePreset();
1401
1425
  init_cursor();
@@ -334,7 +334,7 @@
334
334
  }
335
335
 
336
336
  // ../core/src/validate.ts
337
- var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY"];
337
+ var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY", "blend"];
338
338
  var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
339
339
  var PROPS_BY_TYPE = {
340
340
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
@@ -689,6 +689,7 @@
689
689
  fx.shadowX = num(id, "shadowX", p.shadowX ?? 0);
690
690
  fx.shadowY = num(id, "shadowY", p.shadowY ?? 0);
691
691
  }
692
+ if (p.blend !== void 0 && p.blend !== "normal") fx.blend = p.blend;
692
693
  return fx;
693
694
  };
694
695
  const walk = (node, parent, parentOpacity, clips) => {
@@ -914,6 +915,7 @@
914
915
  ctx2.shadowOffsetX = op.shadowX ?? 0;
915
916
  ctx2.shadowOffsetY = op.shadowY ?? 0;
916
917
  }
918
+ if (op.blend) ctx2.globalCompositeOperation = mapBlend(op.blend);
917
919
  switch (op.type) {
918
920
  case "rect": {
919
921
  const box = { x: op.offsetX, y: op.offsetY, w: op.width, h: op.height };
@@ -1024,6 +1026,9 @@
1024
1026
  ctx2.restore();
1025
1027
  }
1026
1028
  }
1029
+ function mapBlend(blend) {
1030
+ return blend === "add" ? "lighter" : blend;
1031
+ }
1027
1032
  function quoteFamily(family) {
1028
1033
  return family.includes(" ") && !family.includes('"') ? `"${family}"` : family;
1029
1034
  }
package/dist/cli.js CHANGED
@@ -324,7 +324,20 @@ function compileScene(ir) {
324
324
  }
325
325
 
326
326
  // ../core/src/validate.ts
327
- var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY"];
327
+ var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY", "blend"];
328
+ var BLEND_MODES = /* @__PURE__ */ new Set([
329
+ "normal",
330
+ "multiply",
331
+ "screen",
332
+ "overlay",
333
+ "lighten",
334
+ "darken",
335
+ "add",
336
+ "color-dodge",
337
+ "soft-light",
338
+ "hard-light",
339
+ "difference"
340
+ ]);
328
341
  var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
329
342
  var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
330
343
  var PROPS_BY_TYPE = {
@@ -378,6 +391,7 @@ function validateScene(ir) {
378
391
  checkPaint(`node "${node.id}" stroke`, props.stroke);
379
392
  if (typeof props.blur === "number" && props.blur < 0) problems.push(`node "${node.id}": blur must be >= 0`);
380
393
  if (typeof props.shadowBlur === "number" && props.shadowBlur < 0) problems.push(`node "${node.id}": shadowBlur must be >= 0`);
394
+ if (typeof props.blend === "string" && !BLEND_MODES.has(props.blend)) problems.push(`node "${node.id}": unknown blend "${props.blend}" \u2014 use ${[...BLEND_MODES].join(", ")}`);
381
395
  if (node.type === "group") {
382
396
  const clip = node.props.clip;
383
397
  if (clip) {
package/dist/index.js CHANGED
@@ -334,7 +334,20 @@ function compileScene(ir) {
334
334
  }
335
335
 
336
336
  // ../core/src/validate.ts
337
- var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY"];
337
+ var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY", "blend"];
338
+ var BLEND_MODES = /* @__PURE__ */ new Set([
339
+ "normal",
340
+ "multiply",
341
+ "screen",
342
+ "overlay",
343
+ "lighten",
344
+ "darken",
345
+ "add",
346
+ "color-dodge",
347
+ "soft-light",
348
+ "hard-light",
349
+ "difference"
350
+ ]);
338
351
  var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
339
352
  var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
340
353
  var PROPS_BY_TYPE = {
@@ -388,6 +401,7 @@ function validateScene(ir) {
388
401
  checkPaint(`node "${node.id}" stroke`, props.stroke);
389
402
  if (typeof props.blur === "number" && props.blur < 0) problems.push(`node "${node.id}": blur must be >= 0`);
390
403
  if (typeof props.shadowBlur === "number" && props.shadowBlur < 0) problems.push(`node "${node.id}": shadowBlur must be >= 0`);
404
+ if (typeof props.blend === "string" && !BLEND_MODES.has(props.blend)) problems.push(`node "${node.id}": unknown blend "${props.blend}" \u2014 use ${[...BLEND_MODES].join(", ")}`);
391
405
  if (node.type === "group") {
392
406
  const clip = node.props.clip;
393
407
  if (clip) {
@@ -998,6 +1012,121 @@ function dropShadow(color, blur = 24, x = 0, y = 12) {
998
1012
  return { shadowColor: color, shadowBlur: blur, shadowX: x, shadowY: y };
999
1013
  }
1000
1014
 
1015
+ // ../core/src/montage.ts
1016
+ function makeRng(seed) {
1017
+ let a = seed >>> 0 || 2654435769;
1018
+ return () => {
1019
+ a = a + 1831565813 | 0;
1020
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
1021
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
1022
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
1023
+ };
1024
+ }
1025
+ var norm = (img) => typeof img === "string" ? { src: img } : img;
1026
+ function photoMontage(images, opts = {}) {
1027
+ const id = opts.id ?? "shot";
1028
+ const W = opts.size?.width ?? 1920;
1029
+ const H = opts.size?.height ?? 1080;
1030
+ const hold = Math.max(0.5, opts.hold ?? 3.2);
1031
+ const zoom = Math.max(1.001, opts.zoom ?? 1.18);
1032
+ const grade = opts.grade !== false;
1033
+ const rand2 = makeRng((opts.seed ?? 0) + 1);
1034
+ const slides = images.map(norm);
1035
+ const cx = W / 2;
1036
+ const cy = H / 2;
1037
+ const nodes = [];
1038
+ const shots = [];
1039
+ slides.forEach((slide, i) => {
1040
+ const nid = `${id}-${i}`;
1041
+ const slideHold = Math.max(0.5, slide.hold ?? hold);
1042
+ const transition = Math.min(opts.transition ?? 0.6, slideHold * 0.9);
1043
+ const kind = slide.ken ?? ["in", "out", "pan"][Math.floor(rand2() * 3)] ?? "in";
1044
+ const angle = rand2() * Math.PI * 2;
1045
+ const panFrac = 0.4 + rand2() * 0.35;
1046
+ const dx = Math.cos(angle);
1047
+ const dy = Math.sin(angle);
1048
+ let kA, kB;
1049
+ let xA, xB, yA, yB;
1050
+ if (kind === "pan") {
1051
+ kA = kB = zoom;
1052
+ const sx = dx * (zoom - 1) * (W / 2) * panFrac;
1053
+ const sy = dy * (zoom - 1) * (H / 2) * panFrac;
1054
+ xA = cx - sx;
1055
+ xB = cx + sx;
1056
+ yA = cy - sy;
1057
+ yB = cy + sy;
1058
+ } else {
1059
+ kA = kind === "in" ? 1 : zoom;
1060
+ kB = kind === "in" ? zoom : 1;
1061
+ xA = cx + dx * (kA - 1) * (W / 2) * panFrac;
1062
+ xB = cx + dx * (kB - 1) * (W / 2) * panFrac;
1063
+ yA = cy + dy * (kA - 1) * (H / 2) * panFrac;
1064
+ yB = cy + dy * (kB - 1) * (H / 2) * panFrac;
1065
+ }
1066
+ nodes.push(
1067
+ image({
1068
+ id: nid,
1069
+ src: slide.src,
1070
+ x: xA,
1071
+ y: yA,
1072
+ width: W,
1073
+ height: H,
1074
+ anchor: "center",
1075
+ scale: kA,
1076
+ opacity: i === 0 ? 1 : 0
1077
+ })
1078
+ );
1079
+ const ken = tween(
1080
+ nid,
1081
+ { scale: kB, x: xB, y: yB },
1082
+ { duration: slideHold, ease: "easeInOutQuad", label: `shot-${i}` }
1083
+ );
1084
+ const shot = i === 0 ? par(ken) : par(
1085
+ ken,
1086
+ tween(`${id}-${i - 1}`, { opacity: 0 }, { duration: transition, ease: "linear", label: `cross-${i}` }),
1087
+ tween(nid, { opacity: 1 }, { duration: transition, ease: "linear" })
1088
+ );
1089
+ shots.push(shot);
1090
+ });
1091
+ if (grade) {
1092
+ nodes.push(
1093
+ rect({
1094
+ id: `${id}-vignette`,
1095
+ x: 0,
1096
+ y: 0,
1097
+ width: W,
1098
+ height: H,
1099
+ fill: radialGradient(
1100
+ [
1101
+ { offset: 0.55, color: "#FFFFFF" },
1102
+ { offset: 1, color: "#6E6E6E" }
1103
+ ],
1104
+ { cx: 0.5, cy: 0.5, r: 0.72 }
1105
+ ),
1106
+ blend: "multiply"
1107
+ })
1108
+ );
1109
+ nodes.push(
1110
+ rect({
1111
+ id: `${id}-scrim`,
1112
+ x: 0,
1113
+ y: 0,
1114
+ width: W,
1115
+ height: H,
1116
+ fill: linearGradient(
1117
+ [
1118
+ { offset: 0, color: "#00000000" },
1119
+ { offset: 0.62, color: "#00000000" },
1120
+ { offset: 1, color: "#000000B0" }
1121
+ ],
1122
+ { angle: 90 }
1123
+ )
1124
+ })
1125
+ );
1126
+ }
1127
+ return { nodes, timeline: beat("montage", { nodes: nodes.map((n3) => n3.id) }, [seq(...shots)]) };
1128
+ }
1129
+
1001
1130
  // ../core/src/presets.ts
1002
1131
  var PRESET_NAMES = [
1003
1132
  "draw-bloom",
@@ -1007,7 +1136,7 @@ var PRESET_NAMES = [
1007
1136
  "reveal-orbit",
1008
1137
  "spin-forge"
1009
1138
  ];
1010
- function makeRng(seed) {
1139
+ function makeRng2(seed) {
1011
1140
  let a = seed >>> 0 || 2654435769;
1012
1141
  return () => {
1013
1142
  a = a + 1831565813 | 0;
@@ -1019,7 +1148,7 @@ function makeRng(seed) {
1019
1148
  var clamp01 = (x) => Math.max(0, Math.min(1, x));
1020
1149
  var SET = 1 / 120;
1021
1150
  function ctx(o) {
1022
- const rand2 = makeRng((o.seed ?? 0) + 1);
1151
+ const rand2 = makeRng2((o.seed ?? 0) + 1);
1023
1152
  return {
1024
1153
  e: clamp01(o.energy ?? 0.5),
1025
1154
  sp: Math.max(0.25, o.speed ?? 1),
@@ -1574,7 +1703,7 @@ var CHARACTER_PRESET_NAMES = ["walk", "run", "jump", "dance", "wave", "cheer"];
1574
1703
  var THIGH = 76;
1575
1704
  var SHIN = 72;
1576
1705
  var clamp012 = (x) => Math.max(0, Math.min(1, x));
1577
- function makeRng2(seed) {
1706
+ function makeRng3(seed) {
1578
1707
  let a = seed >>> 0 || 2654435769;
1579
1708
  return () => {
1580
1709
  a = a + 1831565813 | 0;
@@ -1585,7 +1714,7 @@ function makeRng2(seed) {
1585
1714
  }
1586
1715
  var dur2 = (base, sp) => base / sp;
1587
1716
  function ctx2(o) {
1588
- const rand2 = makeRng2((o.seed ?? 0) + 1);
1717
+ const rand2 = makeRng3((o.seed ?? 0) + 1);
1589
1718
  return {
1590
1719
  g: o.target,
1591
1720
  label: o.label,
@@ -2958,6 +3087,7 @@ function evaluate(compiled, t) {
2958
3087
  fx.shadowX = num(id, "shadowX", p.shadowX ?? 0);
2959
3088
  fx.shadowY = num(id, "shadowY", p.shadowY ?? 0);
2960
3089
  }
3090
+ if (p.blend !== void 0 && p.blend !== "normal") fx.blend = p.blend;
2961
3091
  return fx;
2962
3092
  };
2963
3093
  const walk = (node, parent, parentOpacity, clips) => {
@@ -3258,6 +3388,7 @@ export {
3258
3388
  path,
3259
3389
  pathPoint,
3260
3390
  pathTangentAngle,
3391
+ photoMontage,
3261
3392
  poseTo,
3262
3393
  radialGradient,
3263
3394
  rect,
package/dist/labels.js CHANGED
@@ -318,7 +318,20 @@ function compileScene(ir) {
318
318
  }
319
319
 
320
320
  // ../core/src/validate.ts
321
- var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY"];
321
+ var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY", "blend"];
322
+ var BLEND_MODES = /* @__PURE__ */ new Set([
323
+ "normal",
324
+ "multiply",
325
+ "screen",
326
+ "overlay",
327
+ "lighten",
328
+ "darken",
329
+ "add",
330
+ "color-dodge",
331
+ "soft-light",
332
+ "hard-light",
333
+ "difference"
334
+ ]);
322
335
  var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
323
336
  var CAMERA_PROPS = ["x", "y", "zoom", "rotation"];
324
337
  var PROPS_BY_TYPE = {
@@ -372,6 +385,7 @@ function validateScene(ir) {
372
385
  checkPaint(`node "${node.id}" stroke`, props.stroke);
373
386
  if (typeof props.blur === "number" && props.blur < 0) problems.push(`node "${node.id}": blur must be >= 0`);
374
387
  if (typeof props.shadowBlur === "number" && props.shadowBlur < 0) problems.push(`node "${node.id}": shadowBlur must be >= 0`);
388
+ if (typeof props.blend === "string" && !BLEND_MODES.has(props.blend)) problems.push(`node "${node.id}": unknown blend "${props.blend}" \u2014 use ${[...BLEND_MODES].join(", ")}`);
375
389
  if (node.type === "group") {
376
390
  const clip = node.props.clip;
377
391
  if (clip) {
@@ -62,6 +62,7 @@ function drawDisplayList(ctx, ops, images) {
62
62
  ctx.shadowOffsetX = op.shadowX ?? 0;
63
63
  ctx.shadowOffsetY = op.shadowY ?? 0;
64
64
  }
65
+ if (op.blend) ctx.globalCompositeOperation = mapBlend(op.blend);
65
66
  switch (op.type) {
66
67
  case "rect": {
67
68
  const box = { x: op.offsetX, y: op.offsetY, w: op.width, h: op.height };
@@ -172,6 +173,9 @@ function drawDisplayList(ctx, ops, images) {
172
173
  ctx.restore();
173
174
  }
174
175
  }
176
+ function mapBlend(blend) {
177
+ return blend === "add" ? "lighter" : blend;
178
+ }
175
179
  function quoteFamily(family) {
176
180
  return family.includes(" ") && !family.includes('"') ? `"${family}"` : family;
177
181
  }
package/dist/trace-cli.js CHANGED
@@ -6,7 +6,7 @@ import { resolve as resolve2 } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
7
 
8
8
  // ../core/src/validate.ts
9
- var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY"];
9
+ var FX_PROPS = ["blur", "shadowColor", "shadowBlur", "shadowX", "shadowY", "blend"];
10
10
  var COMMON_PROPS = ["x", "y", "opacity", "rotation", "scale", "scaleX", "scaleY", "skewX", "skewY", "anchor", "fixed", ...FX_PROPS];
11
11
  var PROPS_BY_TYPE = {
12
12
  rect: [...COMMON_PROPS, "width", "height", "fill", "stroke", "strokeWidth", "radius"],
@@ -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 { ClipShape, Paint, PropValue } from "./ir.js";
7
+ import type { BlendMode, ClipShape, 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,
@@ -30,6 +30,8 @@ interface OpBase {
30
30
  shadowBlur?: number;
31
31
  shadowX?: number;
32
32
  shadowY?: number;
33
+ /** Compositing mode (discrete; present only when authored and not "normal"). */
34
+ blend?: BlendMode;
33
35
  }
34
36
  export type DisplayOp = (OpBase & {
35
37
  type: "rect";
@@ -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";
@@ -47,7 +47,12 @@ export interface BaseProps {
47
47
  shadowBlur?: number;
48
48
  shadowX?: number;
49
49
  shadowY?: number;
50
+ /** How this node composites with what's already drawn (default "normal"). `screen`/
51
+ * `add` brighten (additive light/glow), `multiply` tints/deepens. No-op on a group. */
52
+ blend?: BlendMode;
50
53
  }
54
+ /** Compositing modes (Canvas `globalCompositeOperation`; `add` maps to `lighter`). */
55
+ export type BlendMode = "normal" | "multiply" | "screen" | "overlay" | "lighten" | "darken" | "add" | "color-dodge" | "soft-light" | "hard-light" | "difference";
51
56
  /**
52
57
  * A paint is a solid color string OR a gradient. Coordinates are normalized to the
53
58
  * node's bounding box (0..1, SVG `objectBoundingBox` style) so a gradient is just an
@@ -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
+ * 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.
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;
@@ -194,6 +194,23 @@ rect({ id: "card", /* … */, blur: 18 }); tween("card", { blur: 0 }, { duration
194
194
  - No-op on a `group` (apply to a child; group/composite blur is a later add). See
195
195
  `examples/scenes/shadow-demo.ts`.
196
196
 
197
+ ### Blend modes (compositing)
198
+
199
+ `blend` selects how a shape composites with what's already drawn beneath it — the
200
+ primitive that makes light read.
201
+
202
+ ```ts
203
+ ellipse({ id: "glow", fill: radialGradient(["#FF2D6A", "#FF2D6A00"]), blend: "screen" }) // additive light: brightens where blobs overlap
204
+ rect({ id: "tint", fill: "#1E5BFF", blend: "multiply" }) // tint/deepen the layer beneath
205
+ rect({ id: "neon", fill: linearGradient([...]), shadowColor: "#7A4DFF", blend: "screen" }) // compose with glow
206
+ ```
207
+
208
+ - Modes: `normal` (default), `multiply`, `screen`, `overlay`, `lighten`, `darken`,
209
+ `add` (additive light), `color-dodge`, `soft-light`, `hard-light`, `difference`.
210
+ - **Discrete**, not interpolated — set per node (a static string). Default `normal`.
211
+ - Per-shape. A whole-group blend (composite the subtree, then blend) is a later add;
212
+ on a `group` the prop is a no-op. See `examples/scenes/blend-demo.ts`.
213
+
197
214
  ## Character rig (skeleton, poses, IK)
198
215
 
199
216
  A first-class, declarative character rig that **compiles to plain IR** (nested
@@ -272,6 +289,31 @@ const T = splitText("MOTION IS DATA", { id: "t", x: 960, y: 470, fontSize: 130 }
272
289
  Every effect is seeded (same `seed` → identical) and pure keyframes. To time a
273
290
  `textLoop` window, add up the `textIn` beat length (≈ `(n-1)·stagger + glyphDur`).
274
291
 
292
+ ## Photo montage (`photoMontage`)
293
+
294
+ Turn a list of images into a polished slideshow — crossfades + seeded Ken Burns
295
+ (pan/zoom) + an optional cinematic grade (vignette + bottom scrim via gradients +
296
+ blend) — without hand-wiring each move. The photo analog of `motionPreset`.
297
+
298
+ ```ts
299
+ const m = photoMontage(["a.jpg", "b.jpg", "c.jpg"], {
300
+ id: "shot", size: { width: 1920, height: 1080 },
301
+ hold: 3.4, transition: 0.7, zoom: 1.16, seed: 7,
302
+ });
303
+ scene({ size, nodes: [...m.nodes, ...titles], timeline: par(m.timeline, titleTrack) });
304
+ ```
305
+
306
+ - Returns `{ nodes, timeline }` (like `splitText` owns its glyph nodes). `nodes` are
307
+ the stacked image layers (+ `${id}-vignette` / `${id}-scrim` grade overlays);
308
+ `timeline` is a retimable `beat("montage", …)`. Stable addresses: `${id}-${i}`,
309
+ 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.
313
+ - Per-slide overrides: `{ src, hold?, ken? }` where `ken` is `"in" | "out" | "pan"`.
314
+ - Seeded + pure (same `(images, opts)` → identical IR). Note: image-node sources do
315
+ not render in `reframe player` / artifacts — montage ships as mp4.
316
+
275
317
  ## Cursor (UI demos)
276
318
 
277
319
  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.2",
3
+ "version": "0.6.4",
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",