@framefields/node-displacement-map 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.
@@ -0,0 +1,631 @@
1
+ /// <reference types="webgpu" />
2
+ import type { SignalData, VirtualMediaData } from "@framefields/core";
3
+ import type { WebGPUNodeRenderer } from "@framefields/node-sdk";
4
+ import { signalRegistry } from "@framefields/webgpu-renderers";
5
+
6
+ // FIX (P1): `mapMedia` is now a first-class, typed field instead of being
7
+ // reached via `(operation as any).mapMedia`. Replace `unknown` with the
8
+ // SDK's actual virtual-media child type if one is exported.
9
+ interface DisplacementMapOp {
10
+ op: "DisplacementMap";
11
+ strengthX?: number;
12
+ strengthY?: number;
13
+ xChannel?: "Red" | "Green" | "Blue" | "Alpha" | "Luminance";
14
+ yChannel?: "Red" | "Green" | "Blue" | "Alpha" | "Luminance";
15
+ wrapMode?: "Clamp" | "Repeat" | "Mirror";
16
+ opacity?: number;
17
+ mapMedia?: VirtualMediaData | null;
18
+
19
+ strengthXHandleId?: string | null;
20
+ strengthYHandleId?: string | null;
21
+
22
+ inputs?: Record<
23
+ string,
24
+ {
25
+ connectionValid: boolean;
26
+ outputItem: {
27
+ type: string;
28
+ data: unknown;
29
+ } | null;
30
+ }
31
+ >;
32
+ }
33
+
34
+ type DisplacementSignalData = SignalData & { nodeId?: string };
35
+
36
+ // Schema bounds mirrored from displacementMapConfig.ts (`z.number().min(0).max(500)`).
37
+ // FIX (P2): kept local rather than imported to avoid pulling zod/config-builder
38
+ // into the render path; ideally these are exported as shared constants from
39
+ // displacementMapConfig.ts so the two can't drift.
40
+ const STRENGTH_MIN = 0;
41
+ const STRENGTH_MAX = 500;
42
+
43
+ function clamp(value: number, min: number, max: number): number {
44
+ return Math.min(max, Math.max(min, value));
45
+ }
46
+
47
+ const DISPLACEMENT_MAP_SHADER = `
48
+ struct DisplacementUniforms {
49
+ strengthX : f32,
50
+ strengthY : f32,
51
+ xChannel : f32, // 0=R, 1=G, 2=B, 3=A, 4=Luma
52
+ yChannel : f32, // 0=R, 1=G, 2=B, 3=A, 4=Luma
53
+
54
+ wrapMode : f32, // 0=Clamp, 1=Repeat, 2=Mirror
55
+ hasSignalX : f32,
56
+ hasSignalY : f32,
57
+ _pad : f32,
58
+ };
59
+
60
+ @group(0) @binding(0) var<uniform> u : DisplacementUniforms;
61
+ @group(1) @binding(0) var srcTex : texture_2d<f32>;
62
+ @group(1) @binding(1) var srcSamp : sampler;
63
+ @group(2) @binding(0) var mapTex : texture_2d<f32>;
64
+ @group(2) @binding(1) var mapSamp : sampler;
65
+ @group(3) @binding(0) var sigXTex : texture_2d<f32>;
66
+ @group(3) @binding(1) var sigYTex : texture_2d<f32>;
67
+ @group(3) @binding(2) var sigSamp : sampler;
68
+
69
+ struct VSOut {
70
+ @builtin(position) pos : vec4<f32>,
71
+ @location(0) uv : vec2<f32>,
72
+ };
73
+
74
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> VSOut {
75
+ var pos = array<vec2<f32>, 4>(
76
+ vec2<f32>(-1.0, 1.0),
77
+ vec2<f32>(1.0, 1.0),
78
+ vec2<f32>(-1.0, -1.0),
79
+ vec2<f32>(1.0, -1.0)
80
+ );
81
+ var uv = array<vec2<f32>, 4>(
82
+ vec2<f32>(0.0, 0.0),
83
+ vec2<f32>(1.0, 0.0),
84
+ vec2<f32>(0.0, 1.0),
85
+ vec2<f32>(1.0, 1.0)
86
+ );
87
+ return VSOut(vec4<f32>(pos[vi], 0.0, 1.0), uv[vi]);
88
+ }
89
+
90
+ fn extractChannel(color: vec4<f32>, channel: f32) -> f32 {
91
+ if (channel < 0.5) { return color.r; }
92
+ if (channel < 1.5) { return color.g; }
93
+ if (channel < 2.5) { return color.b; }
94
+ if (channel < 3.5) { return color.a; }
95
+ // Luminance
96
+ return dot(color.rgb, vec3<f32>(0.299, 0.587, 0.114));
97
+ }
98
+
99
+ fn applyWrap(coord: f32, mode: f32) -> f32 {
100
+ if (mode < 0.5) {
101
+ // Clamp
102
+ return clamp(coord, 0.0, 1.0);
103
+ }
104
+ if (mode < 1.5) {
105
+ // Repeat
106
+ return fract(coord);
107
+ }
108
+ // Mirror
109
+ let t = fract(coord * 0.5) * 2.0;
110
+ if (t > 1.0) {
111
+ return 2.0 - t;
112
+ }
113
+ return t;
114
+ }
115
+
116
+ @fragment fn fs(in : VSOut) -> @location(0) vec4<f32> {
117
+ let mapColor = textureSampleLevel(mapTex, mapSamp, in.uv, 0.0);
118
+
119
+ // Extract displacement values from chosen channels, centered around 0.5 and gated by alpha
120
+ // NOTE (P3, not changed here): this yields a max offset of strength/2 px at
121
+ // either extreme (peak-to-peak swing == strength). Confirm with design whether
122
+ // "strength in pixels" should mean peak amplitude instead — see review doc.
123
+ let dispX = (extractChannel(mapColor, u.xChannel) - 0.5) * mapColor.a;
124
+ let dispY = (extractChannel(mapColor, u.yChannel) - 0.5) * mapColor.a;
125
+
126
+ // Get strength, potentially from signal textures
127
+ var sX = u.strengthX;
128
+ var sY = u.strengthY;
129
+ if (u.hasSignalX > 0.5) {
130
+ sX = textureSampleLevel(sigXTex, sigSamp, in.uv, 0.0).r;
131
+ }
132
+ if (u.hasSignalY > 0.5) {
133
+ sY = textureSampleLevel(sigYTex, sigSamp, in.uv, 0.0).r;
134
+ }
135
+
136
+ // Compute displacement in UV space (strength is in pixels, convert to UV)
137
+ let srcDims = vec2<f32>(textureDimensions(srcTex));
138
+ let offset = vec2<f32>(
139
+ dispX * sX / srcDims.x,
140
+ dispY * sY / srcDims.y,
141
+ );
142
+
143
+ let displaced = vec2<f32>(
144
+ applyWrap(in.uv.x + offset.x, u.wrapMode),
145
+ applyWrap(in.uv.y + offset.y, u.wrapMode),
146
+ );
147
+
148
+ return textureSampleLevel(srcTex, srcSamp, displaced, 0.0);
149
+ }
150
+ `;
151
+
152
+ interface DeviceDisplacementResources {
153
+ uniformLayout: GPUBindGroupLayout;
154
+ srcTextureLayout: GPUBindGroupLayout;
155
+ mapTextureLayout: GPUBindGroupLayout;
156
+ signalTextureLayout: GPUBindGroupLayout;
157
+ pipelineCache: Map<string, GPURenderPipeline>;
158
+ }
159
+
160
+ const deviceResourceCache = new WeakMap<
161
+ GPUDevice,
162
+ DeviceDisplacementResources
163
+ >();
164
+ // NOTE (P3, not changed): module-scoped and reused across every invocation
165
+ // (including recursive/nested displacement nodes). Currently safe because
166
+ // there is no `await` between filling this array and the synchronous
167
+ // getTemporaryBuffer() call that consumes it — if a future refactor adds an
168
+ // await in between, this becomes a cross-call data race. Consider making
169
+ // this function-scoped if that invariant ever changes.
170
+ const uniformData = new Float32Array(8);
171
+
172
+ function getDeviceLayouts(device: GPUDevice): DeviceDisplacementResources {
173
+ let res = deviceResourceCache.get(device);
174
+ if (res) return res;
175
+
176
+ const uniformLayout = device.createBindGroupLayout({
177
+ entries: [
178
+ {
179
+ binding: 0,
180
+ visibility: GPUShaderStage.FRAGMENT,
181
+ buffer: { type: "uniform" },
182
+ },
183
+ ],
184
+ });
185
+
186
+ const srcTextureLayout = device.createBindGroupLayout({
187
+ entries: [
188
+ {
189
+ binding: 0,
190
+ visibility: GPUShaderStage.FRAGMENT,
191
+ texture: { sampleType: "float" },
192
+ },
193
+ {
194
+ binding: 1,
195
+ visibility: GPUShaderStage.FRAGMENT,
196
+ sampler: { type: "filtering" },
197
+ },
198
+ ],
199
+ });
200
+
201
+ const mapTextureLayout = device.createBindGroupLayout({
202
+ entries: [
203
+ {
204
+ binding: 0,
205
+ visibility: GPUShaderStage.FRAGMENT,
206
+ texture: { sampleType: "float" },
207
+ },
208
+ {
209
+ binding: 1,
210
+ visibility: GPUShaderStage.FRAGMENT,
211
+ sampler: { type: "filtering" },
212
+ },
213
+ ],
214
+ });
215
+
216
+ const signalTextureLayout = device.createBindGroupLayout({
217
+ entries: [
218
+ {
219
+ binding: 0,
220
+ visibility: GPUShaderStage.FRAGMENT,
221
+ texture: { sampleType: "float" },
222
+ },
223
+ {
224
+ binding: 1,
225
+ visibility: GPUShaderStage.FRAGMENT,
226
+ texture: { sampleType: "float" },
227
+ },
228
+ {
229
+ binding: 2,
230
+ visibility: GPUShaderStage.FRAGMENT,
231
+ sampler: { type: "filtering" },
232
+ },
233
+ ],
234
+ });
235
+
236
+ res = {
237
+ uniformLayout,
238
+ srcTextureLayout,
239
+ mapTextureLayout,
240
+ signalTextureLayout,
241
+ pipelineCache: new Map(),
242
+ };
243
+ deviceResourceCache.set(device, res);
244
+ return res;
245
+ }
246
+
247
+ function getDisplacementResources(device: GPUDevice, format: GPUTextureFormat) {
248
+ const layouts = getDeviceLayouts(device);
249
+
250
+ const cacheKey = `displacement_${format}`;
251
+ let pipeline = layouts.pipelineCache.get(cacheKey);
252
+ if (!pipeline) {
253
+ const module = device.createShaderModule({
254
+ label: `displacement_${format}.wgsl`,
255
+ code: DISPLACEMENT_MAP_SHADER,
256
+ });
257
+
258
+ pipeline = device.createRenderPipeline({
259
+ label: `DisplacementMapPipeline_${format}`,
260
+ layout: device.createPipelineLayout({
261
+ bindGroupLayouts: [
262
+ layouts.uniformLayout,
263
+ layouts.srcTextureLayout,
264
+ layouts.mapTextureLayout,
265
+ layouts.signalTextureLayout,
266
+ ],
267
+ }),
268
+ vertex: { module, entryPoint: "vs" },
269
+ fragment: {
270
+ module,
271
+ entryPoint: "fs",
272
+ targets: [{ format }],
273
+ },
274
+ primitive: { topology: "triangle-strip" },
275
+ });
276
+ layouts.pipelineCache.set(cacheKey, pipeline);
277
+ }
278
+
279
+ return {
280
+ pipeline,
281
+ uniformLayout: layouts.uniformLayout,
282
+ srcTextureLayout: layouts.srcTextureLayout,
283
+ mapTextureLayout: layouts.mapTextureLayout,
284
+ signalTextureLayout: layouts.signalTextureLayout,
285
+ };
286
+ }
287
+
288
+ function channelToFloat(
289
+ ch: "Red" | "Green" | "Blue" | "Alpha" | "Luminance" | undefined,
290
+ ): number {
291
+ switch (ch) {
292
+ case "Green":
293
+ return 1;
294
+ case "Blue":
295
+ return 2;
296
+ case "Alpha":
297
+ return 3;
298
+ case "Luminance":
299
+ return 4;
300
+ default:
301
+ return 0; // Red
302
+ }
303
+ }
304
+
305
+ function wrapModeToFloat(
306
+ mode: "Clamp" | "Repeat" | "Mirror" | undefined,
307
+ ): number {
308
+ switch (mode) {
309
+ case "Repeat":
310
+ return 1;
311
+ case "Mirror":
312
+ return 2;
313
+ default:
314
+ return 0; // Clamp
315
+ }
316
+ }
317
+
318
+ function resolveSignalField(
319
+ op: DisplacementMapOp,
320
+ fieldName: "strengthX" | "strengthY",
321
+ defaultValue: number,
322
+ ): { hasSignal: boolean; sd: DisplacementSignalData | null; value: number } {
323
+ const handleIdKey = `${fieldName}HandleId` as keyof DisplacementMapOp;
324
+ const handleId = op[handleIdKey] as string | null | undefined;
325
+ const signalInput = handleId ? op.inputs?.[handleId] : null;
326
+
327
+ // FIX (P2): the config's dataTypes are exactly ["Number", "Signal"] — there
328
+ // is no "Numeric" type. Checking for it here made this branch either dead
329
+ // code or a landmine for any output item that happened to use that string.
330
+ // Only "Signal" should route through the per-pixel signal-texture path.
331
+ const hasSignal = !!(
332
+ signalInput?.connectionValid && signalInput.outputItem?.type === "Signal"
333
+ );
334
+ const sd = hasSignal
335
+ ? (signalInput?.outputItem?.data as DisplacementSignalData | null)
336
+ : null;
337
+
338
+ let value = defaultValue;
339
+ if (!hasSignal) {
340
+ if (
341
+ signalInput?.connectionValid &&
342
+ signalInput.outputItem?.type === "Number"
343
+ ) {
344
+ // FIX (P2): clamp static Number-connection values to the same bounds
345
+ // the field enforces when unbound, so binding a value behaves
346
+ // consistently with typing one in directly.
347
+ value = clamp(
348
+ Number(signalInput.outputItem.data ?? defaultValue),
349
+ STRENGTH_MIN,
350
+ STRENGTH_MAX,
351
+ );
352
+ } else {
353
+ value = Number(op[fieldName] ?? defaultValue);
354
+ }
355
+ } else if (sd && typeof sd === "object" && "offset" in sd) {
356
+ value = Number(sd.offset ?? defaultValue);
357
+ }
358
+
359
+ return { hasSignal, sd, value };
360
+ }
361
+
362
+ export const DisplacementMapWebGPURenderer: WebGPUNodeRenderer = async (
363
+ args,
364
+ ) => {
365
+ const {
366
+ ctx,
367
+ encoder,
368
+ pass,
369
+ targetView,
370
+ targetTexture,
371
+ targetWidth,
372
+ targetHeight,
373
+ props,
374
+ drawChild,
375
+ } = args;
376
+
377
+ const op = props.virtualMedia?.operation as DisplacementMapOp | undefined;
378
+ if (op?.op !== "DisplacementMap" || !op) return;
379
+
380
+ const sourceMedia = props.virtualMedia.children?.[0];
381
+ // FIX (P1): typed field access instead of `(operation as any)?.mapMedia`.
382
+ const mapMedia = op.mapMedia;
383
+ if (!sourceMedia || !mapMedia) return;
384
+
385
+ // End previous pass
386
+ pass.end();
387
+
388
+ const width = targetWidth;
389
+ const height = targetHeight;
390
+ const frame = props.frame ?? 0;
391
+ const fps = props.fps || 30;
392
+
393
+ // Resolve signal fields
394
+ const strengthXInfo = resolveSignalField(op, "strengthX", 50);
395
+ const strengthYInfo = resolveSignalField(op, "strengthY", 50);
396
+
397
+ // --- Draw source child into srcTex ---
398
+ const srcTex = ctx.renderer.getTemporaryTexture(width, height, [
399
+ ...(props.excludeTextures || []),
400
+ targetTexture,
401
+ ]);
402
+ const srcView = srcTex.createView();
403
+
404
+ const srcClearPass = ctx.renderer.beginFrame(
405
+ encoder,
406
+ srcView,
407
+ { r: 0, g: 0, b: 0, a: 0 },
408
+ width,
409
+ height,
410
+ "clear",
411
+ );
412
+ srcClearPass.end();
413
+
414
+ ctx.renderer.pushScissor({ x: 0, y: 0, width, height });
415
+ ctx.renderer.pushIdentity();
416
+ await drawChild(
417
+ sourceMedia,
418
+ {
419
+ ...props,
420
+ virtualMedia: sourceMedia,
421
+ renderId: `${props.renderId}-c0`,
422
+ excludeTextures: [...(props.excludeTextures || []), srcTex],
423
+ },
424
+ srcView,
425
+ srcTex,
426
+ width,
427
+ height,
428
+ );
429
+ ctx.renderer.popTransform();
430
+ ctx.renderer.popScissor();
431
+
432
+ // FIX (P0 - CRITICAL): drawChild leaves its own last-active pass open on
433
+ // `args.pass` (targeting srcView), matching the same "caller closes it"
434
+ // convention this function itself uses on exit (`args.pass = finalPass`
435
+ // below, with no `.end()`). Without this line, the next `beginFrame` call
436
+ // (for mapClearPass) opens a second render pass on the same encoder while
437
+ // this one is still open, which is invalid WebGPU encoder usage — it will
438
+ // throw or push the encoder into an error state that silently drops
439
+ // subsequent commands. This reproduces whenever `sourceMedia` renders
440
+ // through the GPU, i.e. essentially always.
441
+ args.pass.end();
442
+
443
+ // --- Draw map child into mapTex ---
444
+ const mapTex = ctx.renderer.getTemporaryTexture(width, height, [
445
+ ...(props.excludeTextures || []),
446
+ targetTexture,
447
+ srcTex,
448
+ ]);
449
+ const mapView = mapTex.createView();
450
+
451
+ const mapClearPass = ctx.renderer.beginFrame(
452
+ encoder,
453
+ mapView,
454
+ { r: 0, g: 0, b: 0, a: 0 },
455
+ width,
456
+ height,
457
+ "clear",
458
+ );
459
+ mapClearPass.end();
460
+
461
+ ctx.renderer.pushScissor({ x: 0, y: 0, width, height });
462
+ ctx.renderer.pushIdentity();
463
+ await drawChild(
464
+ mapMedia,
465
+ {
466
+ ...props,
467
+ virtualMedia: mapMedia,
468
+ renderId: `${props.renderId}-c1`,
469
+ excludeTextures: [...(props.excludeTextures || []), srcTex, mapTex],
470
+ },
471
+ mapView,
472
+ mapTex,
473
+ width,
474
+ height,
475
+ );
476
+ ctx.renderer.popTransform();
477
+ ctx.renderer.popScissor();
478
+
479
+ // End active pass from child drawing
480
+ args.pass.end();
481
+
482
+ // --- Fetch pipeline ---
483
+ const {
484
+ pipeline,
485
+ uniformLayout: uLayout,
486
+ srcTextureLayout: srcLayout,
487
+ mapTextureLayout: mapLayout,
488
+ signalTextureLayout: sigLayout,
489
+ } = getDisplacementResources(ctx.device, ctx.renderer.format);
490
+
491
+ const sampler = ctx.renderer.samplerCache.getSampler(ctx.device);
492
+
493
+ // --- Signal textures ---
494
+ let sigXView: GPUTextureView;
495
+ let sigYView: GPUTextureView;
496
+
497
+ const elapsedSeconds =
498
+ props.elapsedMs !== undefined ? props.elapsedMs / 1000 : frame / fps;
499
+ const durationSeconds = props.virtualMedia?.metadata?.durationMs
500
+ ? props.virtualMedia.metadata.durationMs / 1000
501
+ : props.durationMs !== undefined
502
+ ? props.durationMs / 1000
503
+ : 0;
504
+
505
+ if (strengthXInfo.hasSignal && strengthXInfo.sd) {
506
+ sigXView = signalRegistry.getOrCreate2DTextureView(
507
+ ctx.device,
508
+ encoder,
509
+ strengthXInfo.sd.nodeId ?? "displace_sx_sig",
510
+ elapsedSeconds,
511
+ durationSeconds,
512
+ strengthXInfo.sd,
513
+ width,
514
+ height,
515
+ props.renderId,
516
+ frame,
517
+ fps,
518
+ );
519
+ } else {
520
+ sigXView = signalRegistry.getDummy1x1TextureView(ctx.device);
521
+ }
522
+
523
+ if (strengthYInfo.hasSignal && strengthYInfo.sd) {
524
+ sigYView = signalRegistry.getOrCreate2DTextureView(
525
+ ctx.device,
526
+ encoder,
527
+ strengthYInfo.sd.nodeId ?? "displace_sy_sig",
528
+ elapsedSeconds,
529
+ durationSeconds,
530
+ strengthYInfo.sd,
531
+ width,
532
+ height,
533
+ props.renderId,
534
+ frame,
535
+ fps,
536
+ );
537
+ } else {
538
+ sigYView = signalRegistry.getDummy1x1TextureView(ctx.device);
539
+ }
540
+
541
+ // --- Fill uniforms ---
542
+ uniformData[0] = strengthXInfo.value;
543
+ uniformData[1] = strengthYInfo.value;
544
+ uniformData[2] = channelToFloat(op.xChannel);
545
+ uniformData[3] = channelToFloat(op.yChannel);
546
+ uniformData[4] = wrapModeToFloat(op.wrapMode);
547
+ uniformData[5] = strengthXInfo.hasSignal ? 1.0 : 0.0;
548
+ uniformData[6] = strengthYInfo.hasSignal ? 1.0 : 0.0;
549
+ uniformData[7] = 0.0; // padding
550
+
551
+ const uniformBuffer = ctx.renderer.getTemporaryBuffer(uniformData);
552
+
553
+ // --- Output texture ---
554
+ const outTex = ctx.renderer.getTemporaryTexture(width, height, [
555
+ srcTex,
556
+ mapTex,
557
+ targetTexture,
558
+ ...(props.excludeTextures || []),
559
+ ]);
560
+
561
+ // --- Render displacement pass ---
562
+ const renderPass = encoder.beginRenderPass({
563
+ colorAttachments: [
564
+ {
565
+ view: outTex.createView(),
566
+ loadOp: "clear",
567
+ storeOp: "store",
568
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
569
+ },
570
+ ],
571
+ });
572
+
573
+ renderPass.setPipeline(pipeline);
574
+ renderPass.setBindGroup(
575
+ 0,
576
+ ctx.device.createBindGroup({
577
+ layout: uLayout,
578
+ entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
579
+ }),
580
+ );
581
+ renderPass.setBindGroup(
582
+ 1,
583
+ ctx.renderer.bindGroupCache.getBindGroup(
584
+ ctx.device,
585
+ srcLayout,
586
+ srcTex,
587
+ sampler,
588
+ ),
589
+ );
590
+ renderPass.setBindGroup(
591
+ 2,
592
+ ctx.renderer.bindGroupCache.getBindGroup(
593
+ ctx.device,
594
+ mapLayout,
595
+ mapTex,
596
+ sampler,
597
+ ),
598
+ );
599
+ renderPass.setBindGroup(
600
+ 3,
601
+ ctx.device.createBindGroup({
602
+ layout: sigLayout,
603
+ entries: [
604
+ { binding: 0, resource: sigXView },
605
+ { binding: 1, resource: sigYView },
606
+ { binding: 2, resource: sampler },
607
+ ],
608
+ }),
609
+ );
610
+
611
+ renderPass.draw(4);
612
+ renderPass.end();
613
+
614
+ // --- Final pass: draw result to targetView ---
615
+ const finalPass = ctx.renderer.beginFrame(
616
+ encoder,
617
+ targetView,
618
+ { r: 0, g: 0, b: 0, a: 0 },
619
+ targetWidth,
620
+ targetHeight,
621
+ "load",
622
+ );
623
+ ctx.renderer.drawTexture(
624
+ finalPass,
625
+ outTex,
626
+ { x: 0, y: 0, width: targetWidth, height: targetHeight },
627
+ { opacity: op.opacity ?? 1 },
628
+ );
629
+
630
+ args.pass = finalPass;
631
+ };
@@ -0,0 +1,67 @@
1
+ import {
2
+ configBuilder,
3
+ ImageResultSchema,
4
+ VideoResultSchema,
5
+ } from "@framefields/node-sdk";
6
+ import { z } from "zod";
7
+
8
+ export const displacementMapConfig = configBuilder()
9
+ .field("strengthX", z.number().min(0).max(500).default(50), {
10
+ bindable: true,
11
+ dataTypes: ["Number", "Signal"],
12
+ label: "Strength X Signal",
13
+ description:
14
+ "Horizontal displacement strength in pixels. Can be modulated by a static number or a dynamic signal.",
15
+ })
16
+ .field("strengthY", z.number().min(0).max(500).default(50), {
17
+ bindable: true,
18
+ dataTypes: ["Number", "Signal"],
19
+ label: "Strength Y Signal",
20
+ description:
21
+ "Vertical displacement strength in pixels. Can be modulated by a static number or a dynamic signal.",
22
+ })
23
+ .field(
24
+ "xChannel",
25
+ z.enum(["Red", "Green", "Blue", "Alpha", "Luminance"]).default("Red"),
26
+ {
27
+ bindable: false,
28
+ label: "X Channel",
29
+ },
30
+ )
31
+ .field(
32
+ "yChannel",
33
+ z.enum(["Red", "Green", "Blue", "Alpha", "Luminance"]).default("Green"),
34
+ {
35
+ bindable: false,
36
+ label: "Y Channel",
37
+ },
38
+ )
39
+ .field("wrapMode", z.enum(["Clamp", "Repeat", "Mirror"]).default("Clamp"), {
40
+ bindable: false,
41
+ label: "Wrap Mode",
42
+ })
43
+ .build();
44
+
45
+ export const DisplacementMapNodeConfigSchema = displacementMapConfig.schema;
46
+
47
+ export type DisplacementMapNodeConfig = z.infer<
48
+ typeof DisplacementMapNodeConfigSchema
49
+ >;
50
+
51
+ export const DisplacementMapResultSchema = z.union([
52
+ ImageResultSchema,
53
+ VideoResultSchema,
54
+ ]);
55
+
56
+ export type DisplacementMapResult = z.infer<typeof DisplacementMapResultSchema>;
57
+
58
+ export const DISPLACEMENT_OUTPUT_TYPE_MAP: Record<
59
+ string,
60
+ "Image" | "Video" | "GIF"
61
+ > = {
62
+ Video: "Video",
63
+ Lottie: "Video",
64
+ GIF: "GIF",
65
+ Image: "Image",
66
+ SVG: "Image",
67
+ };
@@ -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
+ }