reframe-video 0.6.5 → 0.6.7

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,6 +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", "volume"],
360
361
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
361
362
  group: COMMON_PROPS
362
363
  };
@@ -654,6 +655,10 @@ function image(props) {
654
655
  const { id, ...rest } = props;
655
656
  return { type: "image", id, props: rest };
656
657
  }
658
+ function video(props) {
659
+ const { id, ...rest } = props;
660
+ return { type: "video", id, props: rest };
661
+ }
657
662
  function path(props) {
658
663
  const { id, ...rest } = props;
659
664
  return { type: "path", id, props: rest };
@@ -2623,11 +2628,34 @@ var SFX_DURATION = {
2623
2628
  thud: 0.25
2624
2629
  };
2625
2630
  var FILE_CUE_DURATION = 0.4;
2631
+ function collectClipAudio(ir, duration, warnings) {
2632
+ const out = [];
2633
+ const walk = (nodes) => {
2634
+ for (const node of nodes) {
2635
+ if (node.type === "video") {
2636
+ const gain = node.props.volume ?? 1;
2637
+ const start = node.props.start ?? 0;
2638
+ if (gain <= 0) continue;
2639
+ if (start >= duration) {
2640
+ warnings.push(`video "${node.id}": start ${start.toFixed(2)}s past the scene end \u2014 audio dropped`);
2641
+ continue;
2642
+ }
2643
+ out.push({ nodeId: node.id, src: node.props.src, start, rate: node.props.rate ?? 1, clipStart: node.props.clipStart ?? 0, gain });
2644
+ }
2645
+ if (node.type === "group") walk(node.children);
2646
+ }
2647
+ };
2648
+ walk(ir.nodes);
2649
+ return out;
2650
+ }
2626
2651
  function resolveAudioPlan(compiled) {
2627
2652
  const audio = compiled.ir.audio;
2628
- if (!audio || !audio.bgm && (audio.cues ?? []).length === 0) return null;
2629
2653
  const warnings = [];
2630
2654
  const duration = compiled.duration;
2655
+ const clipAudio = collectClipAudio(compiled.ir, duration, warnings);
2656
+ if (!audio || !audio.bgm && (audio.cues ?? []).length === 0) {
2657
+ return clipAudio.length === 0 ? null : { duration, bgm: null, cues: [], duckWindows: [], clipAudio, warnings };
2658
+ }
2631
2659
  const cues = [];
2632
2660
  for (const [index, cue] of (audio.cues ?? []).entries()) {
2633
2661
  let anchor;
@@ -2663,6 +2691,7 @@ function resolveAudioPlan(compiled) {
2663
2691
  bgm: resolveBgm(audio.bgm),
2664
2692
  cues,
2665
2693
  duckWindows: mergeDuckWindows(cues, duration),
2694
+ clipAudio,
2666
2695
  warnings
2667
2696
  };
2668
2697
  }
@@ -2696,6 +2725,7 @@ function resolveCompositionAudioPlan(comp) {
2696
2725
  const duration = comp.duration;
2697
2726
  const warnings = [];
2698
2727
  const cues = [];
2728
+ const clipAudio = [];
2699
2729
  for (const placement of comp.scenes) {
2700
2730
  const plan = resolveAudioPlan(placement.compiled);
2701
2731
  if (!plan) continue;
@@ -2708,6 +2738,11 @@ function resolveCompositionAudioPlan(comp) {
2708
2738
  if (t >= duration) continue;
2709
2739
  cues.push({ ...cue, t });
2710
2740
  }
2741
+ for (const clip of plan.clipAudio) {
2742
+ const start = clip.start + placement.start;
2743
+ if (start >= duration) continue;
2744
+ clipAudio.push({ ...clip, start });
2745
+ }
2711
2746
  }
2712
2747
  for (const [index, cue] of (audio?.cues ?? []).entries()) {
2713
2748
  if (typeof cue.at !== "number") {
@@ -2727,13 +2762,14 @@ function resolveCompositionAudioPlan(comp) {
2727
2762
  source: cue.sfx ? { kind: "sfx", name: cue.sfx, params: cue.params ?? {} } : { kind: "file", path: cue.file }
2728
2763
  });
2729
2764
  }
2730
- if (!audio?.bgm && cues.length === 0) return null;
2765
+ if (!audio?.bgm && cues.length === 0 && clipAudio.length === 0) return null;
2731
2766
  cues.sort((a, b) => a.t - b.t);
2732
2767
  return {
2733
2768
  duration,
2734
2769
  bgm: resolveBgm(audio?.bgm),
2735
2770
  cues,
2736
2771
  duckWindows: mergeDuckWindows(cues, duration),
2772
+ clipAudio,
2737
2773
  warnings
2738
2774
  };
2739
2775
  }
@@ -3187,6 +3223,33 @@ function evaluate(compiled, t) {
3187
3223
  });
3188
3224
  return;
3189
3225
  }
3226
+ case "video": {
3227
+ const width = num(id, "width", node.props.width);
3228
+ const height = num(id, "height", node.props.height);
3229
+ const [ax, ay] = ANCHOR_FACTORS[node.props.anchor ?? "top-left"];
3230
+ const fps = compiled.ir.fps ?? 30;
3231
+ const start = node.props.start ?? 0;
3232
+ const rate = node.props.rate ?? 1;
3233
+ const clipStart = node.props.clipStart ?? 0;
3234
+ const srcT = clipStart + Math.max(0, t - start) * rate;
3235
+ const frame = Math.max(0, Math.round(srcT * fps));
3236
+ ops.push({
3237
+ type: "video",
3238
+ id,
3239
+ transform: matrix,
3240
+ opacity,
3241
+ src: str(id, "src", node.props.src),
3242
+ width,
3243
+ height,
3244
+ offsetX: -width * ax,
3245
+ offsetY: -height * ay,
3246
+ frame,
3247
+ ...node.props.fit && node.props.fit !== "fill" ? { fit: node.props.fit } : {},
3248
+ ...fx,
3249
+ ...clipSpread
3250
+ });
3251
+ return;
3252
+ }
3190
3253
  case "path": {
3191
3254
  const ox = num(id, "originX", node.props.originX ?? 0);
3192
3255
  const oy = num(id, "originY", node.props.originY ?? 0);
@@ -3255,13 +3318,13 @@ function evaluate(compiled, t) {
3255
3318
  }
3256
3319
 
3257
3320
  // ../core/src/assets.ts
3258
- function collectImageSrcs(ir) {
3321
+ function collectSrcs(ir, type) {
3259
3322
  const srcs = /* @__PURE__ */ new Set();
3260
- const imageIds = /* @__PURE__ */ new Set();
3323
+ const ids = /* @__PURE__ */ new Set();
3261
3324
  const walkNodes = (nodes) => {
3262
3325
  for (const node of nodes) {
3263
- if (node.type === "image") {
3264
- imageIds.add(node.id);
3326
+ if (node.type === type) {
3327
+ ids.add(node.id);
3265
3328
  srcs.add(node.props.src);
3266
3329
  }
3267
3330
  if (node.type === "group") walkNodes(node.children);
@@ -3270,14 +3333,14 @@ function collectImageSrcs(ir) {
3270
3333
  walkNodes(ir.nodes);
3271
3334
  for (const overrides of Object.values(ir.states ?? {})) {
3272
3335
  for (const [nodeId, props] of Object.entries(overrides)) {
3273
- if (imageIds.has(nodeId) && typeof props.src === "string") srcs.add(props.src);
3336
+ if (ids.has(nodeId) && typeof props.src === "string") srcs.add(props.src);
3274
3337
  }
3275
3338
  }
3276
3339
  const walkTimeline = (step) => {
3277
3340
  if (!step) return;
3278
3341
  if (step.kind === "seq" || step.kind === "par" || step.kind === "stagger") {
3279
3342
  for (const child of step.children) walkTimeline(child);
3280
- } else if (step.kind === "tween" && imageIds.has(step.target)) {
3343
+ } else if (step.kind === "tween" && ids.has(step.target)) {
3281
3344
  const src = step.props.src;
3282
3345
  if (typeof src === "string") srcs.add(src);
3283
3346
  }
@@ -3285,6 +3348,12 @@ function collectImageSrcs(ir) {
3285
3348
  walkTimeline(ir.timeline);
3286
3349
  return [...srcs];
3287
3350
  }
3351
+ function collectImageSrcs(ir) {
3352
+ return collectSrcs(ir, "image");
3353
+ }
3354
+ function collectVideoSrcs(ir) {
3355
+ return collectSrcs(ir, "video");
3356
+ }
3288
3357
 
3289
3358
  // ../core/src/motion.ts
3290
3359
  var EASE_BY_CLASS = {
@@ -3351,6 +3420,7 @@ export {
3351
3420
  cameraTo,
3352
3421
  characterPreset,
3353
3422
  collectImageSrcs,
3423
+ collectVideoSrcs,
3354
3424
  compileComposition,
3355
3425
  compileScene,
3356
3426
  composeScene,
@@ -3417,6 +3487,7 @@ export {
3417
3487
  tween,
3418
3488
  validateComposition,
3419
3489
  validateScene,
3490
+ video,
3420
3491
  wait,
3421
3492
  wiggle
3422
3493
  };
package/dist/labels.js CHANGED
@@ -341,6 +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", "volume"],
344
345
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
345
346
  group: COMMON_PROPS
346
347
  };
@@ -25,7 +25,7 @@ function resolvePaint(ctx, paint, box) {
25
25
  for (const s of paint.stops) g.addColorStop(Math.max(0, Math.min(1, s.offset)), s.color);
26
26
  return g;
27
27
  }
28
- function renderFrame(ctx, compiled, t, images) {
28
+ function renderFrame(ctx, compiled, t, images, videos) {
29
29
  const { size, background } = compiled.ir;
30
30
  ctx.setTransform(1, 0, 0, 1, 0, 0);
31
31
  ctx.clearRect(0, 0, size.width, size.height);
@@ -33,9 +33,9 @@ function renderFrame(ctx, compiled, t, images) {
33
33
  ctx.fillStyle = background;
34
34
  ctx.fillRect(0, 0, size.width, size.height);
35
35
  }
36
- drawDisplayList(ctx, evaluate(compiled, t), images);
36
+ drawDisplayList(ctx, evaluate(compiled, t), images, videos);
37
37
  }
38
- function drawDisplayList(ctx, ops, images) {
38
+ function drawDisplayList(ctx, ops, images, videos) {
39
39
  for (const op of ops) {
40
40
  ctx.save();
41
41
  if (op.clips) {
@@ -117,28 +117,11 @@ function drawDisplayList(ctx, ops, images) {
117
117
  break;
118
118
  }
119
119
  case "image": {
120
- const img = images?.get(op.src);
121
- if (img) {
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
- }
129
- } else {
130
- ctx.fillStyle = "#2A2A30";
131
- ctx.fillRect(op.offsetX, op.offsetY, op.width, op.height);
132
- ctx.strokeStyle = "#FF00FF";
133
- ctx.lineWidth = 2;
134
- ctx.strokeRect(op.offsetX, op.offsetY, op.width, op.height);
135
- ctx.beginPath();
136
- ctx.moveTo(op.offsetX, op.offsetY);
137
- ctx.lineTo(op.offsetX + op.width, op.offsetY + op.height);
138
- ctx.moveTo(op.offsetX + op.width, op.offsetY);
139
- ctx.lineTo(op.offsetX, op.offsetY + op.height);
140
- ctx.stroke();
141
- }
120
+ drawRaster(ctx, images?.get(op.src), op);
121
+ break;
122
+ }
123
+ case "video": {
124
+ drawRaster(ctx, videos?.frame(op.src, op.frame), op);
142
125
  break;
143
126
  }
144
127
  case "path": {
@@ -193,6 +176,29 @@ function intrinsicSize(img) {
193
176
  const a = img;
194
177
  return [a.naturalWidth || a.width || 0, a.naturalHeight || a.height || 0];
195
178
  }
179
+ function drawRaster(ctx, img, op) {
180
+ if (img) {
181
+ if (op.fit === "cover") {
182
+ const [iw, ih] = intrinsicSize(img);
183
+ const { sx, sy, sw, sh } = coverRect(iw, ih, op.width, op.height);
184
+ ctx.drawImage(img, sx, sy, sw, sh, op.offsetX, op.offsetY, op.width, op.height);
185
+ } else {
186
+ ctx.drawImage(img, op.offsetX, op.offsetY, op.width, op.height);
187
+ }
188
+ return;
189
+ }
190
+ ctx.fillStyle = "#2A2A30";
191
+ ctx.fillRect(op.offsetX, op.offsetY, op.width, op.height);
192
+ ctx.strokeStyle = "#FF00FF";
193
+ ctx.lineWidth = 2;
194
+ ctx.strokeRect(op.offsetX, op.offsetY, op.width, op.height);
195
+ ctx.beginPath();
196
+ ctx.moveTo(op.offsetX, op.offsetY);
197
+ ctx.lineTo(op.offsetX + op.width, op.offsetY + op.height);
198
+ ctx.moveTo(op.offsetX + op.width, op.offsetY);
199
+ ctx.lineTo(op.offsetX, op.offsetY + op.height);
200
+ ctx.stroke();
201
+ }
196
202
  function quoteFamily(family) {
197
203
  return family.includes(" ") && !family.includes('"') ? `"${family}"` : family;
198
204
  }
package/dist/trace-cli.js CHANGED
@@ -14,6 +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", "volume"],
17
18
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
18
19
  group: COMMON_PROPS
19
20
  };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Asset discovery shared by every consumer that must preload images before
2
+ * Asset discovery shared by every consumer that must preload media before
3
3
  * rendering (the capture page and the preview). One walker means the two
4
4
  * sides can never disagree about which srcs a scene uses — including srcs
5
5
  * introduced only mid-scene by a state override or a tween.
@@ -7,3 +7,5 @@
7
7
  import type { SceneIR } from "./ir.js";
8
8
  /** All image srcs a scene can ever display, deduped, in discovery order. */
9
9
  export declare function collectImageSrcs(ir: SceneIR): string[];
10
+ /** All video srcs a scene can ever display, deduped, in discovery order. */
11
+ export declare function collectVideoSrcs(ir: SceneIR): string[];
@@ -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;
@@ -2,7 +2,7 @@
2
2
  * The eDSL surface: thin factories that build plain IR objects.
3
3
  * `scene()` validates and returns the IR — the return value is the document.
4
4
  */
5
- import type { AudioIR, BehaviorIR, CameraIR, CompositionIR, CompositionSceneEntry, Ease, EllipseProps, GroupProps, ImageProps, LineProps, NodeIR, PathProps, PropValue, RectProps, SceneIR, Size, StateOverride, TextProps, TimelineIR } from "./ir.js";
5
+ import type { AudioIR, BehaviorIR, CameraIR, CompositionIR, CompositionSceneEntry, Ease, EllipseProps, GroupProps, ImageProps, LineProps, NodeIR, PathProps, PropValue, RectProps, SceneIR, Size, StateOverride, TextProps, TimelineIR, VideoProps } from "./ir.js";
6
6
  export interface SceneInput {
7
7
  id: string;
8
8
  size: Size;
@@ -42,6 +42,9 @@ export declare function text(props: {
42
42
  export declare function image(props: {
43
43
  id: string;
44
44
  } & ImageProps): NodeIR;
45
+ export declare function video(props: {
46
+ id: string;
47
+ } & VideoProps): NodeIR;
45
48
  export declare function path(props: {
46
49
  id: string;
47
50
  } & PathProps): NodeIR;
@@ -80,6 +80,18 @@ export type DisplayOp = (OpBase & {
80
80
  offsetY: number;
81
81
  /** Box-fit; present only when authored and not "fill". */
82
82
  fit?: ImageFit;
83
+ }) | (OpBase & {
84
+ type: "video";
85
+ /** Raw src string as authored in the IR — consumers resolve it. */
86
+ src: string;
87
+ width: number;
88
+ height: number;
89
+ offsetX: number;
90
+ offsetY: number;
91
+ /** Source frame index at scene-time t (renderer clamps to the extracted count). */
92
+ frame: number;
93
+ /** Box-fit; present only when authored and not "fill". */
94
+ fit?: ImageFit;
83
95
  }) | (OpBase & {
84
96
  type: "path";
85
97
  /** SVG path data, drawn via Path2D. */
@@ -17,9 +17,9 @@ 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";
24
- export { collectImageSrcs } from "./assets.js";
24
+ export { collectImageSrcs, collectVideoSrcs } from "./assets.js";
25
25
  export { sketchToTimeline, type MotionSketch, type MotionEvent, type MotionEventKind, type MotionRegion, } from "./motion.js";
@@ -192,6 +192,28 @@ export interface ImageProps extends BaseProps {
192
192
  }
193
193
  /** Image box-fit mode. `cover` = crop-to-fill at the image's aspect (centered). */
194
194
  export type ImageFit = "fill" | "cover";
195
+ export interface VideoProps extends BaseProps {
196
+ /** Video file path (absolute, or relative to the scene file). */
197
+ src: string;
198
+ width: number;
199
+ height: number;
200
+ /** Box-fit into width×height, like the image node. `"fill"` (default) | `"cover"`. */
201
+ fit?: ImageFit;
202
+ /**
203
+ * Scene-time (seconds) at which playback begins. Before it, frame 0 (clipStart) shows;
204
+ * the node's visibility is still controlled by opacity/timeline. Default 0.
205
+ */
206
+ start?: number;
207
+ /** Playback speed multiplier (2 = double speed). Default 1. */
208
+ rate?: number;
209
+ /** Source in-point (seconds) shown at `start`. Default 0. */
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;
216
+ }
195
217
  export type NodeIR = {
196
218
  type: "rect";
197
219
  id: string;
@@ -212,6 +234,10 @@ export type NodeIR = {
212
234
  type: "image";
213
235
  id: string;
214
236
  props: ImageProps;
237
+ } | {
238
+ type: "video";
239
+ id: string;
240
+ props: VideoProps;
215
241
  } | {
216
242
  type: "path";
217
243
  id: string;
@@ -317,6 +317,31 @@ scene({ size, nodes: [...m.nodes, ...titles], timeline: par(m.timeline, titleTra
317
317
  - Seeded + pure (same `(images, opts)` → identical IR). Note: image-node sources do
318
318
  not render in `reframe player` / artifacts — montage ships as mp4.
319
319
 
320
+ ## Video clips (`video`)
321
+
322
+ Draw a video clip as a layer. It plays on the scene clock — at scene-time `t` it
323
+ shows the source frame at `clipStart + max(0, t - start) * rate`.
324
+
325
+ ```ts
326
+ 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, volume: 1 })
328
+ tween("clip", { scale: 1.08 }, { duration: 5 }) // transform composes with playback (Ken Burns)
329
+ ```
330
+
331
+ - Props: `src` (mp4 / mov / webm / m4v / mkv, absolute or scene-relative), `width`/`height`,
332
+ `fit` (`"cover"` like the image node), `start` (scene-time playback begins), `rate`
333
+ (speed), `clipStart` (source in-point s), `volume` (clip-audio gain, default 1; `0` mutes).
334
+ Transform/opacity/effects compose as usual.
335
+ - **Deterministic by frame extraction**: render-cli runs `ffmpeg -vf fps=<sceneFps>` to pull
336
+ the clip's frames, and the renderer draws frame `round(t·fps)` — no live `<video>` seek, so
337
+ it stays byte-identical (same machine).
338
+ - **Clip audio**: the clip's own audio track is muxed into the output, placed at `start`
339
+ (trimmed from `clipStart`, sped by `rate`, scaled by `volume`), mixed with `scene.audio`.
340
+ A clip with no audio stream is skipped; set `volume: 0` to drop a clip's sound.
341
+ - **Limitations**: all frames are pre-decoded so keep clips short (≤~5s); like images, video
342
+ sources are not rendered in `reframe player` / artifacts (mp4 only). See
343
+ `examples/scenes/video-demo.ts`.
344
+
320
345
  ## Cursor (UI demos)
321
346
 
322
347
  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.5",
3
+ "version": "0.6.7",
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",
@@ -551,7 +551,8 @@ function opCorners(op: DisplayOp): [number, number][] {
551
551
  switch (op.type) {
552
552
  case "rect":
553
553
  case "ellipse":
554
- case "image": {
554
+ case "image":
555
+ case "video": {
555
556
  const { offsetX: x, offsetY: y, width: w, height: h } = op;
556
557
  return [[x, y], [x + w, y], [x + w, y + h], [x, y + h]].map(([px, py]) =>
557
558
  applyMat(op.transform, px!, py!),