reframe-video 0.6.4 → 0.6.6

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
@@ -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,8 @@ 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"],
360
+ video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart"],
359
361
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
360
362
  group: COMMON_PROPS
361
363
  };
@@ -402,6 +404,7 @@ function validateScene(ir) {
402
404
  if (typeof props.blur === "number" && props.blur < 0) problems.push(`node "${node.id}": blur must be >= 0`);
403
405
  if (typeof props.shadowBlur === "number" && props.shadowBlur < 0) problems.push(`node "${node.id}": shadowBlur must be >= 0`);
404
406
  if (typeof props.blend === "string" && !BLEND_MODES.has(props.blend)) problems.push(`node "${node.id}": unknown blend "${props.blend}" \u2014 use ${[...BLEND_MODES].join(", ")}`);
407
+ 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
408
  if (node.type === "group") {
406
409
  const clip = node.props.clip;
407
410
  if (clip) {
@@ -652,6 +655,10 @@ function image(props) {
652
655
  const { id, ...rest } = props;
653
656
  return { type: "image", id, props: rest };
654
657
  }
658
+ function video(props) {
659
+ const { id, ...rest } = props;
660
+ return { type: "video", id, props: rest };
661
+ }
655
662
  function path(props) {
656
663
  const { id, ...rest } = props;
657
664
  return { type: "path", id, props: rest };
@@ -1072,6 +1079,7 @@ function photoMontage(images, opts = {}) {
1072
1079
  width: W,
1073
1080
  height: H,
1074
1081
  anchor: "center",
1082
+ fit: "cover",
1075
1083
  scale: kA,
1076
1084
  opacity: i === 0 ? 1 : 0
1077
1085
  })
@@ -3178,6 +3186,34 @@ function evaluate(compiled, t) {
3178
3186
  height,
3179
3187
  offsetX: -width * ax,
3180
3188
  offsetY: -height * ay,
3189
+ ...node.props.fit && node.props.fit !== "fill" ? { fit: node.props.fit } : {},
3190
+ ...fx,
3191
+ ...clipSpread
3192
+ });
3193
+ return;
3194
+ }
3195
+ case "video": {
3196
+ const width = num(id, "width", node.props.width);
3197
+ const height = num(id, "height", node.props.height);
3198
+ const [ax, ay] = ANCHOR_FACTORS[node.props.anchor ?? "top-left"];
3199
+ const fps = compiled.ir.fps ?? 30;
3200
+ const start = node.props.start ?? 0;
3201
+ const rate = node.props.rate ?? 1;
3202
+ const clipStart = node.props.clipStart ?? 0;
3203
+ const srcT = clipStart + Math.max(0, t - start) * rate;
3204
+ const frame = Math.max(0, Math.round(srcT * fps));
3205
+ ops.push({
3206
+ type: "video",
3207
+ id,
3208
+ transform: matrix,
3209
+ opacity,
3210
+ src: str(id, "src", node.props.src),
3211
+ width,
3212
+ height,
3213
+ offsetX: -width * ax,
3214
+ offsetY: -height * ay,
3215
+ frame,
3216
+ ...node.props.fit && node.props.fit !== "fill" ? { fit: node.props.fit } : {},
3181
3217
  ...fx,
3182
3218
  ...clipSpread
3183
3219
  });
@@ -3251,13 +3287,13 @@ function evaluate(compiled, t) {
3251
3287
  }
3252
3288
 
3253
3289
  // ../core/src/assets.ts
3254
- function collectImageSrcs(ir) {
3290
+ function collectSrcs(ir, type) {
3255
3291
  const srcs = /* @__PURE__ */ new Set();
3256
- const imageIds = /* @__PURE__ */ new Set();
3292
+ const ids = /* @__PURE__ */ new Set();
3257
3293
  const walkNodes = (nodes) => {
3258
3294
  for (const node of nodes) {
3259
- if (node.type === "image") {
3260
- imageIds.add(node.id);
3295
+ if (node.type === type) {
3296
+ ids.add(node.id);
3261
3297
  srcs.add(node.props.src);
3262
3298
  }
3263
3299
  if (node.type === "group") walkNodes(node.children);
@@ -3266,14 +3302,14 @@ function collectImageSrcs(ir) {
3266
3302
  walkNodes(ir.nodes);
3267
3303
  for (const overrides of Object.values(ir.states ?? {})) {
3268
3304
  for (const [nodeId, props] of Object.entries(overrides)) {
3269
- if (imageIds.has(nodeId) && typeof props.src === "string") srcs.add(props.src);
3305
+ if (ids.has(nodeId) && typeof props.src === "string") srcs.add(props.src);
3270
3306
  }
3271
3307
  }
3272
3308
  const walkTimeline = (step) => {
3273
3309
  if (!step) return;
3274
3310
  if (step.kind === "seq" || step.kind === "par" || step.kind === "stagger") {
3275
3311
  for (const child of step.children) walkTimeline(child);
3276
- } else if (step.kind === "tween" && imageIds.has(step.target)) {
3312
+ } else if (step.kind === "tween" && ids.has(step.target)) {
3277
3313
  const src = step.props.src;
3278
3314
  if (typeof src === "string") srcs.add(src);
3279
3315
  }
@@ -3281,6 +3317,12 @@ function collectImageSrcs(ir) {
3281
3317
  walkTimeline(ir.timeline);
3282
3318
  return [...srcs];
3283
3319
  }
3320
+ function collectImageSrcs(ir) {
3321
+ return collectSrcs(ir, "image");
3322
+ }
3323
+ function collectVideoSrcs(ir) {
3324
+ return collectSrcs(ir, "video");
3325
+ }
3284
3326
 
3285
3327
  // ../core/src/motion.ts
3286
3328
  var EASE_BY_CLASS = {
@@ -3347,6 +3389,7 @@ export {
3347
3389
  cameraTo,
3348
3390
  characterPreset,
3349
3391
  collectImageSrcs,
3392
+ collectVideoSrcs,
3350
3393
  compileComposition,
3351
3394
  compileScene,
3352
3395
  composeScene,
@@ -3413,6 +3456,7 @@ export {
3413
3456
  tween,
3414
3457
  validateComposition,
3415
3458
  validateScene,
3459
+ video,
3416
3460
  wait,
3417
3461
  wiggle
3418
3462
  };
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,8 @@ 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"],
344
+ video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart"],
343
345
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
344
346
  group: COMMON_PROPS
345
347
  };
@@ -386,6 +388,7 @@ function validateScene(ir) {
386
388
  if (typeof props.blur === "number" && props.blur < 0) problems.push(`node "${node.id}": blur must be >= 0`);
387
389
  if (typeof props.shadowBlur === "number" && props.shadowBlur < 0) problems.push(`node "${node.id}": shadowBlur must be >= 0`);
388
390
  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
+ 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
392
  if (node.type === "group") {
390
393
  const clip = node.props.clip;
391
394
  if (clip) {
@@ -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,22 +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
- ctx.drawImage(img, op.offsetX, op.offsetY, op.width, op.height);
123
- } else {
124
- ctx.fillStyle = "#2A2A30";
125
- ctx.fillRect(op.offsetX, op.offsetY, op.width, op.height);
126
- ctx.strokeStyle = "#FF00FF";
127
- ctx.lineWidth = 2;
128
- ctx.strokeRect(op.offsetX, op.offsetY, op.width, op.height);
129
- ctx.beginPath();
130
- ctx.moveTo(op.offsetX, op.offsetY);
131
- ctx.lineTo(op.offsetX + op.width, op.offsetY + op.height);
132
- ctx.moveTo(op.offsetX + op.width, op.offsetY);
133
- ctx.lineTo(op.offsetX, op.offsetY + op.height);
134
- ctx.stroke();
135
- }
120
+ drawRaster(ctx, images?.get(op.src), op);
121
+ break;
122
+ }
123
+ case "video": {
124
+ drawRaster(ctx, videos?.frame(op.src, op.frame), op);
136
125
  break;
137
126
  }
138
127
  case "path": {
@@ -176,6 +165,40 @@ function drawDisplayList(ctx, ops, images) {
176
165
  function mapBlend(blend) {
177
166
  return blend === "add" ? "lighter" : blend;
178
167
  }
168
+ function coverRect(iw, ih, dw, dh) {
169
+ if (iw <= 0 || ih <= 0 || dw <= 0 || dh <= 0) return { sx: 0, sy: 0, sw: iw, sh: ih };
170
+ const s = Math.max(dw / iw, dh / ih);
171
+ const sw = dw / s;
172
+ const sh = dh / s;
173
+ return { sx: (iw - sw) / 2, sy: (ih - sh) / 2, sw, sh };
174
+ }
175
+ function intrinsicSize(img) {
176
+ const a = img;
177
+ return [a.naturalWidth || a.width || 0, a.naturalHeight || a.height || 0];
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
+ }
179
202
  function quoteFamily(family) {
180
203
  return family.includes(" ") && !family.includes('"') ? `"${family}"` : family;
181
204
  }
@@ -193,6 +216,7 @@ function pathLength(d) {
193
216
  return len;
194
217
  }
195
218
  export {
219
+ coverRect,
196
220
  drawDisplayList,
197
221
  renderFrame
198
222
  };
package/dist/trace-cli.js CHANGED
@@ -13,7 +13,8 @@ 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
+ video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart"],
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[];
@@ -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;
@@ -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,20 @@ 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;
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;
81
95
  }) | (OpBase & {
82
96
  type: "path";
83
97
  /** SVG path data, drawn via Path2D. */
@@ -21,5 +21,5 @@ export { resolveAudioPlan, resolveCompositionAudioPlan, SFX_DURATION, type Audio
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";
@@ -182,6 +182,32 @@ 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;
192
+ }
193
+ /** Image box-fit mode. `cover` = crop-to-fill at the image's aspect (centered). */
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;
185
211
  }
186
212
  export type NodeIR = {
187
213
  type: "rect";
@@ -203,6 +229,10 @@ export type NodeIR = {
203
229
  type: "image";
204
230
  id: string;
205
231
  props: ImageProps;
232
+ } | {
233
+ type: "video";
234
+ id: string;
235
+ props: VideoProps;
206
236
  } | {
207
237
  type: "path";
208
238
  id: string;
@@ -9,10 +9,10 @@
9
9
  * a seeded PRNG (mulberry32) — same (images, opts) → identical IR; a different
10
10
  * `seed` re-frames within the same family. No Math.random / Date.
11
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.
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
16
  */
17
17
  import type { NodeIR, TimelineIR } from "./ir.js";
18
18
  export type KenBurns = "in" | "out" | "pan";
@@ -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
@@ -307,13 +309,35 @@ scene({ size, nodes: [...m.nodes, ...titles], timeline: par(m.timeline, titleTra
307
309
  the stacked image layers (+ `${id}-vignette` / `${id}-scrim` grade overlays);
308
310
  `timeline` is a retimable `beat("montage", …)`. Stable addresses: `${id}-${i}`,
309
311
  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.
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.
313
316
  - Per-slide overrides: `{ src, hold?, ken? }` where `ken` is `"in" | "out" | "pan"`.
314
317
  - Seeded + pure (same `(images, opts)` → identical IR). Note: image-node sources do
315
318
  not render in `reframe player` / artifacts — montage ships as mp4.
316
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 })
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). Transform/opacity/effects compose as usual.
334
+ - **Deterministic by frame extraction**: render-cli runs `ffmpeg -vf fps=<sceneFps>` to pull
335
+ the clip's frames, and the renderer draws frame `round(t·fps)` — no live `<video>` seek, so
336
+ 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`.
340
+
317
341
  ## Cursor (UI demos)
318
342
 
319
343
  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.4",
3
+ "version": "0.6.6",
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!),