@framefields/node-lut 2.0.2

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.
@@ -0,0 +1,536 @@
1
+ /// <reference types="webgpu" />
2
+ import type { WebGPUNodeRenderer } from "@framefields/node-sdk";
3
+ import { lutStore, signalRegistry } from "@framefields/webgpu-renderers";
4
+
5
+ export const LUT_3D_SHADER = `
6
+ struct LutUniforms {
7
+ intensity : f32,
8
+ lutSize : f32,
9
+ hasSignal : f32,
10
+ pad : f32,
11
+ };
12
+
13
+ @group(0) @binding(0) var<uniform> u : LutUniforms;
14
+ @group(1) @binding(0) var tex : texture_2d<f32>;
15
+ @group(1) @binding(1) var samp : sampler;
16
+ @group(2) @binding(0) var lutTex : texture_3d<f32>;
17
+ @group(2) @binding(1) var lutSamp : sampler;
18
+ @group(3) @binding(0) var signalTex : texture_2d<f32>;
19
+ @group(3) @binding(1) var signalSamp : sampler;
20
+
21
+ struct VSOut {
22
+ @builtin(position) pos : vec4<f32>,
23
+ @location(0) uv : vec2<f32>,
24
+ };
25
+
26
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> VSOut {
27
+ var pos = array<vec2<f32>, 4>(
28
+ vec2<f32>(-1.0, 1.0),
29
+ vec2<f32>(1.0, 1.0),
30
+ vec2<f32>(-1.0, -1.0),
31
+ vec2<f32>(1.0, -1.0)
32
+ );
33
+ var uv = array<vec2<f32>, 4>(
34
+ vec2<f32>(0.0, 0.0),
35
+ vec2<f32>(1.0, 0.0),
36
+ vec2<f32>(0.0, 1.0),
37
+ vec2<f32>(1.0, 1.0)
38
+ );
39
+ return VSOut(vec4<f32>(pos[vi], 0.0, 1.0), uv[vi]);
40
+ }
41
+
42
+ @fragment fn fs(in : VSOut) -> @location(0) vec4<f32> {
43
+ let inputColor = textureSampleLevel(tex, samp, in.uv, 0.0);
44
+
45
+ // Coordinate adjustment to sample from texel centers
46
+ let uvw = inputColor.rgb * ((u.lutSize - 1.0) / u.lutSize) + vec3<f32>(0.5 / u.lutSize);
47
+ let mappedColor = textureSampleLevel(lutTex, lutSamp, uvw, 0.0);
48
+
49
+ var activeIntensity = u.intensity;
50
+ if (u.hasSignal > 0.5) {
51
+ activeIntensity = textureSampleLevel(signalTex, signalSamp, in.uv, 0.0).r;
52
+ }
53
+
54
+ let finalRgb = inputColor.rgb + activeIntensity * (mappedColor.rgb - inputColor.rgb);
55
+ return vec4<f32>(finalRgb * inputColor.a, inputColor.a);
56
+ }
57
+ `;
58
+
59
+ export const LUT_1D_SHADER = `
60
+ struct LutUniforms {
61
+ intensity : f32,
62
+ lutSize : f32,
63
+ hasSignal : f32,
64
+ pad : f32,
65
+ };
66
+
67
+ @group(0) @binding(0) var<uniform> u : LutUniforms;
68
+ @group(1) @binding(0) var tex : texture_2d<f32>;
69
+ @group(1) @binding(1) var samp : sampler;
70
+ @group(2) @binding(0) var lutTex : texture_1d<f32>;
71
+ @group(2) @binding(1) var lutSamp : sampler;
72
+ @group(3) @binding(0) var signalTex : texture_2d<f32>;
73
+ @group(3) @binding(1) var signalSamp : sampler;
74
+
75
+ struct VSOut {
76
+ @builtin(position) pos : vec4<f32>,
77
+ @location(0) uv : vec2<f32>,
78
+ };
79
+
80
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> VSOut {
81
+ var pos = array<vec2<f32>, 4>(
82
+ vec2<f32>(-1.0, 1.0),
83
+ vec2<f32>(1.0, 1.0),
84
+ vec2<f32>(-1.0, -1.0),
85
+ vec2<f32>(1.0, -1.0)
86
+ );
87
+ var uv = array<vec2<f32>, 4>(
88
+ vec2<f32>(0.0, 0.0),
89
+ vec2<f32>(1.0, 0.0),
90
+ vec2<f32>(0.0, 1.0),
91
+ vec2<f32>(1.0, 1.0)
92
+ );
93
+ return VSOut(vec4<f32>(pos[vi], 0.0, 1.0), uv[vi]);
94
+ }
95
+
96
+ @fragment fn fs(in : VSOut) -> @location(0) vec4<f32> {
97
+ let inputColor = textureSampleLevel(tex, samp, in.uv, 0.0);
98
+
99
+ let rCoord = inputColor.r * ((u.lutSize - 1.0) / u.lutSize) + (0.5 / u.lutSize);
100
+ let gCoord = inputColor.g * ((u.lutSize - 1.0) / u.lutSize) + (0.5 / u.lutSize);
101
+ let bCoord = inputColor.b * ((u.lutSize - 1.0) / u.lutSize) + (0.5 / u.lutSize);
102
+
103
+ let mappedR = textureSampleLevel(lutTex, lutSamp, rCoord, 0.0).r;
104
+ let mappedG = textureSampleLevel(lutTex, lutSamp, gCoord, 0.0).g;
105
+ let mappedB = textureSampleLevel(lutTex, lutSamp, bCoord, 0.0).b;
106
+
107
+ var activeIntensity = u.intensity;
108
+ if (u.hasSignal > 0.5) {
109
+ activeIntensity = textureSampleLevel(signalTex, signalSamp, in.uv, 0.0).r;
110
+ }
111
+
112
+ let finalRgb = inputColor.rgb + activeIntensity * (vec3<f32>(mappedR, mappedG, mappedB) - inputColor.rgb);
113
+ return vec4<f32>(finalRgb * inputColor.a, inputColor.a);
114
+ }
115
+ `;
116
+
117
+ interface DeviceLutLayouts {
118
+ lutUniformLayout: GPUBindGroupLayout;
119
+ singleTextureLayout: GPUBindGroupLayout;
120
+ lutTextureLayout3D: GPUBindGroupLayout;
121
+ lutTextureLayout1D: GPUBindGroupLayout;
122
+ signalTextureLayout: GPUBindGroupLayout;
123
+ pipelineCache: Map<string, GPURenderPipeline>;
124
+ }
125
+
126
+ const deviceResourceCache = new WeakMap<GPUDevice, DeviceLutLayouts>();
127
+ const lutUniformData = new Float32Array(4);
128
+
129
+ function getDeviceLayouts(device: GPUDevice): DeviceLutLayouts {
130
+ let res = deviceResourceCache.get(device);
131
+ if (res) return res;
132
+
133
+ const lutUniformLayout = device.createBindGroupLayout({
134
+ entries: [
135
+ {
136
+ binding: 0,
137
+ visibility: GPUShaderStage.FRAGMENT,
138
+ buffer: { type: "uniform" },
139
+ },
140
+ ],
141
+ });
142
+ const singleTextureLayout = device.createBindGroupLayout({
143
+ entries: [
144
+ {
145
+ binding: 0,
146
+ visibility: GPUShaderStage.FRAGMENT,
147
+ texture: { sampleType: "float" },
148
+ },
149
+ {
150
+ binding: 1,
151
+ visibility: GPUShaderStage.FRAGMENT,
152
+ sampler: { type: "filtering" },
153
+ },
154
+ ],
155
+ });
156
+ const lutTextureLayout3D = device.createBindGroupLayout({
157
+ entries: [
158
+ {
159
+ binding: 0,
160
+ visibility: GPUShaderStage.FRAGMENT,
161
+ texture: { viewDimension: "3d", sampleType: "float" },
162
+ },
163
+ {
164
+ binding: 1,
165
+ visibility: GPUShaderStage.FRAGMENT,
166
+ sampler: { type: "filtering" },
167
+ },
168
+ ],
169
+ });
170
+ const lutTextureLayout1D = device.createBindGroupLayout({
171
+ entries: [
172
+ {
173
+ binding: 0,
174
+ visibility: GPUShaderStage.FRAGMENT,
175
+ texture: { viewDimension: "1d", sampleType: "float" },
176
+ },
177
+ {
178
+ binding: 1,
179
+ visibility: GPUShaderStage.FRAGMENT,
180
+ sampler: { type: "filtering" },
181
+ },
182
+ ],
183
+ });
184
+ const signalTextureLayout = device.createBindGroupLayout({
185
+ entries: [
186
+ {
187
+ binding: 0,
188
+ visibility: GPUShaderStage.FRAGMENT,
189
+ texture: { sampleType: "float" },
190
+ },
191
+ {
192
+ binding: 1,
193
+ visibility: GPUShaderStage.FRAGMENT,
194
+ sampler: { type: "filtering" },
195
+ },
196
+ ],
197
+ });
198
+
199
+ res = {
200
+ lutUniformLayout,
201
+ singleTextureLayout,
202
+ lutTextureLayout3D,
203
+ lutTextureLayout1D,
204
+ signalTextureLayout,
205
+ pipelineCache: new Map(),
206
+ };
207
+ deviceResourceCache.set(device, res);
208
+ return res;
209
+ }
210
+
211
+ function getLutResources(
212
+ device: GPUDevice,
213
+ format: GPUTextureFormat,
214
+ type: "1D" | "3D",
215
+ ) {
216
+ const layouts = getDeviceLayouts(device);
217
+ const cacheKey = `lut_${type}_${format}`;
218
+ let pipeline = layouts.pipelineCache.get(cacheKey);
219
+ if (!pipeline) {
220
+ const shaderCode = type === "3D" ? LUT_3D_SHADER : LUT_1D_SHADER;
221
+ const lutModule = device.createShaderModule({
222
+ label: `lut_${type.toLowerCase()}_${format}.wgsl`,
223
+ code: shaderCode,
224
+ });
225
+ const currentLutLayout =
226
+ type === "3D" ? layouts.lutTextureLayout3D : layouts.lutTextureLayout1D;
227
+ pipeline = device.createRenderPipeline({
228
+ label: `LutPipeline_${type}_${format}`,
229
+ layout: device.createPipelineLayout({
230
+ bindGroupLayouts: [
231
+ layouts.lutUniformLayout,
232
+ layouts.singleTextureLayout,
233
+ currentLutLayout,
234
+ layouts.signalTextureLayout,
235
+ ],
236
+ }),
237
+ vertex: { module: lutModule, entryPoint: "vs" },
238
+ fragment: {
239
+ module: lutModule,
240
+ entryPoint: "fs",
241
+ targets: [{ format }],
242
+ },
243
+ primitive: { topology: "triangle-strip" },
244
+ });
245
+ layouts.pipelineCache.set(cacheKey, pipeline);
246
+ }
247
+
248
+ return {
249
+ lutPipeline: pipeline,
250
+ lutUniformLayout: layouts.lutUniformLayout,
251
+ singleTextureLayout: layouts.singleTextureLayout,
252
+ lutTextureLayout:
253
+ type === "3D" ? layouts.lutTextureLayout3D : layouts.lutTextureLayout1D,
254
+ signalTextureLayout: layouts.signalTextureLayout,
255
+ };
256
+ }
257
+
258
+ export const LutWebGPURenderer: WebGPUNodeRenderer = async (args) => {
259
+ const {
260
+ ctx,
261
+ encoder,
262
+ pass,
263
+ targetView,
264
+ targetTexture,
265
+ targetWidth,
266
+ targetHeight,
267
+ props,
268
+ drawChild,
269
+ } = args;
270
+ const frame = props.frame ?? 0;
271
+ const fps = props.fps || 30;
272
+ const op = props.virtualMedia?.operation as any;
273
+ if (op?.op !== "ApplyLUT" || !op) return;
274
+ const childMedia = props.virtualMedia.children?.[0];
275
+ if (!childMedia) return;
276
+
277
+ const lutUrl = op.lutUrl;
278
+ if (!lutUrl) return;
279
+
280
+ // End the active pass
281
+ pass.end();
282
+
283
+ const width = targetWidth;
284
+ const height = targetHeight;
285
+
286
+ // Resolve the intensity signal
287
+ const intensityInput = op.intensityHandleId
288
+ ? op.inputs?.[op.intensityHandleId]
289
+ : null;
290
+ const hasSignal = !!(
291
+ intensityInput?.connectionValid &&
292
+ intensityInput.outputItem?.type === "Signal"
293
+ );
294
+ const sd = hasSignal ? intensityInput?.outputItem?.data : null;
295
+
296
+ let intensity = 1.0;
297
+ let hasStaticSignal = false;
298
+
299
+ if (!hasSignal) {
300
+ if (
301
+ intensityInput?.connectionValid &&
302
+ intensityInput.outputItem?.type === "Number"
303
+ ) {
304
+ intensity = Number(intensityInput.outputItem.data ?? 1.0);
305
+ } else {
306
+ intensity = Number(op.intensity ?? 1.0);
307
+ }
308
+ } else if (sd && typeof sd === "object") {
309
+ intensity = Number((sd as any).offset ?? 1.0);
310
+ hasStaticSignal = true;
311
+ }
312
+ intensity = Math.max(0, Math.min(10, intensity));
313
+
314
+ // Load the GPU LUT resource without blocking the rendering pipeline.
315
+ let lut = lutStore.get(lutUrl, ctx.device);
316
+ if (!lut) {
317
+ const isHeadless =
318
+ props.isHeadless ??
319
+ (typeof window === "undefined" ||
320
+ (globalThis as any).__IS_HEADLESS_RENDERER__);
321
+
322
+ if (isHeadless || props.forceWait) {
323
+ try {
324
+ lut = await lutStore.getOrLoad(lutUrl, ctx.device);
325
+ } catch (err) {
326
+ if (
327
+ !(err instanceof DOMException && err.name === "AbortError") &&
328
+ !(
329
+ err &&
330
+ typeof err === "object" &&
331
+ "name" in err &&
332
+ err.name === "AbortError"
333
+ )
334
+ ) {
335
+ console.error("[ApplyLUT] Failed to load LUT synchronously:", err);
336
+ }
337
+ }
338
+ } else {
339
+ // Trigger background load
340
+ lutStore.getOrLoad(lutUrl, ctx.device).catch((err) => {
341
+ if (
342
+ (err instanceof DOMException && err.name === "AbortError") ||
343
+ (err &&
344
+ typeof err === "object" &&
345
+ "name" in err &&
346
+ err.name === "AbortError")
347
+ ) {
348
+ return;
349
+ }
350
+ console.error("[ApplyLUT] Failed to load LUT in background:", err);
351
+ });
352
+ }
353
+
354
+ // Fallback to any matching cached LUT for the same node
355
+ if (!lut && lutUrl.startsWith("runtime://lut/")) {
356
+ const prefix = lutUrl.substring(0, lutUrl.lastIndexOf("-") + 1);
357
+ if (prefix) {
358
+ lut = lutStore.getAnyMatching(prefix, ctx.device);
359
+ }
360
+ }
361
+ }
362
+
363
+ const tmpTex = ctx.renderer.getTemporaryTexture(width, height, [
364
+ ...(props.excludeTextures || []),
365
+ targetTexture,
366
+ ]);
367
+ const tmpView = tmpTex.createView();
368
+
369
+ // Clear temporary texture
370
+ const childPass = ctx.renderer.beginFrame(
371
+ encoder,
372
+ tmpView,
373
+ { r: 0, g: 0, b: 0, a: 0 },
374
+ width,
375
+ height,
376
+ "clear",
377
+ );
378
+ childPass.end();
379
+
380
+ // Draw child frame into tmpTex
381
+ ctx.renderer.pushScissor({ x: 0, y: 0, width, height });
382
+ ctx.renderer.pushIdentity();
383
+ await drawChild(
384
+ childMedia,
385
+ { ...props, virtualMedia: childMedia },
386
+ tmpView,
387
+ tmpTex,
388
+ width,
389
+ height,
390
+ );
391
+ ctx.renderer.popTransform();
392
+ ctx.renderer.popScissor();
393
+
394
+ // If no LUT is ready (and no fallback is available), draw the child frame directly as a passthrough
395
+ if (!lut) {
396
+ const finalPass = ctx.renderer.beginFrame(
397
+ encoder,
398
+ targetView,
399
+ { r: 0, g: 0, b: 0, a: 0 },
400
+ targetWidth,
401
+ targetHeight,
402
+ "load",
403
+ );
404
+
405
+ ctx.renderer.drawTexture(
406
+ finalPass,
407
+ tmpTex,
408
+ { x: 0, y: 0, width: targetWidth, height: targetHeight },
409
+ { opacity: op.opacity ?? 1 },
410
+ );
411
+ args.pass = finalPass;
412
+ return;
413
+ }
414
+
415
+ // Fetch pipeline and layouts
416
+ const {
417
+ lutPipeline: pipeline,
418
+ lutUniformLayout: uLayout,
419
+ singleTextureLayout: tLayout,
420
+ lutTextureLayout: lLayout,
421
+ signalTextureLayout: sigLayout,
422
+ } = getLutResources(ctx.device, ctx.renderer.format, lut.type);
423
+
424
+ const sampler = ctx.renderer.samplerCache.getSampler(ctx.device);
425
+
426
+ // Setup intermediate output texture
427
+ const outTex = ctx.renderer.getTemporaryTexture(width, height, [
428
+ tmpTex,
429
+ targetTexture,
430
+ ...(props.excludeTextures || []),
431
+ ]);
432
+
433
+ // Fetch dynamic intensity signal texture view
434
+ let signalView: GPUTextureView;
435
+ if (hasStaticSignal) {
436
+ const elapsedSeconds =
437
+ props.elapsedMs !== undefined ? props.elapsedMs / 1000 : frame / fps;
438
+ const durationSeconds = props.virtualMedia?.metadata?.durationMs
439
+ ? props.virtualMedia.metadata.durationMs / 1000
440
+ : props.durationMs !== undefined
441
+ ? props.durationMs / 1000
442
+ : 0;
443
+ signalView = signalRegistry.getOrCreate2DTextureView(
444
+ ctx.device,
445
+ encoder,
446
+ sd.nodeId ?? "lut_intensity_sig",
447
+ elapsedSeconds,
448
+ durationSeconds,
449
+ sd,
450
+ width,
451
+ height,
452
+ props.renderId,
453
+ frame,
454
+ fps,
455
+ );
456
+ } else {
457
+ signalView = signalRegistry.getDummy1x1TextureView(ctx.device);
458
+ }
459
+
460
+ // Prepare uniforms
461
+ lutUniformData[0] = intensity;
462
+ lutUniformData[1] = lut.size;
463
+ lutUniformData[2] = hasStaticSignal ? 1 : 0;
464
+ lutUniformData[3] = 0; // padding
465
+
466
+ const uniformBuffer = ctx.renderer.getTemporaryBuffer(lutUniformData);
467
+
468
+ const renderPass = encoder.beginRenderPass({
469
+ colorAttachments: [
470
+ {
471
+ view: outTex.createView(),
472
+ loadOp: "clear",
473
+ storeOp: "store",
474
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
475
+ },
476
+ ],
477
+ });
478
+
479
+ renderPass.setPipeline(pipeline!);
480
+ renderPass.setBindGroup(
481
+ 0,
482
+ ctx.device.createBindGroup({
483
+ layout: uLayout!,
484
+ entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
485
+ }),
486
+ );
487
+ renderPass.setBindGroup(
488
+ 1,
489
+ ctx.renderer.bindGroupCache.getBindGroup(
490
+ ctx.device,
491
+ tLayout!,
492
+ tmpTex,
493
+ sampler,
494
+ ),
495
+ );
496
+ renderPass.setBindGroup(
497
+ 2,
498
+ ctx.device.createBindGroup({
499
+ layout: lLayout!,
500
+ entries: [
501
+ { binding: 0, resource: lut.texture.createView() },
502
+ { binding: 1, resource: sampler },
503
+ ],
504
+ }),
505
+ );
506
+ renderPass.setBindGroup(
507
+ 3,
508
+ ctx.device.createBindGroup({
509
+ layout: sigLayout!,
510
+ entries: [
511
+ { binding: 0, resource: signalView },
512
+ { binding: 1, resource: sampler },
513
+ ],
514
+ }),
515
+ );
516
+ renderPass.draw(4);
517
+ renderPass.end();
518
+
519
+ // Blending/opacity draw to the targetView
520
+ const finalPass = ctx.renderer.beginFrame(
521
+ encoder,
522
+ targetView,
523
+ { r: 0, g: 0, b: 0, a: 0 },
524
+ targetWidth,
525
+ targetHeight,
526
+ "load",
527
+ );
528
+
529
+ ctx.renderer.drawTexture(
530
+ finalPass,
531
+ outTex,
532
+ { x: 0, y: 0, width: targetWidth, height: targetHeight },
533
+ { opacity: op.opacity ?? 1 },
534
+ );
535
+ args.pass = finalPass;
536
+ };
@@ -0,0 +1,30 @@
1
+ import {
2
+ configBuilder,
3
+ ImageResultSchema,
4
+ VideoResultSchema,
5
+ } from "@framefields/node-sdk";
6
+ import { z } from "zod";
7
+
8
+ export const lutConfig = configBuilder()
9
+ .field("intensity", z.number().min(0).max(10).default(1.0), {
10
+ bindable: true,
11
+ dataTypes: ["Number", "Signal"],
12
+ label: "Intensity Signal",
13
+ })
14
+ .build();
15
+
16
+ export const LutNodeConfigSchema = lutConfig.schema;
17
+
18
+ export type LutNodeConfig = z.infer<typeof LutNodeConfigSchema>;
19
+
20
+ export const LutResultSchema = z.union([ImageResultSchema, VideoResultSchema]);
21
+
22
+ export type LutResult = z.infer<typeof LutResultSchema>;
23
+
24
+ export const LUT_OUTPUT_TYPE_MAP: Record<string, "Image" | "Video" | "GIF"> = {
25
+ Video: "Video",
26
+ Lottie: "Video",
27
+ GIF: "GIF",
28
+ Image: "Image",
29
+ SVG: "Image",
30
+ };
@@ -0,0 +1 @@
1
+ export * from "./config.js";
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "@framefields/tsconfig/node-library.json",
3
+ "compilerOptions": {
4
+ "baseUrl": ".",
5
+ "paths": {
6
+ "@/*": ["./src/*"]
7
+ },
8
+ "experimentalDecorators": true,
9
+ "emitDecoratorMetadata": true
10
+ },
11
+ "include": ["src"]
12
+ }
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from "tsdown";
2
+
3
+ export default defineConfig({
4
+ entry: {
5
+ index: "src/index.ts",
6
+ renderer: "src/renderers/index.ts",
7
+ },
8
+ format: ["esm"],
9
+ dts: true,
10
+ clean: true,
11
+ sourcemap: true,
12
+ treeshake: true,
13
+ });