@effect-motion/renderer 0.5.0

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/node.js ADDED
@@ -0,0 +1,206 @@
1
+ import { deflateSync } from "node:zlib";
2
+ import { Renderer as Gpu, PostProcessing, RenderTarget, Scene as ThreeScene, } from "@effect-motion/three";
3
+ // side effects first-class: installs navigator.gpu (Dawn), WebGPU globals,
4
+ // and the rAF/`self` shims three needs — before any renderer is created
5
+ import * as NodeGpu from "@effect-motion/three/node";
6
+ import { Effect, Scope } from "effect";
7
+ import { builtinRegistry } from "./Builtins.js";
8
+ import { renderCompTargets } from "./Renderer.js";
9
+ import * as Sync from "./Sync.js";
10
+ // ── minimal RGBA → PNG encoder (filter 0 + zlib), lifted from the ThorVG
11
+ // package's node PNG path; node:zlib does the compression ────────────────
12
+ const SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
13
+ const crcTable = (() => {
14
+ const t = new Uint32Array(256);
15
+ for (let n = 0; n < 256; n++) {
16
+ let c = n;
17
+ for (let k = 0; k < 8; k++) {
18
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
19
+ }
20
+ t[n] = c >>> 0;
21
+ }
22
+ return t;
23
+ })();
24
+ const crc32 = (bytes) => {
25
+ let c = 0xffffffff;
26
+ for (let i = 0; i < bytes.length; i++) {
27
+ c = (crcTable[(c ^ (bytes[i] ?? 0)) & 0xff] ?? 0) ^ (c >>> 8);
28
+ }
29
+ return (c ^ 0xffffffff) >>> 0;
30
+ };
31
+ const chunk = (type, data) => {
32
+ const typeBytes = Uint8Array.from(type, (ch) => ch.charCodeAt(0));
33
+ const body = new Uint8Array(typeBytes.length + data.length);
34
+ body.set(typeBytes, 0);
35
+ body.set(data, typeBytes.length);
36
+ const out = new Uint8Array(4 + body.length + 4);
37
+ const view = new DataView(out.buffer);
38
+ view.setUint32(0, data.length, false);
39
+ out.set(body, 4);
40
+ view.setUint32(4 + body.length, crc32(body), false);
41
+ return out;
42
+ };
43
+ /**
44
+ * Encode a raw RGBA buffer as a PNG.
45
+ *
46
+ * @remarks
47
+ * {@link renderToPng} already does this, so reach for it directly only when
48
+ * you have pixels from somewhere else. The encoder is deliberately minimal —
49
+ * no filtering, just zlib — which keeps it fast at the cost of somewhat
50
+ * larger files than an optimizing encoder would produce.
51
+ *
52
+ * @param rgba - Exactly `width * height * 4` bytes, 8 bits per channel.
53
+ * @param width - Image width in pixels.
54
+ * @param height - Image height in pixels.
55
+ * @returns The PNG file bytes.
56
+ * @throws If `rgba` is not exactly `width * height * 4` bytes.
57
+ */
58
+ export const encodePng = (rgba, width, height) => {
59
+ if (rgba.length !== width * height * 4) {
60
+ throw new Error(`encodePng: buffer is ${rgba.length} bytes, expected ${width * height * 4} (${width}x${height} RGBA)`);
61
+ }
62
+ const stride = width * 4;
63
+ const raw = new Uint8Array((stride + 1) * height);
64
+ for (let y = 0; y < height; y++) {
65
+ raw[y * (stride + 1)] = 0;
66
+ raw.set(rgba.subarray(y * stride, y * stride + stride), y * (stride + 1) + 1);
67
+ }
68
+ const ihdr = new Uint8Array(13);
69
+ const ihdrView = new DataView(ihdr.buffer);
70
+ ihdrView.setUint32(0, width, false);
71
+ ihdrView.setUint32(4, height, false);
72
+ ihdr[8] = 8; // bit depth
73
+ ihdr[9] = 6; // color type: RGBA
74
+ const idat = deflateSync(raw);
75
+ const out = new Uint8Array(SIGNATURE.length + (12 + ihdr.length) + (12 + idat.length) + 12);
76
+ let offset = 0;
77
+ for (const part of [
78
+ SIGNATURE,
79
+ chunk("IHDR", ihdr),
80
+ chunk("IDAT", new Uint8Array(idat.buffer, idat.byteOffset, idat.length)),
81
+ chunk("IEND", new Uint8Array(0)),
82
+ ]) {
83
+ out.set(part, offset);
84
+ offset += part.length;
85
+ }
86
+ return out;
87
+ };
88
+ /**
89
+ * Render one frame and return it as PNG bytes.
90
+ *
91
+ * @remarks
92
+ * The whole export path in one call: resolve the frame's fonts and images,
93
+ * sync the retained scene, wait for glyph layouts and decodes, render, read
94
+ * the pixels back off the GPU, and encode.
95
+ *
96
+ * Call it once per frame on the SAME renderer; state is retained between
97
+ * calls, so consecutive frames only pay for what changed.
98
+ *
99
+ * The frame's dimensions must match the renderer's. A mismatch is a defect
100
+ * naming both sizes, not a silent rescale.
101
+ *
102
+ * Output is `pixelWidth × pixelHeight` — larger than the logical size when
103
+ * a `pixelRatio` was given.
104
+ *
105
+ * @param renderer - A renderer from {@link make}.
106
+ * @param frame - The frame to draw.
107
+ * @returns PNG file bytes.
108
+ */
109
+ export const renderToPng = Effect.fnUntraced(function* (renderer, frame) {
110
+ if (frame.width !== renderer.width || frame.height !== renderer.height) {
111
+ // deliberate defect: a mis-sized frame is a caller bug, not a
112
+ // recoverable condition
113
+ return yield* Effect.die(new Error(`NodeRenderer: frame is ${frame.width}x${frame.height}, renderer was made for ${renderer.width}x${renderer.height}`));
114
+ }
115
+ yield* Sync.resolveResources(renderer.sync, frame).pipe(Effect.provideService(Scope.Scope, renderer.scope));
116
+ yield* Sync.syncFrame(renderer.sync, frame);
117
+ // glyph layouts registered during sync must land before readback —
118
+ // an export frame never ships half-built text
119
+ yield* Sync.whenReady(renderer.sync);
120
+ // three advances nodeFrame.frameId only inside its rAF-driven
121
+ // animation loop (a 16ms setTimeout shim headless). Back-to-back
122
+ // exports outrun it, so FRAME-deduped nodes — the scene PassNode
123
+ // above all — skip their per-frame work and consecutive frames
124
+ // read a stale pass texture (pairwise-duplicated video frames).
125
+ // Drive it explicitly: one exported frame IS one three frame.
126
+ // sync call in a generator: a plain statement, no Effect.sync ceremony
127
+ // (that only buys an allocation and a fiber step for an infallible
128
+ // field write). Effect.sync is for the combinators that take one —
129
+ // ensuring, addFinalizer.
130
+ Gpu.advanceFrame(renderer.gpu);
131
+ yield* renderCompTargets(renderer.gpu, renderer.sync, renderer.pixelRatio);
132
+ const pipeline = ThreeScene.isEmpty(renderer.sync.hudScene)
133
+ ? renderer.post
134
+ : renderer.postWithHud;
135
+ yield* PostProcessing.render(pipeline);
136
+ const rgba = yield* Gpu.readRenderTarget(renderer.gpu, renderer.target, renderer.pixelWidth, renderer.pixelHeight);
137
+ return encodePng(rgba, renderer.pixelWidth, renderer.pixelHeight);
138
+ });
139
+ /**
140
+ * Acquire a headless renderer.
141
+ *
142
+ * @remarks
143
+ * Scoped: the GPU device, render targets, and every retained object are
144
+ * released when the scope closes. Acquire one per export and reuse it for
145
+ * every frame — startup involves creating a GPU device, so per-frame
146
+ * acquisition is dramatically slower.
147
+ *
148
+ * `width` and `height` fix the renderer's size for its lifetime and must
149
+ * match the frames you render.
150
+ *
151
+ * Use `pixelRatio` to supersample: a ratio of 2 renders at twice the linear
152
+ * resolution (four times the pixels), which is the usual way to get cleaner
153
+ * edges in an export.
154
+ *
155
+ * Depth of field is not applied — every frame renders sharp.
156
+ *
157
+ * @param options - Dimensions, supersampling, and any custom entity
158
+ * renderers.
159
+ * @returns A renderer, valid for the current scope.
160
+ */
161
+ export const make = Effect.fn("NodeRenderer.make")(function* (options) {
162
+ const dpr = options.pixelRatio ?? 1;
163
+ const pixelWidth = Math.round(options.width * dpr);
164
+ const pixelHeight = Math.round(options.height * dpr);
165
+ const registry = {
166
+ ...builtinRegistry,
167
+ ...options.renderers,
168
+ };
169
+ const sync = Sync.make(registry);
170
+ const device = yield* NodeGpu.makeDevice();
171
+ const { canvas, context } = NodeGpu.stubCanvas(pixelWidth, pixelHeight);
172
+ const gpu = yield* Gpu.make({
173
+ canvas: canvas,
174
+ context: context,
175
+ antialias: true,
176
+ device,
177
+ width: options.width,
178
+ height: options.height,
179
+ pixelRatio: dpr,
180
+ });
181
+ yield* Effect.addFinalizer(() => Sync.dispose(sync));
182
+ const scenePass = PostProcessing.pass(sync.scene, sync.camera);
183
+ // ponytail: no depth of field — the pipeline draws the scene pass
184
+ // straight through.
185
+ const sceneColor = scenePass.getTextureNode();
186
+ const post = PostProcessing.makePipeline(gpu, sceneColor);
187
+ const hudScenePass = PostProcessing.pass(sync.hudScene, sync.hudCamera);
188
+ const hudTex = hudScenePass.getTextureNode();
189
+ const postWithHud = PostProcessing.makePipeline(gpu, sceneColor.mul(hudTex.a.oneMinus()).add(hudTex.rgb.mul(hudTex.a)));
190
+ const target = yield* RenderTarget.make(pixelWidth, pixelHeight);
191
+ Gpu.setRenderTarget(gpu, target);
192
+ const scope = yield* Effect.scope;
193
+ return {
194
+ sync,
195
+ gpu,
196
+ scope,
197
+ post,
198
+ postWithHud,
199
+ target,
200
+ width: options.width,
201
+ height: options.height,
202
+ pixelWidth,
203
+ pixelHeight,
204
+ pixelRatio: dpr,
205
+ };
206
+ });
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@effect-motion/renderer",
3
+ "version": "0.5.0",
4
+ "description": "Retained three.js frame renderer for effect-motion",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/julia-script/effect-motion.git",
10
+ "directory": "packages/renderer"
11
+ },
12
+ "homepage": "https://github.com/julia-script/effect-motion#readme",
13
+ "bugs": "https://github.com/julia-script/effect-motion/issues",
14
+ "keywords": [
15
+ "effect",
16
+ "motion",
17
+ "renderer",
18
+ "three",
19
+ "webgpu",
20
+ "motion-graphics"
21
+ ],
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./*": {
28
+ "types": "./dist/*.d.ts",
29
+ "default": "./dist/*.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "@effect/platform-node": "4.0.0-beta.98",
40
+ "@types/three": "^0.185.1",
41
+ "jpeg-js": "^0.4.4",
42
+ "pngjs": "^7.0.0",
43
+ "three": "^0.185.1",
44
+ "troika-three-text": "^0.52.4",
45
+ "webgl-sdf-generator": "^1.1.1",
46
+ "@effect-motion/three": "^0.5.0",
47
+ "effect-motion": "^0.5.0"
48
+ },
49
+ "peerDependencies": {
50
+ "effect": ">=4.0.0-beta.98"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^26.1.1",
54
+ "@types/pngjs": "^6.0.5",
55
+ "effect": "4.0.0-beta.98",
56
+ "typescript": "^7.0.2",
57
+ "vitest": "^4.1.10"
58
+ },
59
+ "scripts": {
60
+ "build": "tsc -p tsconfig.build.json",
61
+ "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
62
+ "test": "vitest run --passWithNoTests",
63
+ "check": "tsc --noEmit"
64
+ }
65
+ }