reframe-video 0.6.8 → 0.6.9

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
@@ -391,6 +391,14 @@ function validateScene(ir) {
391
391
  problems.push(`group "${node.id}" clip: width and height must be > 0`);
392
392
  }
393
393
  }
394
+ const matte = node.props.matte;
395
+ if (matte !== void 0) {
396
+ if (matte !== "alpha" && matte !== "luma") {
397
+ problems.push(`group "${node.id}" matte: unknown mode "${String(matte)}" \u2014 use "alpha" or "luma"`);
398
+ } else if (node.children.length < 2) {
399
+ problems.push(`group "${node.id}" matte: needs \u22652 children (first masks the rest)`);
400
+ }
401
+ }
394
402
  collect(node.children);
395
403
  }
396
404
  }
@@ -737,6 +737,14 @@
737
737
  switch (node.type) {
738
738
  case "group": {
739
739
  const childClips = node.props.clip ? [...clips, { transform: matrix, shape: node.props.clip }] : clips;
740
+ if (node.props.matte && node.children.length >= 2) {
741
+ ops.push({ type: "matte-push", id, transform: matrix, opacity, mode: node.props.matte, ...clipSpread });
742
+ walk(node.children[0], matrix, opacity, childClips);
743
+ ops.push({ type: "matte-sep", id, transform: matrix, opacity });
744
+ for (let i = 1; i < node.children.length; i++) walk(node.children[i], matrix, opacity, childClips);
745
+ ops.push({ type: "matte-pop", id, transform: matrix, opacity });
746
+ return;
747
+ }
740
748
  for (const child of node.children) walk(child, matrix, opacity, childClips);
741
749
  return;
742
750
  }
@@ -918,7 +926,70 @@
918
926
  drawDisplayList(ctx2, evaluate(compiled2, t), images2, videos2);
919
927
  }
920
928
  function drawDisplayList(ctx2, ops, images2, videos2) {
929
+ const stack = [];
930
+ const target = () => {
931
+ const f = stack[stack.length - 1];
932
+ if (!f) return ctx2;
933
+ return f.phase === "content" && f.contentCtx ? f.contentCtx : f.matteCtx;
934
+ };
935
+ const newCtx = () => {
936
+ const c = document.createElement("canvas");
937
+ c.width = ctx2.canvas.width;
938
+ c.height = ctx2.canvas.height;
939
+ return c.getContext("2d");
940
+ };
941
+ const composite = (f) => {
942
+ if (!f.contentCtx) return;
943
+ if (f.mode === "luma") lumaToAlpha(f.matteCtx);
944
+ f.contentCtx.save();
945
+ f.contentCtx.setTransform(1, 0, 0, 1, 0, 0);
946
+ f.contentCtx.globalCompositeOperation = "destination-in";
947
+ f.contentCtx.drawImage(f.matteCtx.canvas, 0, 0);
948
+ f.contentCtx.restore();
949
+ f.parent.save();
950
+ f.parent.setTransform(1, 0, 0, 1, 0, 0);
951
+ f.parent.globalAlpha = 1;
952
+ f.parent.globalCompositeOperation = "source-over";
953
+ f.parent.filter = "none";
954
+ f.parent.drawImage(f.contentCtx.canvas, 0, 0);
955
+ f.parent.restore();
956
+ };
921
957
  for (const op of ops) {
958
+ if (op.type === "matte-push") {
959
+ stack.push({ mode: op.mode, parent: target(), matteCtx: newCtx(), phase: "matte" });
960
+ continue;
961
+ }
962
+ if (op.type === "matte-sep") {
963
+ const f = stack[stack.length - 1];
964
+ if (f) {
965
+ f.contentCtx = newCtx();
966
+ f.phase = "content";
967
+ }
968
+ continue;
969
+ }
970
+ if (op.type === "matte-pop") {
971
+ const f = stack.pop();
972
+ if (f) composite(f);
973
+ continue;
974
+ }
975
+ drawOp(target(), op, images2, videos2);
976
+ }
977
+ while (stack.length) composite(stack.pop());
978
+ }
979
+ function lumaToAlpha(ctx2) {
980
+ const { width, height } = ctx2.canvas;
981
+ if (width === 0 || height === 0) return;
982
+ const img = ctx2.getImageData(0, 0, width, height);
983
+ const d = img.data;
984
+ for (let i = 0; i < d.length; i += 4) {
985
+ const a = d[i + 3] / 255;
986
+ const luma = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2];
987
+ d[i + 3] = Math.round(luma * a);
988
+ }
989
+ ctx2.putImageData(img, 0, 0);
990
+ }
991
+ function drawOp(ctx2, op, images2, videos2) {
992
+ {
922
993
  ctx2.save();
923
994
  if (op.clips) {
924
995
  for (const clip of op.clips) {
package/dist/cli.js CHANGED
@@ -405,6 +405,14 @@ function validateScene(ir) {
405
405
  problems.push(`group "${node.id}" clip: width and height must be > 0`);
406
406
  }
407
407
  }
408
+ const matte = node.props.matte;
409
+ if (matte !== void 0) {
410
+ if (matte !== "alpha" && matte !== "luma") {
411
+ problems.push(`group "${node.id}" matte: unknown mode "${String(matte)}" \u2014 use "alpha" or "luma"`);
412
+ } else if (node.children.length < 2) {
413
+ problems.push(`group "${node.id}" matte: needs \u22652 children (first masks the rest)`);
414
+ }
415
+ }
408
416
  collect(node.children);
409
417
  }
410
418
  }
package/dist/index.js CHANGED
@@ -415,6 +415,14 @@ function validateScene(ir) {
415
415
  problems.push(`group "${node.id}" clip: width and height must be > 0`);
416
416
  }
417
417
  }
418
+ const matte = node.props.matte;
419
+ if (matte !== void 0) {
420
+ if (matte !== "alpha" && matte !== "luma") {
421
+ problems.push(`group "${node.id}" matte: unknown mode "${String(matte)}" \u2014 use "alpha" or "luma"`);
422
+ } else if (node.children.length < 2) {
423
+ problems.push(`group "${node.id}" matte: needs \u22652 children (first masks the rest)`);
424
+ }
425
+ }
418
426
  collect(node.children);
419
427
  }
420
428
  }
@@ -3169,6 +3177,14 @@ function evaluate(compiled, t) {
3169
3177
  switch (node.type) {
3170
3178
  case "group": {
3171
3179
  const childClips = node.props.clip ? [...clips, { transform: matrix, shape: node.props.clip }] : clips;
3180
+ if (node.props.matte && node.children.length >= 2) {
3181
+ ops.push({ type: "matte-push", id, transform: matrix, opacity, mode: node.props.matte, ...clipSpread });
3182
+ walk(node.children[0], matrix, opacity, childClips);
3183
+ ops.push({ type: "matte-sep", id, transform: matrix, opacity });
3184
+ for (let i = 1; i < node.children.length; i++) walk(node.children[i], matrix, opacity, childClips);
3185
+ ops.push({ type: "matte-pop", id, transform: matrix, opacity });
3186
+ return;
3187
+ }
3172
3188
  for (const child of node.children) walk(child, matrix, opacity, childClips);
3173
3189
  return;
3174
3190
  }
package/dist/labels.js CHANGED
@@ -399,6 +399,14 @@ function validateScene(ir) {
399
399
  problems.push(`group "${node.id}" clip: width and height must be > 0`);
400
400
  }
401
401
  }
402
+ const matte = node.props.matte;
403
+ if (matte !== void 0) {
404
+ if (matte !== "alpha" && matte !== "luma") {
405
+ problems.push(`group "${node.id}" matte: unknown mode "${String(matte)}" \u2014 use "alpha" or "luma"`);
406
+ } else if (node.children.length < 2) {
407
+ problems.push(`group "${node.id}" matte: needs \u22652 children (first masks the rest)`);
408
+ }
409
+ }
402
410
  collect(node.children);
403
411
  }
404
412
  }
@@ -36,7 +36,70 @@ function renderFrame(ctx, compiled, t, images, videos) {
36
36
  drawDisplayList(ctx, evaluate(compiled, t), images, videos);
37
37
  }
38
38
  function drawDisplayList(ctx, ops, images, videos) {
39
+ const stack = [];
40
+ const target = () => {
41
+ const f = stack[stack.length - 1];
42
+ if (!f) return ctx;
43
+ return f.phase === "content" && f.contentCtx ? f.contentCtx : f.matteCtx;
44
+ };
45
+ const newCtx = () => {
46
+ const c = document.createElement("canvas");
47
+ c.width = ctx.canvas.width;
48
+ c.height = ctx.canvas.height;
49
+ return c.getContext("2d");
50
+ };
51
+ const composite = (f) => {
52
+ if (!f.contentCtx) return;
53
+ if (f.mode === "luma") lumaToAlpha(f.matteCtx);
54
+ f.contentCtx.save();
55
+ f.contentCtx.setTransform(1, 0, 0, 1, 0, 0);
56
+ f.contentCtx.globalCompositeOperation = "destination-in";
57
+ f.contentCtx.drawImage(f.matteCtx.canvas, 0, 0);
58
+ f.contentCtx.restore();
59
+ f.parent.save();
60
+ f.parent.setTransform(1, 0, 0, 1, 0, 0);
61
+ f.parent.globalAlpha = 1;
62
+ f.parent.globalCompositeOperation = "source-over";
63
+ f.parent.filter = "none";
64
+ f.parent.drawImage(f.contentCtx.canvas, 0, 0);
65
+ f.parent.restore();
66
+ };
39
67
  for (const op of ops) {
68
+ if (op.type === "matte-push") {
69
+ stack.push({ mode: op.mode, parent: target(), matteCtx: newCtx(), phase: "matte" });
70
+ continue;
71
+ }
72
+ if (op.type === "matte-sep") {
73
+ const f = stack[stack.length - 1];
74
+ if (f) {
75
+ f.contentCtx = newCtx();
76
+ f.phase = "content";
77
+ }
78
+ continue;
79
+ }
80
+ if (op.type === "matte-pop") {
81
+ const f = stack.pop();
82
+ if (f) composite(f);
83
+ continue;
84
+ }
85
+ drawOp(target(), op, images, videos);
86
+ }
87
+ while (stack.length) composite(stack.pop());
88
+ }
89
+ function lumaToAlpha(ctx) {
90
+ const { width, height } = ctx.canvas;
91
+ if (width === 0 || height === 0) return;
92
+ const img = ctx.getImageData(0, 0, width, height);
93
+ const d = img.data;
94
+ for (let i = 0; i < d.length; i += 4) {
95
+ const a = d[i + 3] / 255;
96
+ const luma = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2];
97
+ d[i + 3] = Math.round(luma * a);
98
+ }
99
+ ctx.putImageData(img, 0, 0);
100
+ }
101
+ function drawOp(ctx, op, images, videos) {
102
+ {
40
103
  ctx.save();
41
104
  if (op.clips) {
42
105
  for (const clip of op.clips) {
@@ -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, ImageFit, Paint, PropValue } from "./ir.js";
7
+ import type { BlendMode, ClipShape, ImageFit, MatteMode, 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,
@@ -103,6 +103,13 @@ export type DisplayOp = (OpBase & {
103
103
  strokeWidth?: number;
104
104
  /** Local-space bbox [x,y,w,h] for mapping a gradient paint (set only when one is used). */
105
105
  bbox?: [number, number, number, number];
106
+ }) | (OpBase & {
107
+ type: "matte-push";
108
+ mode: MatteMode;
109
+ }) | (OpBase & {
110
+ type: "matte-sep";
111
+ }) | (OpBase & {
112
+ type: "matte-pop";
106
113
  });
107
114
  export type DisplayList = DisplayOp[];
108
115
  /**
@@ -151,7 +151,15 @@ export type ClipShape = {
151
151
  export interface GroupProps extends BaseProps {
152
152
  /** Clip the group's children to this shape (group-local coords). */
153
153
  clip?: ClipShape;
154
+ /**
155
+ * Track matte: the group's FIRST child masks the rest. `"alpha"` masks by the
156
+ * matte's alpha (e.g. video-filled text), `"luma"` by its luminance (e.g. a
157
+ * gradient wipe). Needs ≥2 children; the renderer composites it offscreen.
158
+ */
159
+ matte?: MatteMode;
154
160
  }
161
+ /** Track-matte mode: mask the content by the matte's `alpha` or `luma`. */
162
+ export type MatteMode = "alpha" | "luma";
155
163
  export interface PathProps extends BaseProps {
156
164
  /** SVG path data (the `d` attribute). Drawn as a true vector — crisp at any zoom. */
157
165
  d: string;
@@ -348,6 +348,28 @@ tween("clip", { scale: 1.08 }, { duration: 5 }) // transform composes with play
348
348
  sources are not rendered in `reframe player` / artifacts (mp4 only). See
349
349
  `examples/scenes/video-demo.ts`.
350
350
 
351
+ ## Track mattes (`group({ matte })`)
352
+
353
+ Use one layer's alpha or luminance to mask another — video-filled text, shape /
354
+ PNG punch-through, luma wipes. In a **matte group the FIRST child is the matte**;
355
+ the remaining children are the masked content.
356
+
357
+ ```ts
358
+ // the clip shows ONLY inside the letters (alpha matte)
359
+ group({ id: "reveal", x: W/2, y: H/2, anchor: "center", matte: "alpha" }, [
360
+ text({ id: "mask", x: 0, y: 0, anchor: "center", content: "PLAY", fontSize: 300, fontWeight: 800, fill: "#fff" }),
361
+ video({ id: "clip", x: 0, y: 0, width: W, height: H, anchor: "center", fit: "cover", src: "shot.mp4" }),
362
+ ])
363
+ ```
364
+
365
+ - `matte: "alpha"` keeps content where the matte is opaque; `"luma"` where it's bright
366
+ (animate a gradient/shape as the matte for a wipe). Needs ≥2 children.
367
+ - The group's transform / opacity / clip apply as usual (a centered group scales about
368
+ its middle; fading the group fades the masked result). Mattes can nest.
369
+ - Rendered by **offscreen compositing** (the matte + content draw to separate buffers,
370
+ combined via `destination-in`). Deterministic same-machine. See
371
+ `examples/scenes/matte-demo.ts`.
372
+
351
373
  ## Cursor (UI demos)
352
374
 
353
375
  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.8",
3
+ "version": "0.6.9",
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",
@@ -573,6 +573,10 @@ function opCorners(op: DisplayOp): [number, number][] {
573
573
  case "path":
574
574
  // No cheap bbox for an arbitrary `d`; mark the origin for selection.
575
575
  return [applyMat(op.transform, 0, 0)];
576
+ case "matte-push":
577
+ case "matte-sep":
578
+ case "matte-pop":
579
+ return []; // boundary markers have no geometry
576
580
  }
577
581
  }
578
582