@framefields/node-compositor 0.0.0-stage → 2.0.3

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.
Files changed (40) hide show
  1. package/.turbo/turbo-build.log +22 -0
  2. package/.turbo/turbo-test.log +21 -0
  3. package/CHANGELOG.md +34 -0
  4. package/LICENCE +202 -0
  5. package/SKILL.md +370 -0
  6. package/dist/index-B0ir9Z7o.d.mts +139 -0
  7. package/dist/index-B0ir9Z7o.d.mts.map +1 -0
  8. package/dist/index.d.mts +230 -0
  9. package/dist/index.d.mts.map +1 -0
  10. package/dist/index.mjs +674 -0
  11. package/dist/index.mjs.map +1 -0
  12. package/dist/renderer.d.mts +2 -0
  13. package/dist/renderer.mjs +3 -0
  14. package/dist/renderers-DoYYCzgA.mjs +3326 -0
  15. package/dist/renderers-DoYYCzgA.mjs.map +1 -0
  16. package/package.json +51 -3
  17. package/src/index.ts +2 -0
  18. package/src/renderers/audio-processor.ts +123 -0
  19. package/src/renderers/container-bg.test.ts +22 -0
  20. package/src/renderers/container-bg.ts +15 -0
  21. package/src/renderers/index.ts +9 -0
  22. package/src/renderers/transform.test.ts +120 -0
  23. package/src/renderers/transform.ts +101 -0
  24. package/src/renderers/transform3d.test.ts +290 -0
  25. package/src/renderers/transform3d.ts +428 -0
  26. package/src/renderers/webgpu-renderer.ts +4038 -0
  27. package/src/shared/audio-reactive.test.ts +224 -0
  28. package/src/shared/bezier-solver.test.ts +92 -0
  29. package/src/shared/bezier-solver.ts +257 -0
  30. package/src/shared/compiler.test.ts +1309 -0
  31. package/src/shared/compiler.ts +996 -0
  32. package/src/shared/config.ts +44 -0
  33. package/src/shared/index.ts +3 -0
  34. package/src/shared/kinetic-typography.test.ts +138 -0
  35. package/src/shared/presets.test.ts +217 -0
  36. package/src/shared/presets.ts +644 -0
  37. package/src/shared/processor.ts +105 -0
  38. package/tsconfig.json +13 -0
  39. package/tsdown.config.ts +13 -0
  40. package/README.md +0 -3
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@framefields/node-compositor",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "2.0.3",
4
+ "private": false,
5
+ "framefields": {
6
+ "type": "Compositor",
7
+ "kind": "utility"
8
+ },
9
+ "type": "module",
10
+ "license": "Apache-2.0",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.mts",
14
+ "development": "./src/index.ts",
15
+ "import": "./dist/index.mjs",
16
+ "default": "./dist/index.mjs"
17
+ },
18
+ "./renderer": {
19
+ "types": "./dist/renderer.d.mts",
20
+ "development": "./src/renderers/index.ts",
21
+ "import": "./dist/renderer.mjs",
22
+ "default": "./dist/renderer.mjs"
23
+ }
24
+ },
25
+ "dependencies": {
26
+ "gsap": "^3.15.0",
27
+ "zod": "^4.3.6",
28
+ "@framefields/core": "2.0.3",
29
+ "@framefields/node-sdk": "2.0.3",
30
+ "@framefields/client-utils": "2.0.3",
31
+ "@framefields/tensor-webgpu": "2.0.3",
32
+ "@framefields/renderers": "2.0.3",
33
+ "@framefields/webgpu-renderers": "2.0.3",
34
+ "@framefields/compositions": "2.0.3"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^24.1.0",
38
+ "tsdown": "^0.19.0",
39
+ "typescript": "^5.8.3",
40
+ "vitest": "^4.1.5",
41
+ "webgpu": "^0.4.0",
42
+ "@framefields/tsconfig": "2.0.3"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "scripts": {
48
+ "build": "tsdown",
49
+ "dev": "tsdown --watch",
50
+ "clean": "rm -rf dist .turbo",
51
+ "test": "vitest run --passWithNoTests",
52
+ "typecheck": "tsc --noEmit"
53
+ }
6
54
  }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./shared/index.js";
2
+ export * from "./renderers/index.js";
@@ -0,0 +1,123 @@
1
+ import type { LayerAnimation } from "@framefields/compositions/program";
2
+ import type { VirtualMediaData } from "@framefields/core";
3
+ import type { AudioProcessor } from "@framefields/node-sdk";
4
+ import { audioRegistry } from "@framefields/node-sdk";
5
+ import { compileLayerTimeline } from "../shared/compiler.js";
6
+
7
+ interface CompositorOperation {
8
+ op: "Compositor" | "CompositorLayer";
9
+ id?: string;
10
+ inputHandleId?: string;
11
+ volume?: number;
12
+ muted?: boolean;
13
+ animation?: LayerAnimation;
14
+ }
15
+
16
+ export const compositorLayerAudioProcessor: AudioProcessor = async (
17
+ channels: Float32Array[],
18
+ sampleRate: number,
19
+ virtualMedia: VirtualMediaData,
20
+ ctx,
21
+ ) => {
22
+ const op = virtualMedia.operation as unknown as CompositorOperation;
23
+ if (!op) return;
24
+
25
+ const numChannels = channels.length;
26
+ if (numChannels === 0) return;
27
+ const numSamples = channels[0].length;
28
+ if (numSamples === 0) return;
29
+
30
+ // Master gain (L1): the audio extractor dispatches this processor for the
31
+ // root Compositor op too (registered under the node type "Compositor").
32
+ // The root's channels ARE the fully-mixed composition, so scale them
33
+ // once by the program-level `volume` — the per-layer path below handles
34
+ // each CompositorLayer op's own volume/mute animation.
35
+ if (op.op === "Compositor") {
36
+ const master = typeof op.volume === "number" ? op.volume : 1;
37
+ if (master === 1) return;
38
+ for (let c = 0; c < numChannels; c++) {
39
+ const ch = channels[c];
40
+ for (let i = 0; i < ch.length; i++) ch[i] *= master;
41
+ }
42
+ return;
43
+ }
44
+
45
+ const volume = typeof op.volume === "number" ? op.volume : 1;
46
+ const muted = !!op.muted;
47
+ const animation = op.animation;
48
+ const fps = ctx?.fps ?? 24;
49
+
50
+ // Check if there are active volume or muted animation tracks with keyframes
51
+ const tracks = animation?.tracks || [];
52
+ const hasVolumeKeys = tracks.some(
53
+ (t) => t.prop === "volume" && t.keyframes && t.keyframes.length > 0,
54
+ );
55
+ const hasMutedKeys = tracks.some(
56
+ (t) => t.prop === "muted" && t.keyframes && t.keyframes.length > 0,
57
+ );
58
+
59
+ if (!hasVolumeKeys && !hasMutedKeys) {
60
+ // If no animated volume/muted tracks exist, the static volume and mute state
61
+ // have already been applied by the child clip or audio extractor.
62
+ return;
63
+ }
64
+
65
+ // Compile or retrieve the GSAP timeline for this layer
66
+ const { tl, stub } = compileLayerTimeline(
67
+ op.id || op.inputHandleId || "layer",
68
+ animation,
69
+ volume,
70
+ muted,
71
+ fps,
72
+ );
73
+
74
+ const elapsedMs = ctx?.elapsedMs ?? 0;
75
+ const startTimeSec = elapsedMs / 1000;
76
+
77
+ // Process in sub-chunks of 128 samples to be fast and smooth
78
+ const subChunkSize = 128;
79
+
80
+ // Seek to the starting time to establish baseline values
81
+ tl.seek(startTimeSec);
82
+ let lastVol = stub.volume;
83
+ let lastMuted = stub.muted;
84
+
85
+ const baseGain = muted ? 0 : volume;
86
+
87
+ for (let i = 0; i < numSamples; i += subChunkSize) {
88
+ const nextI = Math.min(numSamples, i + subChunkSize);
89
+ const tNext = startTimeSec + (nextI - i) / sampleRate;
90
+ tl.seek(tNext);
91
+ const nextVol = stub.volume;
92
+ const nextMuted = stub.muted;
93
+
94
+ for (let j = i; j < nextI; j++) {
95
+ const p = (j - i) / (nextI - i);
96
+ // L3: clamp — keyframes are schema-bounded to 0–1, but a lerp
97
+ // between in-range values can overshoot with custom eases; the
98
+ // relativeGain compensation below must never amplify past the
99
+ // channel's own scale.
100
+ const currentVol = Math.min(
101
+ 1,
102
+ Math.max(0, lastVol + (nextVol - lastVol) * p),
103
+ );
104
+ // For muted, if it changes mid-sub-chunk, toggle it at the halfway point
105
+ const currentMuted = j - i < (nextI - i) / 2 ? lastMuted : nextMuted;
106
+
107
+ const finalGain = currentMuted ? 0 : currentVol;
108
+ const relativeGain = baseGain === 0 ? 0 : finalGain / baseGain;
109
+
110
+ if (relativeGain !== 1) {
111
+ for (let c = 0; c < numChannels; c++) {
112
+ channels[c][j] *= relativeGain;
113
+ }
114
+ }
115
+ }
116
+
117
+ lastVol = nextVol;
118
+ lastMuted = nextMuted;
119
+ }
120
+ };
121
+
122
+ // Register immediately on load
123
+ audioRegistry.register("CompositorLayer", compositorLayerAudioProcessor);
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldPaintContainerBackground } from "./container-bg.js";
3
+
4
+ describe("shouldPaintContainerBackground", () => {
5
+ it("paints every container kind when a background is set", () => {
6
+ for (const kind of ["flex", "block", "box"]) {
7
+ expect(shouldPaintContainerBackground(kind, "#3a2f1e")).toBe(true);
8
+ }
9
+ });
10
+
11
+ it("never paints when there is no background", () => {
12
+ expect(shouldPaintContainerBackground("flex", undefined)).toBe(false);
13
+ expect(shouldPaintContainerBackground("box", null)).toBe(false);
14
+ expect(shouldPaintContainerBackground("box", "")).toBe(false);
15
+ });
16
+
17
+ it("does not paint non-container kinds", () => {
18
+ for (const kind of ["text", "media", undefined]) {
19
+ expect(shouldPaintContainerBackground(kind, "#ffffff")).toBe(false);
20
+ }
21
+ });
22
+ });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Which container kinds paint their own background.
3
+ *
4
+ * flex/block/box all accept an optional `background` in the program schema.
5
+ * The renderer must draw the rounded background pass for ALL of them (the old
6
+ * code only painted `box`, so flex/block backgrounds were silently dropped and
7
+ * their "transparent-optimized" fast path skipped the fill entirely).
8
+ */
9
+ export function shouldPaintContainerBackground(
10
+ kind: string | undefined,
11
+ background?: string | null,
12
+ ): boolean {
13
+ if (!background) return false;
14
+ return kind === "flex" || kind === "block" || kind === "box";
15
+ }
@@ -0,0 +1,9 @@
1
+ import { defineRenderer } from "@framefields/node-sdk/renderer";
2
+ import { CompositorWebGPURenderer } from "./webgpu-renderer.js";
3
+ import "./audio-processor.js";
4
+
5
+ export default defineRenderer({
6
+ WebGPURenderer: CompositorWebGPURenderer,
7
+ });
8
+
9
+ export * from "./transform3d.js";
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildLayerMatrix,
4
+ type Matrix2D,
5
+ multiply,
6
+ rotate,
7
+ scale,
8
+ transformPoint,
9
+ translate,
10
+ } from "./transform.js";
11
+
12
+ const closeTo = (actual: number, expected: number, eps = 1e-9) =>
13
+ expect(Math.abs(actual - expected)).toBeLessThan(eps);
14
+
15
+ const expectPoint = (
16
+ a: { x: number; y: number },
17
+ e: { x: number; y: number },
18
+ ) => {
19
+ closeTo(a.x, e.x);
20
+ closeTo(a.y, e.y);
21
+ };
22
+
23
+ describe("transform (pure affine)", () => {
24
+ it("composes translations in apply-order (m1 ∘ m2)", () => {
25
+ const m = multiply(translate(3, 4), translate(5, 6));
26
+ expectPoint(transformPoint(m, 0, 0), { x: 8, y: 10 });
27
+ });
28
+
29
+ it("rotate is clockwise in screen space (y-down)", () => {
30
+ expectPoint(transformPoint(rotate(90), 1, 0), { x: 0, y: 1 });
31
+ expectPoint(transformPoint(rotate(90), 0, 1), { x: -1, y: 0 });
32
+ });
33
+
34
+ it("scale multiplies and can mirror through negative x", () => {
35
+ expectPoint(transformPoint(scale(2), 3, 4), { x: 6, y: 8 });
36
+ });
37
+ });
38
+
39
+ describe("buildLayerMatrix — anchor-aware pivot (review H2)", () => {
40
+ const box = { x: 10, y: 20, width: 200, height: 100 };
41
+
42
+ it("is a plain placement when scale=1 and rotation=0 (any anchor)", () => {
43
+ for (const anchorX of [0, 0.5, 1]) {
44
+ for (const anchorY of [0, 0.5, 1]) {
45
+ const m = buildLayerMatrix({
46
+ ...box,
47
+ rotation: 0,
48
+ scale: 1,
49
+ anchorX,
50
+ anchorY,
51
+ });
52
+ expectPoint(transformPoint(m, 0, 0), { x: 10, y: 20 });
53
+ expectPoint(transformPoint(m, 200, 100), { x: 210, y: 120 });
54
+ }
55
+ }
56
+ });
57
+
58
+ it("keeps the default center anchor (0.5/0.5) invariant under scale", () => {
59
+ const m = buildLayerMatrix({ ...box, rotation: 0, scale: 2 });
60
+ // center of the box
61
+ expectPoint(transformPoint(m, 100, 50), { x: 110, y: 70 });
62
+ // top-left scales AWAY from the center (the H2 fix)
63
+ expectPoint(transformPoint(m, 0, 0), { x: -90, y: -30 });
64
+ // top-right scales away symmetrically: x + w·s − ax·w = 10 + 400 − 100
65
+ expectPoint(transformPoint(m, 200, 0), { x: 310, y: -30 });
66
+ });
67
+
68
+ it("keeps the center anchor invariant under rotation", () => {
69
+ const m = buildLayerMatrix({ ...box, rotation: 45, scale: 1 });
70
+ expectPoint(transformPoint(m, 100, 50), { x: 110, y: 70 });
71
+ });
72
+
73
+ it("keeps ANY anchor point invariant under the full transform (scale+rotation)", () => {
74
+ for (const anchorX of [0, 0.25, 0.5, 0.75, 1]) {
75
+ for (const anchorY of [0, 0.25, 0.5, 0.75, 1]) {
76
+ const anchorXAbs = anchorX * box.width;
77
+ const anchorYAbs = anchorY * box.height;
78
+ const m = buildLayerMatrix({
79
+ ...box,
80
+ rotation: 33,
81
+ scale: 1.7,
82
+ anchorX,
83
+ anchorY,
84
+ });
85
+ expectPoint(transformPoint(m, anchorXAbs, anchorYAbs), {
86
+ x: box.x + anchorXAbs,
87
+ y: box.y + anchorYAbs,
88
+ });
89
+ }
90
+ }
91
+ });
92
+
93
+ it("anchor (0,0) reproduces the legacy top-left pivot exactly", () => {
94
+ const m = buildLayerMatrix({
95
+ ...box,
96
+ rotation: 90,
97
+ scale: 2,
98
+ anchorX: 0,
99
+ anchorY: 0,
100
+ });
101
+ // top-left corner stays put; the rest scales/rotates around it
102
+ expectPoint(transformPoint(m, 0, 0), { x: 10, y: 20 });
103
+ // right-middle: (200,50) → rotate 90° clockwise around (0,0): (x,y)→(-50·? …)
104
+ // y' = x = 200 → scaled 2 → (0 - 50·2, 20 + 200·2) = (-100, 420)
105
+ expectPoint(transformPoint(m, 200, 50), {
106
+ x: 10 - 50 * 2,
107
+ y: 20 + 200 * 2,
108
+ });
109
+ });
110
+
111
+ it("does not corrupt the matrix type contract used by the renderer (DOMMatrix mapping)", () => {
112
+ const m: Matrix2D = buildLayerMatrix({ ...box, rotation: 0, scale: 1 });
113
+ // DOMMatrix([a,b,c,d,e,f]) maps (x,y) → (a·x + c·y + e, b·x + d·y + f)
114
+ const mapped = {
115
+ x: m.a * 100 + m.c * 50 + m.e,
116
+ y: m.b * 100 + m.d * 50 + m.f,
117
+ };
118
+ expectPoint(mapped, { x: 110, y: 70 });
119
+ });
120
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Minimal pure 2D affine matrix (same 6-number layout as DOMMatrix:
3
+ * [a, b, c, d, e, f] with x' = a·x + c·y + e, y' = b·x + d·y + f).
4
+ *
5
+ * DOM-free on purpose — WebGPU node renderers build DOMMatrix chains before
6
+ * pushing transforms, but DOMMatrix is not available in Node (unit tests /
7
+ * headless layout), so the anchor math that decides WHERE a layer pivots is
8
+ * implemented here as pure functions and only materialized into a DOMMatrix
9
+ * at the render boundary via `toDOMMatrix`.
10
+ */
11
+ export interface Matrix2D {
12
+ a: number;
13
+ b: number;
14
+ c: number;
15
+ d: number;
16
+ e: number;
17
+ f: number;
18
+ }
19
+
20
+ export const IDENTITY: Matrix2D = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
21
+
22
+ export function translate(tx: number, ty: number): Matrix2D {
23
+ return { a: 1, b: 0, c: 0, d: 1, e: tx, f: ty };
24
+ }
25
+
26
+ /** Rotation in degrees (screen convention: positive = clockwise, y-down). */
27
+ export function rotate(degrees: number): Matrix2D {
28
+ const rad = (degrees * Math.PI) / 180;
29
+ const cos = Math.cos(rad);
30
+ const sin = Math.sin(rad);
31
+ return { a: cos, b: sin, c: -sin, d: cos, e: 0, f: 0 };
32
+ }
33
+
34
+ export function scale(sx: number, sy: number = sx): Matrix2D {
35
+ return { a: sx, b: 0, c: 0, d: sy, e: 0, f: 0 };
36
+ }
37
+
38
+ /** m1 ∘ m2 (m2 applied first — matches DOMMatrix.multiply ordering). */
39
+ export function multiply(m1: Matrix2D, m2: Matrix2D): Matrix2D {
40
+ return {
41
+ a: m1.a * m2.a + m1.c * m2.b,
42
+ b: m1.b * m2.a + m1.d * m2.b,
43
+ c: m1.a * m2.c + m1.c * m2.d,
44
+ d: m1.b * m2.c + m1.d * m2.d,
45
+ e: m1.a * m2.e + m1.c * m2.f + m1.e,
46
+ f: m1.b * m2.e + m1.d * m2.f + m1.f,
47
+ };
48
+ }
49
+
50
+ export function transformPoint(
51
+ m: Matrix2D,
52
+ x: number,
53
+ y: number,
54
+ ): { x: number; y: number } {
55
+ return { x: m.a * x + m.c * y + m.e, y: m.b * x + m.d * y + m.f };
56
+ }
57
+
58
+ /** Materialize into a DOMMatrix for the renderer's transform stack. */
59
+ export function toDOMMatrix(m: Matrix2D): DOMMatrix {
60
+ return new DOMMatrix([m.a, m.b, m.c, m.d, m.e, m.f]);
61
+ }
62
+
63
+ /**
64
+ * Layer transform matrix with anchor-aware pivot semantics.
65
+ *
66
+ * The composition node box is drawn at (0, 0, width, height) in local space
67
+ * and positioned at `x`/`y`. `anchorX`/`anchorY` (0–1 fractions of the box)
68
+ * select the pivot point that scale/rotation are applied AROUND:
69
+ *
70
+ * position → pivot → rotate → scale → un-pivot
71
+ *
72
+ * With the default center anchor (0.5, 0.5) a `scale: 2` animation grows the
73
+ * node symmetrically from its middle instead of from its top-left corner.
74
+ * The pivot point itself is invariant under rotation and scale.
75
+ */
76
+ export function buildLayerMatrix(params: {
77
+ x: number;
78
+ y: number;
79
+ width: number;
80
+ height: number;
81
+ rotation: number;
82
+ scale: number;
83
+ anchorX?: number;
84
+ anchorY?: number;
85
+ }): Matrix2D {
86
+ const ax = params.anchorX ?? 0.5;
87
+ const ay = params.anchorY ?? 0.5;
88
+ const px = ax * params.width;
89
+ const py = ay * params.height;
90
+
91
+ return multiply(
92
+ translate(params.x, params.y),
93
+ multiply(
94
+ translate(px, py),
95
+ multiply(
96
+ rotate(params.rotation),
97
+ multiply(scale(params.scale), translate(-px, -py)),
98
+ ),
99
+ ),
100
+ );
101
+ }