reframe-video 0.6.6 → 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.
package/dist/index.js CHANGED
@@ -357,7 +357,7 @@ var PROPS_BY_TYPE = {
357
357
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
358
358
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
359
359
  image: [...COMMON_PROPS, "src", "width", "height", "fit"],
360
- video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart"],
360
+ video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume"],
361
361
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
362
362
  group: COMMON_PROPS
363
363
  };
@@ -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 = [
@@ -2628,11 +2624,34 @@ var SFX_DURATION = {
2628
2624
  thud: 0.25
2629
2625
  };
2630
2626
  var FILE_CUE_DURATION = 0.4;
2627
+ function collectClipAudio(ir, duration, warnings) {
2628
+ const out = [];
2629
+ const walk = (nodes) => {
2630
+ for (const node of nodes) {
2631
+ if (node.type === "video") {
2632
+ const gain = node.props.volume ?? 1;
2633
+ const start = node.props.start ?? 0;
2634
+ if (gain <= 0) continue;
2635
+ if (start >= duration) {
2636
+ warnings.push(`video "${node.id}": start ${start.toFixed(2)}s past the scene end \u2014 audio dropped`);
2637
+ continue;
2638
+ }
2639
+ out.push({ nodeId: node.id, src: node.props.src, start, rate: node.props.rate ?? 1, clipStart: node.props.clipStart ?? 0, gain });
2640
+ }
2641
+ if (node.type === "group") walk(node.children);
2642
+ }
2643
+ };
2644
+ walk(ir.nodes);
2645
+ return out;
2646
+ }
2631
2647
  function resolveAudioPlan(compiled) {
2632
2648
  const audio = compiled.ir.audio;
2633
- if (!audio || !audio.bgm && (audio.cues ?? []).length === 0) return null;
2634
2649
  const warnings = [];
2635
2650
  const duration = compiled.duration;
2651
+ const clipAudio = collectClipAudio(compiled.ir, duration, warnings);
2652
+ if (!audio || !audio.bgm && (audio.cues ?? []).length === 0) {
2653
+ return clipAudio.length === 0 ? null : { duration, bgm: null, cues: [], duckWindows: [], clipAudio, warnings };
2654
+ }
2636
2655
  const cues = [];
2637
2656
  for (const [index, cue] of (audio.cues ?? []).entries()) {
2638
2657
  let anchor;
@@ -2668,6 +2687,7 @@ function resolveAudioPlan(compiled) {
2668
2687
  bgm: resolveBgm(audio.bgm),
2669
2688
  cues,
2670
2689
  duckWindows: mergeDuckWindows(cues, duration),
2690
+ clipAudio,
2671
2691
  warnings
2672
2692
  };
2673
2693
  }
@@ -2701,6 +2721,7 @@ function resolveCompositionAudioPlan(comp) {
2701
2721
  const duration = comp.duration;
2702
2722
  const warnings = [];
2703
2723
  const cues = [];
2724
+ const clipAudio = [];
2704
2725
  for (const placement of comp.scenes) {
2705
2726
  const plan = resolveAudioPlan(placement.compiled);
2706
2727
  if (!plan) continue;
@@ -2713,6 +2734,11 @@ function resolveCompositionAudioPlan(comp) {
2713
2734
  if (t >= duration) continue;
2714
2735
  cues.push({ ...cue, t });
2715
2736
  }
2737
+ for (const clip of plan.clipAudio) {
2738
+ const start = clip.start + placement.start;
2739
+ if (start >= duration) continue;
2740
+ clipAudio.push({ ...clip, start });
2741
+ }
2716
2742
  }
2717
2743
  for (const [index, cue] of (audio?.cues ?? []).entries()) {
2718
2744
  if (typeof cue.at !== "number") {
@@ -2732,13 +2758,14 @@ function resolveCompositionAudioPlan(comp) {
2732
2758
  source: cue.sfx ? { kind: "sfx", name: cue.sfx, params: cue.params ?? {} } : { kind: "file", path: cue.file }
2733
2759
  });
2734
2760
  }
2735
- if (!audio?.bgm && cues.length === 0) return null;
2761
+ if (!audio?.bgm && cues.length === 0 && clipAudio.length === 0) return null;
2736
2762
  cues.sort((a, b) => a.t - b.t);
2737
2763
  return {
2738
2764
  duration,
2739
2765
  bgm: resolveBgm(audio?.bgm),
2740
2766
  cues,
2741
2767
  duckWindows: mergeDuckWindows(cues, duration),
2768
+ clipAudio,
2742
2769
  warnings
2743
2770
  };
2744
2771
  }
@@ -3457,6 +3484,7 @@ export {
3457
3484
  validateComposition,
3458
3485
  validateScene,
3459
3486
  video,
3487
+ videoMontage,
3460
3488
  wait,
3461
3489
  wiggle
3462
3490
  };
package/dist/labels.js CHANGED
@@ -341,7 +341,7 @@ var PROPS_BY_TYPE = {
341
341
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
342
342
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
343
343
  image: [...COMMON_PROPS, "src", "width", "height", "fit"],
344
- video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart"],
344
+ video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume"],
345
345
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
346
346
  group: COMMON_PROPS
347
347
  };
package/dist/trace-cli.js CHANGED
@@ -14,7 +14,7 @@ var PROPS_BY_TYPE = {
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
16
  image: [...COMMON_PROPS, "src", "width", "height", "fit"],
17
- video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart"],
17
+ video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume"],
18
18
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
19
19
  group: COMMON_PROPS
20
20
  };
@@ -24,6 +24,19 @@ export interface ResolvedCue {
24
24
  path: string;
25
25
  };
26
26
  }
27
+ /** A video node's own audio track, placed on the scene clock. */
28
+ export interface ClipAudio {
29
+ nodeId: string;
30
+ src: string;
31
+ /** Scene-time (s) the clip's audio begins. */
32
+ start: number;
33
+ /** Playback speed (atempo). */
34
+ rate: number;
35
+ /** Source in-point (s) — audio is trimmed to begin here. */
36
+ clipStart: number;
37
+ /** Linear gain. */
38
+ gain: number;
39
+ }
27
40
  export interface AudioPlan {
28
41
  duration: number;
29
42
  bgm: {
@@ -49,6 +62,8 @@ export interface AudioPlan {
49
62
  t0: number;
50
63
  t1: number;
51
64
  }[];
65
+ /** Video clip soundtracks to mux in (a clip with no audio stream is skipped at render). */
66
+ clipAudio: ClipAudio[];
52
67
  warnings: string[];
53
68
  }
54
69
  export declare function resolveAudioPlan(compiled: CompiledScene): AudioPlan | null;
@@ -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";
@@ -17,7 +17,7 @@ export { characterPreset, CHARACTER_PRESET_NAMES, type CharacterPresetName, type
17
17
  export { figure, type FigureStyle, type FigureOpts, type FigurePalette } from "./figure.js";
18
18
  export { splitText, textIn, textLoop, textOut, textTypeCues, type SplitOpts, type Glyph, type TextBlock, type FontWeight, type TextInName, type TextLoopName, type TextOutName, type TextLoopOpts, type TextOutOpts, type TypeCueOpts, } from "./textFx.js";
19
19
  export { motionOp, motionOpLabel, MOTION_OPS, type MotionOpName, type MotionOpOpts, type MotionOpResult } from "./motionOps.js";
20
- export { resolveAudioPlan, resolveCompositionAudioPlan, SFX_DURATION, type AudioPlan, type ResolvedCue, } from "./audio.js";
20
+ export { resolveAudioPlan, resolveCompositionAudioPlan, SFX_DURATION, type AudioPlan, type ResolvedCue, type ClipAudio, } from "./audio.js";
21
21
  export { evaluate, sampleProp, nodeParentMatrix, type DisplayList, type DisplayOp, type Mat2D, type ClipRegion, type TextAlign, type TextBaseline, } from "./evaluate.js";
22
22
  export { resolveEase, lerpValue, isColor, EASE_NAMES } from "./interpolate.js";
23
23
  export { sampleBehavior } from "./behaviors.js";
@@ -208,6 +208,11 @@ export interface VideoProps extends BaseProps {
208
208
  rate?: number;
209
209
  /** Source in-point (seconds) shown at `start`. Default 0. */
210
210
  clipStart?: number;
211
+ /**
212
+ * Linear gain for the clip's own audio track, muxed into the output at `start`
213
+ * (trimmed from `clipStart`, sped by `rate`). Default 1; `0` mutes the clip.
214
+ */
215
+ volume?: number;
211
216
  }
212
217
  export type NodeIR = {
213
218
  type: "rect";
@@ -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
 
@@ -324,19 +330,23 @@ shows the source frame at `clipStart + max(0, t - start) * rate`.
324
330
 
325
331
  ```ts
326
332
  video({ id: "clip", src: "shot.mp4", x: 960, y: 540, width: 1920, height: 1080,
327
- anchor: "center", fit: "cover", start: 0, rate: 1, clipStart: 0 })
333
+ anchor: "center", fit: "cover", start: 0, rate: 1, clipStart: 0, volume: 1 })
328
334
  tween("clip", { scale: 1.08 }, { duration: 5 }) // transform composes with playback (Ken Burns)
329
335
  ```
330
336
 
331
337
  - Props: `src` (mp4 / mov / webm / m4v / mkv, absolute or scene-relative), `width`/`height`,
332
338
  `fit` (`"cover"` like the image node), `start` (scene-time playback begins), `rate`
333
- (speed), `clipStart` (source in-point s). Transform/opacity/effects compose as usual.
339
+ (speed), `clipStart` (source in-point s), `volume` (clip-audio gain, default 1; `0` mutes).
340
+ Transform/opacity/effects compose as usual.
334
341
  - **Deterministic by frame extraction**: render-cli runs `ffmpeg -vf fps=<sceneFps>` to pull
335
342
  the clip's frames, and the renderer draws frame `round(t·fps)` — no live `<video>` seek, so
336
343
  it stays byte-identical (same machine).
337
- - **v1 limitations**: visual-only (the clip's own audio is not muxed use `scene.audio`);
338
- all frames are pre-decoded so keep clips short (≤~5s); like images, not rendered in
339
- `reframe player` / artifacts (mp4 only). See `examples/scenes/video-demo.ts`.
344
+ - **Clip audio**: the clip's own audio track is muxed into the output, placed at `start`
345
+ (trimmed from `clipStart`, sped by `rate`, scaled by `volume`), mixed with `scene.audio`.
346
+ A clip with no audio stream is skipped; set `volume: 0` to drop a clip's sound.
347
+ - **Limitations**: all frames are pre-decoded so keep clips short (≤~5s); like images, video
348
+ sources are not rendered in `reframe player` / artifacts (mp4 only). See
349
+ `examples/scenes/video-demo.ts`.
340
350
 
341
351
  ## Cursor (UI demos)
342
352
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reframe-video",
3
- "version": "0.6.6",
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",