reframe-video 0.6.7 → 0.6.8

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.
@@ -1104,11 +1104,26 @@
1104
1104
  var canvas = null;
1105
1105
  var images = /* @__PURE__ */ new Map();
1106
1106
  var videoFrames = /* @__PURE__ */ new Map();
1107
- var decode = (dataUrl) => {
1107
+ async function decode(dataUrl, label = "") {
1108
1108
  const img = new Image();
1109
1109
  img.src = dataUrl;
1110
- return img.decode().then(() => img);
1111
- };
1110
+ try {
1111
+ await img.decode();
1112
+ return img;
1113
+ } catch (e) {
1114
+ throw new Error(`decode failed for ${label} (len=${dataUrl.length}): ${String(e)}`);
1115
+ }
1116
+ }
1117
+ async function decodeAll(urls, label) {
1118
+ const out = new Array(urls.length);
1119
+ const LIMIT = 8;
1120
+ for (let base = 0; base < urls.length; base += LIMIT) {
1121
+ const batch = urls.slice(base, base + LIMIT);
1122
+ const decoded = await Promise.all(batch.map((u, j) => decode(u, `${label}#${base + j}`)));
1123
+ for (let j = 0; j < decoded.length; j++) out[base + j] = decoded[j];
1124
+ }
1125
+ return out;
1126
+ }
1112
1127
  var videos = {
1113
1128
  frame(src, index) {
1114
1129
  const frames = videoFrames.get(src);
@@ -1126,14 +1141,14 @@
1126
1141
  document.body.appendChild(canvas);
1127
1142
  ctx = canvas.getContext("2d", { willReadFrequently: true });
1128
1143
  if (!ctx) throw new Error("could not create 2d context");
1129
- await Promise.all([
1130
- ...Object.entries(assets).map(async ([src, dataUrl]) => {
1131
- images.set(src, await decode(dataUrl));
1132
- }),
1133
- ...Object.entries(videoAssets).map(async ([src, frames]) => {
1134
- videoFrames.set(src, await Promise.all(frames.map(decode)));
1144
+ await Promise.all(
1145
+ Object.entries(assets).map(async ([src, dataUrl]) => {
1146
+ images.set(src, await decode(dataUrl, `image ${src}`));
1135
1147
  })
1136
- ]);
1148
+ );
1149
+ for (const [src, frames] of Object.entries(videoAssets)) {
1150
+ videoFrames.set(src, await decodeAll(frames, `video ${src}`));
1151
+ }
1137
1152
  return { duration: compiled.duration, fps: ir.fps ?? 30 };
1138
1153
  },
1139
1154
  renderFrame(t) {
package/dist/index.js CHANGED
@@ -1020,6 +1020,8 @@ function dropShadow(color, blur = 24, x = 0, y = 12) {
1020
1020
  }
1021
1021
 
1022
1022
  // ../core/src/montage.ts
1023
+ var VIDEO_EXT = /\.(mp4|mov|webm|m4v|mkv)$/i;
1024
+ var isVideoSrc = (src) => VIDEO_EXT.test(src);
1023
1025
  function makeRng(seed) {
1024
1026
  let a = seed >>> 0 || 2654435769;
1025
1027
  return () => {
@@ -1043,10 +1045,13 @@ function photoMontage(images, opts = {}) {
1043
1045
  const cy = H / 2;
1044
1046
  const nodes = [];
1045
1047
  const shots = [];
1048
+ let clock = 0;
1046
1049
  slides.forEach((slide, i) => {
1047
1050
  const nid = `${id}-${i}`;
1048
1051
  const slideHold = Math.max(0.5, slide.hold ?? hold);
1049
1052
  const transition = Math.min(opts.transition ?? 0.6, slideHold * 0.9);
1053
+ const shotStart = clock;
1054
+ clock += slideHold;
1050
1055
  const kind = slide.ken ?? ["in", "out", "pan"][Math.floor(rand2() * 3)] ?? "in";
1051
1056
  const angle = rand2() * Math.PI * 2;
1052
1057
  const panFrac = 0.4 + rand2() * 0.35;
@@ -1070,19 +1075,9 @@ function photoMontage(images, opts = {}) {
1070
1075
  yA = cy + dy * (kA - 1) * (H / 2) * panFrac;
1071
1076
  yB = cy + dy * (kB - 1) * (H / 2) * panFrac;
1072
1077
  }
1078
+ const box = { id: nid, src: slide.src, x: xA, y: yA, width: W, height: H, anchor: "center", fit: "cover", scale: kA, opacity: i === 0 ? 1 : 0 };
1073
1079
  nodes.push(
1074
- image({
1075
- id: nid,
1076
- src: slide.src,
1077
- x: xA,
1078
- y: yA,
1079
- width: W,
1080
- height: H,
1081
- anchor: "center",
1082
- fit: "cover",
1083
- scale: kA,
1084
- opacity: i === 0 ? 1 : 0
1085
- })
1080
+ isVideoSrc(slide.src) ? video({ ...box, start: shotStart, volume: slide.volume ?? 0 }) : image(box)
1086
1081
  );
1087
1082
  const ken = tween(
1088
1083
  nid,
@@ -1134,6 +1129,7 @@ function photoMontage(images, opts = {}) {
1134
1129
  }
1135
1130
  return { nodes, timeline: beat("montage", { nodes: nodes.map((n3) => n3.id) }, [seq(...shots)]) };
1136
1131
  }
1132
+ var videoMontage = photoMontage;
1137
1133
 
1138
1134
  // ../core/src/presets.ts
1139
1135
  var PRESET_NAMES = [
@@ -3488,6 +3484,7 @@ export {
3488
3484
  validateComposition,
3489
3485
  validateScene,
3490
3486
  video,
3487
+ videoMontage,
3491
3488
  wait,
3492
3489
  wiggle
3493
3490
  };
@@ -8,7 +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
+ export { photoMontage, videoMontage, type MontageImage, type MontageOpts, type MontageResult, type KenBurns } from "./montage.js";
12
12
  export { motionPreset, PRESET_NAMES, type PresetName, type PresetRig, type PresetOpts } from "./presets.js";
13
13
  export { devicePreset, deviceScreen, deviceScreenCenter, deviceBounds, deviceScreenPoint, DEVICE_PRESET_NAMES, type DevicePresetName, type DevicePresetOpts } from "./devicePreset.js";
14
14
  export { cursor, cursorTo, cursorPath, cursorClick, cursorDouble, type CursorStyle, type CursorOpts, type CursorToOpts, type CursorPathOpts, type CursorClickOpts } from "./cursor.js";
@@ -1,9 +1,10 @@
1
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`.
2
+ * Photo/video montage — a SEEDED GENERATOR that turns a list of shots (images AND
3
+ * video clips, mixed freely) into a polished slideshow: layered image/video nodes +
4
+ * a retimable `beat` that crossfades between shots and pans/zooms each (Ken Burns),
5
+ * with an optional cinematic grade (vignette + bottom scrim) built from gradients +
6
+ * blend modes. A video src plays as a clip for its `hold`. The photo analog of
7
+ * `motionPreset` / `splitText`. (`videoMontage` is the same generator, by intent.)
7
8
  *
8
9
  * Pure and deterministic: the per-slide Ken Burns direction / framing is chosen by
9
10
  * a seeded PRNG (mulberry32) — same (images, opts) → identical IR; a different
@@ -16,11 +17,16 @@
16
17
  */
17
18
  import type { NodeIR, TimelineIR } from "./ir.js";
18
19
  export type KenBurns = "in" | "out" | "pan";
19
- /** One slide: a bare src, or a src with per-slide overrides. */
20
+ /**
21
+ * One shot: a bare src, or a src with per-shot overrides. A video src plays as a
22
+ * clip for its `hold`; `volume` (video shots only) is the clip-audio gain — default
23
+ * 0 (muted) in a montage to avoid stacking soundtracks; set it per shot to include.
24
+ */
20
25
  export type MontageImage = string | {
21
26
  src: string;
22
27
  hold?: number;
23
28
  ken?: KenBurns;
29
+ volume?: number;
24
30
  };
25
31
  export interface MontageOpts {
26
32
  /** Node-id prefix → stable regen addresses `${id}-${i}`. Default "shot". */
@@ -54,3 +60,10 @@ export interface MontageResult {
54
60
  * scene({ size, nodes: [...m.nodes, ...titles], timeline: seq(m.timeline) });
55
61
  */
56
62
  export declare function photoMontage(images: MontageImage[], opts?: MontageOpts): MontageResult;
63
+ /**
64
+ * Same as `photoMontage`, named for clip-driven montages — shots may be images or
65
+ * video clips (mixed freely; a video src plays for its `hold`, muted by default).
66
+ *
67
+ * videoMontage(["intro.jpg", "shot-a.mp4", { src: "shot-b.mp4", volume: 1 }], { seed: 3 })
68
+ */
69
+ export declare const videoMontage: typeof photoMontage;
@@ -291,14 +291,16 @@ const T = splitText("MOTION IS DATA", { id: "t", x: 960, y: 470, fontSize: 130 }
291
291
  Every effect is seeded (same `seed` → identical) and pure keyframes. To time a
292
292
  `textLoop` window, add up the `textIn` beat length (≈ `(n-1)·stagger + glyphDur`).
293
293
 
294
- ## Photo montage (`photoMontage`)
294
+ ## Photo / video montage (`photoMontage` / `videoMontage`)
295
295
 
296
- Turn a list of images into a polished slideshow — crossfades + seeded Ken Burns
296
+ Turn a list of shots into a polished slideshow — crossfades + seeded Ken Burns
297
297
  (pan/zoom) + an optional cinematic grade (vignette + bottom scrim via gradients +
298
- blend) — without hand-wiring each move. The photo analog of `motionPreset`.
298
+ blend) — without hand-wiring each move. Shots may be images AND video clips, mixed
299
+ freely (a video src, by extension, plays as a clip for its `hold`). `videoMontage`
300
+ is the same generator, named for clip-driven cuts. The photo analog of `motionPreset`.
299
301
 
300
302
  ```ts
301
- const m = photoMontage(["a.jpg", "b.jpg", "c.jpg"], {
303
+ const m = videoMontage(["a.jpg", { src: "b.mp4", volume: 1 }, "c.jpg"], {
302
304
  id: "shot", size: { width: 1920, height: 1080 },
303
305
  hold: 3.4, transition: 0.7, zoom: 1.16, seed: 7,
304
306
  });
@@ -306,16 +308,20 @@ scene({ size, nodes: [...m.nodes, ...titles], timeline: par(m.timeline, titleTra
306
308
  ```
307
309
 
308
310
  - Returns `{ nodes, timeline }` (like `splitText` owns its glyph nodes). `nodes` are
309
- the stacked image layers (+ `${id}-vignette` / `${id}-scrim` grade overlays);
311
+ the stacked image/video layers (+ `${id}-vignette` / `${id}-scrim` grade overlays);
310
312
  `timeline` is a retimable `beat("montage", …)`. Stable addresses: `${id}-${i}`,
311
313
  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
+ - **Any-aspect media works** — each layer uses `fit: "cover"`, so the renderer
315
+ crops to fill the frame at the source's aspect (no pre-cropping, no distortion).
314
316
  The Ken Burns keeps `scale ≥ 1` with the pan bounded to its slack, so an edge is
315
317
  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.
318
+ - Per-shot overrides: `{ src, hold?, ken?, volume? }` where `ken` is `"in" | "out" |
319
+ "pan"`. A **video** shot plays as a clip from its slot's start; its audio is **muted
320
+ by default** in a montage — set `volume` (per shot) to include it, or add a `scene.audio`
321
+ bed.
322
+ - Seeded + pure (same `(shots, opts)` → identical IR). Note: image/video sources do
323
+ not render in `reframe player` / artifacts — montage ships as mp4. See
324
+ `examples/scenes/video-montage.ts`.
319
325
 
320
326
  ## Video clips (`video`)
321
327
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reframe-video",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
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",