@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
@@ -0,0 +1,4038 @@
1
+ /// <reference types="webgpu" />
2
+ import { resolveMediaSourceUrl } from "@framefields/client-utils";
3
+ import { decodeAudioSource } from "@framefields/compositions";
4
+ import {
5
+ collectNodeOps,
6
+ computeLayout,
7
+ initLayout,
8
+ type LayoutNode,
9
+ type Rect,
10
+ } from "@framefields/compositions/program";
11
+ import type { MeshAudioDeformConfig } from "@framefields/core";
12
+ import {
13
+ DEFAULT_DURATION_MS,
14
+ getActiveMediaMetadata,
15
+ type VirtualMediaData,
16
+ } from "@framefields/core";
17
+ import type { WebGPUNodeRenderer } from "@framefields/node-sdk";
18
+ import { measureText } from "@framefields/renderers";
19
+ import { TemporalDeflickerPipeline } from "@framefields/tensor-webgpu";
20
+ import {
21
+ AudioLatentTrackCache,
22
+ type BlendMode,
23
+ Camera3D,
24
+ type Camera3DPose,
25
+ type Color,
26
+ type DrawMesh3DOpts,
27
+ type DrawQuad3DOpts,
28
+ type ExtrudedTextGeometryOptions,
29
+ type GPUMeshBuffers,
30
+ generateExtrudedTextGeometry,
31
+ jointMatrices,
32
+ type Light3DOptions,
33
+ type Mat4,
34
+ Matrix4Math,
35
+ type Mesh3DData,
36
+ type Model3DData,
37
+ measureMaxCaptionHeight,
38
+ parseColor,
39
+ poseNodes,
40
+ type RenderContextValue,
41
+ Renderer3D,
42
+ ScreenSpaceRelightPipeline,
43
+ type ShapeFillConfig,
44
+ type ShapeGeometryConfig,
45
+ SlugFontCache,
46
+ type SlugGlyphBatch,
47
+ srtLoader,
48
+ Vector3Math,
49
+ } from "@framefields/webgpu-renderers";
50
+
51
+ import { compileTimeline } from "../shared/compiler.js";
52
+ import type { CompositorOperation } from "../shared/config.js";
53
+ import { shouldPaintContainerBackground } from "./container-bg.js";
54
+ import { buildLayerMatrix, toDOMMatrix } from "./transform.js";
55
+ import {
56
+ has3DTransform,
57
+ type Quad2D,
58
+ solveHomography,
59
+ Transform3DMath,
60
+ } from "./transform3d.js";
61
+
62
+ // Layout depends on the document tree (whose `children` array keeps its
63
+ // identity between frames), the viewport, media sizes and the few layout
64
+ // props a timeline can animate. A film's tree holds every scene, so
65
+ // re-running Yoga each frame is costly when none of those changed.
66
+ const LAYOUT_PROPS = [
67
+ "width",
68
+ "height",
69
+ "gap",
70
+ "padding",
71
+ "fontSize",
72
+ "letterSpacing",
73
+ ] as const;
74
+ const layoutMemo = new WeakMap<
75
+ object,
76
+ { key: string; rects: Record<string, Rect> }
77
+ >();
78
+
79
+ function layoutKey(
80
+ width: number,
81
+ height: number,
82
+ targetsById: Record<string, any>,
83
+ mediaDims: Map<string, { width: number; height: number }>,
84
+ ): string {
85
+ const parts: unknown[] = [width, height];
86
+ for (const id in targetsById) {
87
+ const t = targetsById[id];
88
+ for (const k of LAYOUT_PROPS)
89
+ if (t[k] !== undefined) parts.push(id, k, t[k]);
90
+ }
91
+ for (const [id, d] of mediaDims) parts.push(id, d.width, d.height);
92
+ return JSON.stringify(parts);
93
+ }
94
+
95
+ const compositorHomographyWgsl = `
96
+ struct HomographyUniforms {
97
+ matrix_row0 : vec4<f32>,
98
+ matrix_row1 : vec4<f32>,
99
+ matrix_row2 : vec4<f32>,
100
+ params : vec4<f32>, // params.x = opacity
101
+ };
102
+
103
+ @group(0) @binding(0) var<uniform> u : HomographyUniforms;
104
+ @group(1) @binding(0) var tex : texture_2d<f32>;
105
+ @group(1) @binding(1) var samp : sampler;
106
+
107
+ struct VSOut {
108
+ @builtin(position) pos : vec4<f32>,
109
+ @location(0) uv : vec2<f32>,
110
+ };
111
+
112
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> VSOut {
113
+ var pos = array<vec2<f32>, 4>(
114
+ vec2<f32>(-1.0, 1.0),
115
+ vec2<f32>(1.0, 1.0),
116
+ vec2<f32>(-1.0, -1.0),
117
+ vec2<f32>(1.0, -1.0)
118
+ );
119
+ var uv = array<vec2<f32>, 4>(
120
+ vec2<f32>(0.0, 0.0),
121
+ vec2<f32>(1.0, 0.0),
122
+ vec2<f32>(0.0, 1.0),
123
+ vec2<f32>(1.0, 1.0)
124
+ );
125
+ return VSOut(vec4<f32>(pos[vi], 0.0, 1.0), uv[vi]);
126
+ }
127
+
128
+ @fragment fn fs(in : VSOut) -> @location(0) vec4<f32> {
129
+ let x = in.uv.x;
130
+ let y = in.uv.y;
131
+
132
+ let d = u.matrix_row2.x * x + u.matrix_row2.y * y + u.matrix_row2.z;
133
+ if (d <= 1e-6) {
134
+ return vec4<f32>(0.0);
135
+ }
136
+
137
+ let src_u = (u.matrix_row0.x * x + u.matrix_row0.y * y + u.matrix_row0.z) / d;
138
+ let src_v = (u.matrix_row1.x * x + u.matrix_row1.y * y + u.matrix_row1.z) / d;
139
+
140
+ if (src_u < 0.0 || src_u > 1.0 || src_v < 0.0 || src_v > 1.0) {
141
+ return vec4<f32>(0.0);
142
+ }
143
+
144
+ let color = textureSampleLevel(tex, samp, vec2<f32>(src_u, src_v), 0.0);
145
+ return color * u.params.x;
146
+ }
147
+ `;
148
+
149
+ interface DeviceHomographyResources {
150
+ pipeline: GPURenderPipeline;
151
+ uniformLayout: GPUBindGroupLayout;
152
+ textureLayout: GPUBindGroupLayout;
153
+ }
154
+
155
+ const deviceHomographyCache = new WeakMap<
156
+ GPUDevice,
157
+ DeviceHomographyResources
158
+ >();
159
+
160
+ function getHomographyResources(
161
+ device: GPUDevice,
162
+ format: GPUTextureFormat,
163
+ ): DeviceHomographyResources {
164
+ let res = deviceHomographyCache.get(device);
165
+ if (!res) {
166
+ const uniformLayout = device.createBindGroupLayout({
167
+ entries: [
168
+ {
169
+ binding: 0,
170
+ visibility: GPUShaderStage.FRAGMENT,
171
+ buffer: { type: "uniform" },
172
+ },
173
+ ],
174
+ });
175
+
176
+ const textureLayout = device.createBindGroupLayout({
177
+ entries: [
178
+ {
179
+ binding: 0,
180
+ visibility: GPUShaderStage.FRAGMENT,
181
+ texture: { sampleType: "float" },
182
+ },
183
+ {
184
+ binding: 1,
185
+ visibility: GPUShaderStage.FRAGMENT,
186
+ sampler: { type: "filtering" },
187
+ },
188
+ ],
189
+ });
190
+
191
+ const module = device.createShaderModule({
192
+ label: "compositor_homography.wgsl",
193
+ code: compositorHomographyWgsl,
194
+ });
195
+
196
+ const pipeline = device.createRenderPipeline({
197
+ label: "CompositorHomographyPipeline",
198
+ layout: device.createPipelineLayout({
199
+ bindGroupLayouts: [uniformLayout, textureLayout],
200
+ }),
201
+ vertex: {
202
+ module,
203
+ entryPoint: "vs",
204
+ },
205
+ fragment: {
206
+ module,
207
+ entryPoint: "fs",
208
+ targets: [
209
+ {
210
+ format,
211
+ blend: {
212
+ color: {
213
+ srcFactor: "one",
214
+ dstFactor: "one-minus-src-alpha",
215
+ operation: "add",
216
+ },
217
+ alpha: {
218
+ srcFactor: "one",
219
+ dstFactor: "one-minus-src-alpha",
220
+ operation: "add",
221
+ },
222
+ },
223
+ },
224
+ ],
225
+ },
226
+ primitive: {
227
+ topology: "triangle-strip",
228
+ },
229
+ });
230
+
231
+ res = { pipeline, uniformLayout, textureLayout };
232
+ deviceHomographyCache.set(device, res);
233
+ }
234
+ return res;
235
+ }
236
+
237
+ const audioTrackLatentCache = new Map<string, AudioLatentTrackCache>();
238
+ const deviceRenderer3DCache = new WeakMap<GPUDevice, Map<string, Renderer3D>>();
239
+
240
+ /** One Renderer3D per device, target format and MSAA sample count. */
241
+ function getRenderer3D(
242
+ device: GPUDevice,
243
+ format: GPUTextureFormat,
244
+ sampleCount: number,
245
+ ): Renderer3D {
246
+ let byConfig = deviceRenderer3DCache.get(device);
247
+ if (!byConfig) {
248
+ byConfig = new Map();
249
+ deviceRenderer3DCache.set(device, byConfig);
250
+ }
251
+ const key = `${format}|${sampleCount}`;
252
+ let r3d = byConfig.get(key);
253
+ if (!r3d) {
254
+ r3d = new Renderer3D(device, format, sampleCount);
255
+ byConfig.set(key, r3d);
256
+ }
257
+ return r3d;
258
+ }
259
+
260
+ const deviceRelightPipelineCache = new WeakMap<
261
+ GPUDevice,
262
+ ScreenSpaceRelightPipeline
263
+ >();
264
+
265
+ function getScreenSpaceRelightPipeline(
266
+ device: GPUDevice,
267
+ format: GPUTextureFormat,
268
+ ): ScreenSpaceRelightPipeline {
269
+ let relight = deviceRelightPipelineCache.get(device);
270
+ if (!relight || relight.format !== format) {
271
+ relight = new ScreenSpaceRelightPipeline(device, format);
272
+ deviceRelightPipelineCache.set(device, relight);
273
+ }
274
+ return relight;
275
+ }
276
+
277
+ const deviceDeflickerPipelineCache = new WeakMap<
278
+ GPUDevice,
279
+ Map<string, TemporalDeflickerPipeline>
280
+ >();
281
+
282
+ function getTemporalDeflickerPipeline(
283
+ device: GPUDevice,
284
+ layerKey: string,
285
+ ): TemporalDeflickerPipeline {
286
+ let map = deviceDeflickerPipelineCache.get(device);
287
+ if (!map) {
288
+ map = new Map();
289
+ deviceDeflickerPipelineCache.set(device, map);
290
+ }
291
+ let pipe = map.get(layerKey);
292
+ if (!pipe) {
293
+ pipe = new TemporalDeflickerPipeline(device);
294
+ map.set(layerKey, pipe);
295
+ }
296
+ return pipe;
297
+ }
298
+
299
+ function resolveVal(
300
+ val: unknown,
301
+ fallback: number,
302
+ frame: number,
303
+ fps: number,
304
+ ): number {
305
+ if (val === undefined || val === null) return fallback;
306
+ if (typeof val === "number") return Number.isFinite(val) ? val : fallback;
307
+ if (typeof val === "object" && val !== null) {
308
+ if (
309
+ "get" in (val as Record<string, unknown>) &&
310
+ typeof (val as { get: unknown }).get === "function"
311
+ ) {
312
+ try {
313
+ const res = (val as { get: (ctx?: unknown) => unknown }).get({
314
+ frame,
315
+ fps,
316
+ time: fps > 0 ? frame / fps : 0,
317
+ duration: 0,
318
+ durationMs: 0,
319
+ progress: 0,
320
+ deltaTime: fps > 0 ? 1 / fps : 0,
321
+ });
322
+ const n = Number(res);
323
+ if (Number.isFinite(n)) return n;
324
+ } catch {
325
+ // fallback
326
+ }
327
+ }
328
+ if ("value" in (val as Record<string, unknown>)) {
329
+ const n = Number((val as { value: unknown }).value);
330
+ if (Number.isFinite(n)) return n;
331
+ }
332
+ }
333
+ if (typeof val === "function") {
334
+ try {
335
+ const n = Number(val({ frame, fps, time: fps > 0 ? frame / fps : 0 }));
336
+ if (Number.isFinite(n)) return n;
337
+ } catch {
338
+ // fallback
339
+ }
340
+ }
341
+ const n = Number(val);
342
+ return Number.isFinite(n) ? n : fallback;
343
+ }
344
+
345
+ function synthesizeRelightLights(
346
+ relightOpts: Record<string, unknown>,
347
+ sceneLights: Light3DOptions[],
348
+ bounds: { x: number; y: number; width: number; height: number },
349
+ shiftedFrame: number,
350
+ fps: number,
351
+ ): Light3DOptions[] {
352
+ if (
353
+ sceneLights.length > 0 ||
354
+ (relightOpts.lightType === undefined &&
355
+ relightOpts.intensity === undefined &&
356
+ relightOpts.lightPosX === undefined)
357
+ ) {
358
+ return sceneLights;
359
+ }
360
+
361
+ const lightTypeStr = String(relightOpts.lightType ?? "point").toLowerCase();
362
+ const lightKind: Light3DOptions["lightType"] =
363
+ lightTypeStr === "directional"
364
+ ? "directional"
365
+ : lightTypeStr === "spot"
366
+ ? "spot"
367
+ : "point";
368
+
369
+ const rawIntensity = relightOpts.intensity ?? 1.0;
370
+ const intensity = resolveVal(rawIntensity, 1.0, shiftedFrame, fps);
371
+
372
+ const normX = resolveVal(relightOpts.lightPosX, 0.5, shiftedFrame, fps);
373
+ const normY = resolveVal(relightOpts.lightPosY, 0.5, shiftedFrame, fps);
374
+ const normZ = resolveVal(relightOpts.lightPosZ, 0.25, shiftedFrame, fps);
375
+ const normRadius = resolveVal(
376
+ relightOpts.lightRadius,
377
+ 0.75,
378
+ shiftedFrame,
379
+ fps,
380
+ );
381
+
382
+ const lightX = bounds.x + normX * bounds.width;
383
+ const lightY = bounds.y + normY * bounds.height;
384
+ const lightZ = normZ * Math.max(bounds.width, bounds.height);
385
+ const lightRadius = normRadius * Math.max(bounds.width, bounds.height);
386
+
387
+ const synthLight: Light3DOptions = {
388
+ lightType: lightKind,
389
+ color: (relightOpts.lightColor as string) ?? "#ffffff",
390
+ intensity,
391
+ x: lightX,
392
+ y: lightY,
393
+ z: lightZ,
394
+ radius: lightRadius,
395
+ decay: 1.0,
396
+ };
397
+
398
+ const ambientInt = resolveVal(
399
+ relightOpts.ambientIntensity,
400
+ 0.15,
401
+ shiftedFrame,
402
+ fps,
403
+ );
404
+ const synthAmbient: Light3DOptions = {
405
+ lightType: "ambient",
406
+ color: (relightOpts.ambientColor as string) ?? "#ffffff",
407
+ intensity: ambientInt,
408
+ };
409
+
410
+ return [synthAmbient, synthLight];
411
+ }
412
+
413
+ function drawProjectedQuad(
414
+ ctx: RenderContextValue,
415
+ encoder: GPUCommandEncoder,
416
+ destView: GPUTextureView,
417
+ sourceTex: GPUTexture,
418
+ quad: Quad2D,
419
+ destWidth: number,
420
+ destHeight: number,
421
+ opacity: number,
422
+ ): void {
423
+ const area = Math.abs(Transform3DMath.computeSignedArea(quad));
424
+ if (area < 1e-4) {
425
+ return;
426
+ }
427
+
428
+ const minX = Math.max(
429
+ 0,
430
+ Math.min(
431
+ destWidth,
432
+ Math.floor(
433
+ Math.min(
434
+ quad.topLeft.x,
435
+ quad.topRight.x,
436
+ quad.bottomLeft.x,
437
+ quad.bottomRight.x,
438
+ ),
439
+ ),
440
+ ),
441
+ );
442
+ const minY = Math.max(
443
+ 0,
444
+ Math.min(
445
+ destHeight,
446
+ Math.floor(
447
+ Math.min(
448
+ quad.topLeft.y,
449
+ quad.topRight.y,
450
+ quad.bottomLeft.y,
451
+ quad.bottomRight.y,
452
+ ),
453
+ ),
454
+ ),
455
+ );
456
+ const maxX = Math.max(
457
+ 0,
458
+ Math.min(
459
+ destWidth,
460
+ Math.ceil(
461
+ Math.max(
462
+ quad.topLeft.x,
463
+ quad.topRight.x,
464
+ quad.bottomLeft.x,
465
+ quad.bottomRight.x,
466
+ ),
467
+ ),
468
+ ),
469
+ );
470
+ const maxY = Math.max(
471
+ 0,
472
+ Math.min(
473
+ destHeight,
474
+ Math.ceil(
475
+ Math.max(
476
+ quad.topLeft.y,
477
+ quad.topRight.y,
478
+ quad.bottomLeft.y,
479
+ quad.bottomRight.y,
480
+ ),
481
+ ),
482
+ ),
483
+ );
484
+
485
+ const scissorW = maxX - minX;
486
+ const scissorH = maxY - minY;
487
+ if (scissorW <= 0 || scissorH <= 0) {
488
+ return;
489
+ }
490
+
491
+ const dstPoints = [
492
+ { x: quad.topLeft.x / destWidth, y: quad.topLeft.y / destHeight },
493
+ { x: quad.topRight.x / destWidth, y: quad.topRight.y / destHeight },
494
+ { x: quad.bottomLeft.x / destWidth, y: quad.bottomLeft.y / destHeight },
495
+ { x: quad.bottomRight.x / destWidth, y: quad.bottomRight.y / destHeight },
496
+ ];
497
+
498
+ const h = solveHomography(dstPoints);
499
+ for (const val of h) {
500
+ if (!Number.isFinite(val)) return;
501
+ }
502
+
503
+ const uniformData = new Float32Array(16);
504
+ uniformData[0] = h[0];
505
+ uniformData[1] = h[1];
506
+ uniformData[2] = h[2];
507
+ uniformData[3] = 0;
508
+
509
+ uniformData[4] = h[3];
510
+ uniformData[5] = h[4];
511
+ uniformData[6] = h[5];
512
+ uniformData[7] = 0;
513
+
514
+ uniformData[8] = h[6];
515
+ uniformData[9] = h[7];
516
+ uniformData[10] = h[8];
517
+ uniformData[11] = 0;
518
+
519
+ uniformData[12] = opacity;
520
+ uniformData[13] = 0;
521
+ uniformData[14] = 0;
522
+ uniformData[15] = 0;
523
+
524
+ const { pipeline, uniformLayout, textureLayout } = getHomographyResources(
525
+ ctx.device,
526
+ ctx.renderer.format,
527
+ );
528
+
529
+ const sampler = ctx.renderer.samplerCache.getSampler(ctx.device);
530
+ const uniformBuffer = ctx.renderer.getTemporaryBuffer(uniformData);
531
+
532
+ const uniformBindGroup = ctx.device.createBindGroup({
533
+ layout: uniformLayout,
534
+ entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
535
+ });
536
+
537
+ const textureBindGroup = ctx.renderer.bindGroupCache.getBindGroup(
538
+ ctx.device,
539
+ textureLayout,
540
+ sourceTex,
541
+ sampler,
542
+ );
543
+
544
+ const renderPass = encoder.beginRenderPass({
545
+ colorAttachments: [
546
+ {
547
+ view: destView,
548
+ loadOp: "load",
549
+ storeOp: "store",
550
+ },
551
+ ],
552
+ });
553
+
554
+ renderPass.setPipeline(pipeline);
555
+ renderPass.setScissorRect(minX, minY, scissorW, scissorH);
556
+ renderPass.setBindGroup(0, uniformBindGroup);
557
+ renderPass.setBindGroup(1, textureBindGroup);
558
+ renderPass.draw(4);
559
+ renderPass.end();
560
+ }
561
+
562
+ function computeContainerScissor(
563
+ matrix: DOMMatrix,
564
+ width: number,
565
+ height: number,
566
+ ): Rect {
567
+ const corners = [
568
+ matrix.transformPoint(new DOMPoint(0, 0)),
569
+ matrix.transformPoint(new DOMPoint(width, 0)),
570
+ matrix.transformPoint(new DOMPoint(0, height)),
571
+ matrix.transformPoint(new DOMPoint(width, height)),
572
+ ];
573
+ const xs = corners.map((p) => p.x);
574
+ const ys = corners.map((p) => p.y);
575
+ const minX = Math.min(...xs);
576
+ const minY = Math.min(...ys);
577
+ const maxX = Math.max(...xs);
578
+ const maxY = Math.max(...ys);
579
+ return {
580
+ x: Math.round(minX),
581
+ y: Math.round(minY),
582
+ width: Math.max(0, Math.round(maxX - minX)),
583
+ height: Math.max(0, Math.round(maxY - minY)),
584
+ };
585
+ }
586
+
587
+ export const CompositorWebGPURenderer: WebGPUNodeRenderer = async (args) => {
588
+ const {
589
+ ctx,
590
+ pass,
591
+ targetView,
592
+ targetTexture,
593
+ targetWidth,
594
+ targetHeight,
595
+ encoder,
596
+ props,
597
+ drawChild,
598
+ } = args;
599
+ const { virtualMedia, fps, frame } = props;
600
+ const op = virtualMedia.operation as any;
601
+
602
+ if (!op) return;
603
+
604
+ // Always end the incoming pass as we will be creating our own
605
+ pass.end();
606
+
607
+ if (op.op === "CompositorLayer") {
608
+ console.warn(
609
+ "CompositorWebGPURenderer called directly with legacy CompositorLayer operation. This is a bug.",
610
+ );
611
+ (args as any).pass = ctx.renderer.beginFrame(
612
+ encoder,
613
+ targetView,
614
+ { r: 0, g: 0, b: 0, a: 0 },
615
+ targetWidth,
616
+ targetHeight,
617
+ "load",
618
+ );
619
+ return;
620
+ }
621
+
622
+ if (op.op === "Compositor") {
623
+ const vop = op as CompositorOperation;
624
+ // 4x MSAA in the 3D pass unless the document opts out (antialias3d: false).
625
+ const sampleCount3d = vop.antialias3d === false ? 1 : 4;
626
+ const containerWidth = props.containerWidth ?? ctx.surface.width;
627
+ const containerHeight = props.containerHeight ?? ctx.surface.height;
628
+
629
+ const nativeWidth = vop.width ?? containerWidth;
630
+ const nativeHeight = vop.height ?? containerHeight;
631
+ const opacity =
632
+ typeof (vop as any).opacity === "number" ? (vop as any).opacity : 1;
633
+
634
+ const isVideoMode =
635
+ props.isVideoMode ??
636
+ (vop.mode === "Video" ||
637
+ vop.dataType === "Video" ||
638
+ virtualMedia?.metadata?.durationMs != null);
639
+
640
+ const inheritedSeekOffset = props.inheritedSeekOffset ?? 0;
641
+ const shiftedFrame = Math.max(
642
+ 0,
643
+ frame != null && fps != null
644
+ ? frame + Math.round(inheritedSeekOffset * fps)
645
+ : (frame ?? 0),
646
+ );
647
+
648
+ // 1. Create a temporary texture at native resolution
649
+ const compTex = ctx.renderer.getTemporaryTexture(
650
+ nativeWidth,
651
+ nativeHeight,
652
+ [...(props.excludeTextures || []), targetTexture],
653
+ );
654
+ const compView = compTex.createView();
655
+
656
+ const composeDuration =
657
+ virtualMedia?.metadata?.durationMs ?? DEFAULT_DURATION_MS;
658
+ const durationSec = composeDuration / 1000;
659
+ const { tl, targetsById } = compileTimeline(
660
+ props.renderId,
661
+ virtualMedia as any,
662
+ { fps: fps ?? 24, durationSec },
663
+ );
664
+ tl.seek(shiftedFrame / (fps ?? 24));
665
+
666
+ const r3d = getRenderer3D(
667
+ ctx.device,
668
+ ctx.renderer.format ?? "rgba8unorm",
669
+ sampleCount3d,
670
+ );
671
+ r3d.resetPools();
672
+
673
+ const relightPipe = getScreenSpaceRelightPipeline(
674
+ ctx.device,
675
+ ctx.renderer.format ?? "rgba8unorm",
676
+ );
677
+ relightPipe.resetPools();
678
+
679
+ // 2. Initial clear
680
+ const clearColor = parseColor(vop.backgroundColor);
681
+ const clearPass = ctx.renderer.beginFrame(
682
+ encoder,
683
+ compView,
684
+ clearColor,
685
+ nativeWidth,
686
+ nativeHeight,
687
+ "clear",
688
+ );
689
+ clearPass.end();
690
+
691
+ // 3. Draw all children into the native texture
692
+ let currentCompTex = compTex;
693
+
694
+ ctx.renderer.pushScissor({
695
+ x: 0,
696
+ y: 0,
697
+ width: nativeWidth,
698
+ height: nativeHeight,
699
+ });
700
+ ctx.renderer.pushIdentity();
701
+ // 3. Layout pass — deterministic geometry from the document (v2).
702
+ // Animated layout props (width/height/gap/padding) are sampled per
703
+ // frame from the compiled timeline into the tree via targetsById.
704
+ await initLayout();
705
+ const mediaDims = new Map<string, { width: number; height: number }>();
706
+ for (const vm of collectNodeOps(virtualMedia.children)) {
707
+ const lop = vm.operation as any;
708
+ if (lop.kind === "media") {
709
+ const meta = getActiveMediaMetadata(vm.children?.[0] as any);
710
+ if (meta?.width && meta?.height) {
711
+ mediaDims.set(lop.id, { width: meta.width, height: meta.height });
712
+ } else if (
713
+ lop.dataType === "Caption" ||
714
+ (vm.children?.[0]?.operation as any)?.dataType === "Caption" ||
715
+ (vm.children?.[0]?.operation as any)?.srtText !== undefined
716
+ ) {
717
+ const lopWidth = targetsById[lop.id]?.width ?? lop.width;
718
+ const autoWidth =
719
+ typeof lopWidth === "number"
720
+ ? lopWidth
721
+ : Math.round(nativeWidth * 0.8);
722
+ const lopHeight = targetsById[lop.id]?.height ?? lop.height;
723
+ let autoHeight = typeof lopHeight === "number" ? lopHeight : 160;
724
+ const contentVM = vm.children?.[0];
725
+ const src =
726
+ resolveMediaSourceUrl(contentVM) ??
727
+ (contentVM?.operation as any)?.url ??
728
+ (contentVM?.operation as any)?.src ??
729
+ (contentVM?.operation as any)?.srtText ??
730
+ lop.src ??
731
+ lop.url;
732
+ if (typeof lopHeight !== "number" && src) {
733
+ try {
734
+ const captions = await srtLoader.load(src);
735
+ if (captions && captions.length > 0) {
736
+ autoHeight = measureMaxCaptionHeight(captions, lop, autoWidth);
737
+ }
738
+ } catch {}
739
+ }
740
+ mediaDims.set(lop.id, { width: autoWidth, height: autoHeight });
741
+ }
742
+ }
743
+ }
744
+ const frameResolveNode = (vm: any): LayoutNode => {
745
+ const lop = vm?.operation ?? {};
746
+ const target = targetsById[lop.id];
747
+ const pick = (k: string) =>
748
+ target && target[k] !== undefined ? target[k] : lop[k];
749
+ const node: any = {
750
+ ...lop,
751
+ width: pick("width"),
752
+ height: pick("height"),
753
+ gap: pick("gap"),
754
+ padding: pick("padding"),
755
+ fontSize: pick("fontSize"),
756
+ letterSpacing: pick("letterSpacing"),
757
+ };
758
+ node.children = (vm?.children ?? []).map(frameResolveNode);
759
+ if (!node.children.length) node.children = undefined;
760
+ return node;
761
+ };
762
+ const layoutRoots = (): LayoutNode[] =>
763
+ (virtualMedia.children ?? [])
764
+ .map(frameResolveNode)
765
+ .filter(
766
+ (node: any) => node?.kind !== "camera" && node?.kind !== "light",
767
+ );
768
+ const memoTree = virtualMedia.children as object | undefined;
769
+ const memoKey = layoutKey(
770
+ nativeWidth,
771
+ nativeHeight,
772
+ targetsById,
773
+ mediaDims,
774
+ );
775
+ const memo = memoTree ? layoutMemo.get(memoTree) : undefined;
776
+ const { rects } =
777
+ memo?.key === memoKey
778
+ ? memo
779
+ : await computeLayout({
780
+ layout: layoutRoots(),
781
+ viewport: { width: nativeWidth, height: nativeHeight },
782
+ measure: (node, constraintWidth) => {
783
+ if (node.kind === "text") {
784
+ const textStyle = {
785
+ fontSize: node.fontSize ?? 48,
786
+ fontWeight: node.fontWeight,
787
+ fontFamily: node.fontFamily,
788
+ letterSpacing: node.letterSpacing,
789
+ lineHeight: node.lineHeight,
790
+ padding: node.padding,
791
+ };
792
+
793
+ if (constraintWidth !== undefined) {
794
+ const isAuto = typeof node.width !== "number";
795
+ const wrapped = measureText(node.text ?? "", {
796
+ ...textStyle,
797
+ width: isAuto ? undefined : constraintWidth,
798
+ keepNaturalWidth: true,
799
+ });
800
+ return {
801
+ width: isAuto
802
+ ? Math.ceil(wrapped.width)
803
+ : Math.min(constraintWidth, Math.ceil(wrapped.width)),
804
+ height: Math.ceil(wrapped.height),
805
+ };
806
+ }
807
+
808
+ const natural = measureText(node.text ?? "", textStyle);
809
+ if (typeof node.width !== "number") {
810
+ return {
811
+ width: Math.ceil(natural.width),
812
+ height: Math.ceil(natural.height),
813
+ };
814
+ }
815
+ const wrapped = measureText(node.text ?? "", {
816
+ ...textStyle,
817
+ width: node.width,
818
+ });
819
+ return {
820
+ width: Math.ceil(wrapped.width),
821
+ height: Math.ceil(wrapped.height),
822
+ };
823
+ }
824
+ if (node.kind === "media") return mediaDims.get(node.id) ?? null;
825
+ return null;
826
+ },
827
+ });
828
+ if (memoTree && memo?.key !== memoKey)
829
+ layoutMemo.set(memoTree, { key: memoKey, rects });
830
+ // 4. 3D Camera and Multiplane Setup
831
+ const allNodeOps = collectNodeOps(virtualMedia.children ?? []);
832
+ // A film can cut between cameras: the active one is the camera whose
833
+ // window holds this frame, and the latest to start wins (a cut list).
834
+ const cameraVMs = allNodeOps.filter(
835
+ (vm) => (vm.operation as any)?.kind === "camera",
836
+ );
837
+ const cameraVM = isVideoMode
838
+ ? cameraVMs
839
+ .filter((vm) => {
840
+ const op = vm.operation as any;
841
+ const start = op.startFrame ?? 0;
842
+ const dur = op.durationFrames;
843
+ return (
844
+ shiftedFrame >= start &&
845
+ (dur === undefined || shiftedFrame < start + dur)
846
+ );
847
+ })
848
+ .sort(
849
+ (a, b) =>
850
+ ((b.operation as any).startFrame ?? 0) -
851
+ ((a.operation as any).startFrame ?? 0),
852
+ )[0]
853
+ : cameraVMs[0];
854
+ const lightVMs = allNodeOps.filter(
855
+ (vm) => (vm.operation as any)?.kind === "light",
856
+ );
857
+ const has3DLayer = allNodeOps.some((vm) => {
858
+ const lop = vm.operation as any;
859
+ const target = lop.id ? targetsById[lop.id] : undefined;
860
+ return (
861
+ lop.kind !== "camera" &&
862
+ lop.kind !== "light" &&
863
+ (Boolean(target?.is3D ?? lop.is3D) || (target?.z ?? lop.z ?? 0) !== 0)
864
+ );
865
+ });
866
+ const is3DScene = Boolean(cameraVM || has3DLayer || lightVMs.length > 0);
867
+
868
+ let camera: Camera3DPose | null = null;
869
+ let dofEnabled = false;
870
+ const sceneLights: Light3DOptions[] = [];
871
+
872
+ if (is3DScene) {
873
+ const camOp = cameraVM?.operation as any;
874
+ const camStub = camOp?.id ? targetsById[camOp.id] : undefined;
875
+ const fov = camStub?.cameraFov ?? camOp?.lens?.fov ?? 50;
876
+ camera = Camera3D.createDefaultCamera(nativeWidth, nativeHeight, fov);
877
+ camera.near = camOp?.lens?.near ?? 1.0;
878
+ camera.far = camOp?.lens?.far ?? 50000.0;
879
+ camera.zoom = camStub?.cameraZoom ?? camOp?.lens?.zoom ?? 1.0;
880
+
881
+ const defaultEye = camera.eye;
882
+ const defaultTarget = camera.target;
883
+
884
+ // Eye
885
+ const eyeX = camStub?.cameraX ?? camStub?.x ?? camOp?.x ?? defaultEye[0];
886
+ const eyeY = camStub?.cameraY ?? camStub?.y ?? camOp?.y ?? defaultEye[1];
887
+ const eyeZ = camStub?.cameraZ ?? camStub?.z ?? camOp?.z ?? defaultEye[2];
888
+ camera.eye = [eyeX, eyeY, eyeZ];
889
+
890
+ // Target
891
+ const hasCustomTarget =
892
+ camStub?.targetX !== undefined ||
893
+ camStub?.targetY !== undefined ||
894
+ camStub?.targetZ !== undefined ||
895
+ camOp?.targetX !== undefined ||
896
+ camOp?.targetY !== undefined ||
897
+ camOp?.targetZ !== undefined;
898
+ if (hasCustomTarget) {
899
+ camera.target = [
900
+ camStub?.targetX ?? camOp?.targetX ?? defaultTarget[0],
901
+ camStub?.targetY ?? camOp?.targetY ?? defaultTarget[1],
902
+ camStub?.targetZ ?? camOp?.targetZ ?? defaultTarget[2],
903
+ ];
904
+ } else {
905
+ // Pure truck / pedestal shifts target along with eye
906
+ camera.target = [eyeX, eyeY, 0];
907
+ }
908
+
909
+ // Orientation (Pitch / Yaw / Roll)
910
+ const pitch = camStub?.cameraPitch ?? camOp?.pitch ?? 0;
911
+ const yaw = camStub?.cameraYaw ?? camOp?.yaw ?? 0;
912
+ const roll = camStub?.cameraRoll ?? camOp?.roll ?? 0;
913
+ if (pitch !== 0 || yaw !== 0 || roll !== 0) {
914
+ camera = Camera3D.applyOrientation(camera, pitch, yaw, roll);
915
+ }
916
+
917
+ // Orbit turntable
918
+ const orbitAzimuth = camStub?.orbitAzimuth;
919
+ const orbitElevation = camStub?.orbitElevation;
920
+ const orbitRadius = camStub?.orbitRadius;
921
+ if (
922
+ orbitAzimuth !== undefined ||
923
+ orbitElevation !== undefined ||
924
+ orbitRadius !== undefined
925
+ ) {
926
+ const radius =
927
+ orbitRadius ?? Vector3Math.distance(camera.eye, camera.target);
928
+ camera.eye = Camera3D.orbit(
929
+ camera.target,
930
+ radius,
931
+ orbitAzimuth ?? 0,
932
+ orbitElevation ?? 0,
933
+ );
934
+ }
935
+
936
+ // Procedural Handheld Shake
937
+ const shakeTrans =
938
+ camStub?.shakeTranslation ??
939
+ (camOp?.shake?.enabled ? camOp?.shake?.translationAmplitude : 0) ??
940
+ 0;
941
+ const shakeRot =
942
+ camStub?.shakeRotation ??
943
+ (camOp?.shake?.enabled ? camOp?.shake?.rotationAmplitude : 0) ??
944
+ 0;
945
+ if (shakeTrans > 0 || shakeRot > 0) {
946
+ const t = shiftedFrame / (fps ?? 24);
947
+ camera = Camera3D.applyCameraShake(camera, t, {
948
+ translationAmplitude: shakeTrans,
949
+ rotationAmplitude: shakeRot,
950
+ frequency: camOp?.shake?.frequency ?? 1.5,
951
+ octaves: camOp?.shake?.octaves ?? 3,
952
+ seed: camOp?.shake?.seed ?? 42,
953
+ });
954
+ }
955
+
956
+ // Depth of Field
957
+ dofEnabled = Boolean(
958
+ camOp?.dof?.enabled || camStub?.focusDistance !== undefined,
959
+ );
960
+ if (dofEnabled) {
961
+ camera.focusDistance =
962
+ camStub?.focusDistance ??
963
+ camOp?.dof?.focusDistance ??
964
+ Vector3Math.distance(camera.eye, camera.target);
965
+ camera.fStop = camStub?.fStop ?? camOp?.dof?.fStop ?? 2.8;
966
+ camera.maxBlurRadius =
967
+ camStub?.maxBlurRadius ?? camOp?.dof?.maxBlurRadius ?? 24;
968
+ }
969
+
970
+ // Collect Active Lights
971
+ for (const lvm of lightVMs) {
972
+ const lop = lvm.operation as any;
973
+ const targetId = lop.id || lop.inputHandleId;
974
+ const target = targetId ? targetsById[targetId] : undefined;
975
+ if (target?.hidden || lop.hidden) continue;
976
+
977
+ if (isVideoMode) {
978
+ const startFrame = lop.startFrame ?? 0;
979
+ const durationFrames = lop.durationFrames;
980
+ if (shiftedFrame < startFrame) continue;
981
+ if (
982
+ durationFrames !== undefined &&
983
+ shiftedFrame >= startFrame + durationFrames
984
+ ) {
985
+ continue;
986
+ }
987
+ }
988
+
989
+ const lightType = (target?.lightType ?? lop.lightType ?? "point") as
990
+ | "ambient"
991
+ | "directional"
992
+ | "point"
993
+ | "spot";
994
+ const rawColor = target?.color ?? lop.color ?? "#ffffff";
995
+ const color = parseColor(rawColor);
996
+ const intensity = target?.intensity ?? lop.intensity ?? 1.0;
997
+ const x = target?.x ?? lop.x ?? 0;
998
+ const y = target?.y ?? lop.y ?? 0;
999
+ const z = target?.z ?? lop.z ?? 0;
1000
+ const targetX = target?.targetX ?? lop.targetX ?? 0;
1001
+ const targetY = target?.targetY ?? lop.targetY ?? 0;
1002
+ const targetZ = target?.targetZ ?? lop.targetZ ?? 0;
1003
+ const radius = target?.radius ?? lop.radius ?? 1000;
1004
+ const angle = target?.angle ?? lop.angle ?? 45;
1005
+ const penumbra = target?.penumbra ?? lop.penumbra ?? 0.2;
1006
+ const decay = target?.decay ?? lop.decay ?? 1;
1007
+
1008
+ sceneLights.push({
1009
+ type: lightType,
1010
+ color,
1011
+ intensity,
1012
+ position: [x, y, z],
1013
+ target: [targetX, targetY, targetZ],
1014
+ radius,
1015
+ angle,
1016
+ penumbra,
1017
+ decay,
1018
+ });
1019
+ }
1020
+ }
1021
+
1022
+ type Pending3DItem =
1023
+ | { kind: "quad"; texture: GPUTexture; opts: DrawQuad3DOpts }
1024
+ | {
1025
+ // A text layer's glyphs, drawn natively on its plane.
1026
+ kind: "slug";
1027
+ texture?: undefined;
1028
+ batches: SlugGlyphBatch[];
1029
+ opts: DrawQuad3DOpts;
1030
+ /** Passes already on this plane (its backdrop quad) */
1031
+ stackBase: number;
1032
+ }
1033
+ | {
1034
+ kind: "mesh";
1035
+ mesh: Mesh3DData;
1036
+ texture?: GPUTexture;
1037
+ opts: DrawMesh3DOpts;
1038
+ meshBuffers?: GPUMeshBuffers;
1039
+ };
1040
+ const pending3DItems: Pending3DItem[] = [];
1041
+
1042
+ const flush3DPass = (
1043
+ curTex: GPUTexture,
1044
+ curView: GPUTextureView,
1045
+ curExcludes: GPUTexture[],
1046
+ ): { finalTex: GPUTexture; finalView: GPUTextureView } => {
1047
+ if (pending3DItems.length === 0 || !camera) {
1048
+ return { finalTex: curTex, finalView: curView };
1049
+ }
1050
+
1051
+ const r3d = getRenderer3D(
1052
+ ctx.device,
1053
+ ctx.renderer.format ?? "rgba8unorm",
1054
+ sampleCount3d,
1055
+ );
1056
+
1057
+ // Execute audio mesh deformation compute passes before beginning render pass
1058
+ for (const item of pending3DItems) {
1059
+ if (item.kind === "mesh" && item.opts.audioDeform) {
1060
+ item.meshBuffers = r3d.deformMesh(
1061
+ encoder,
1062
+ item.mesh,
1063
+ item.opts.audioDeform,
1064
+ );
1065
+ }
1066
+ }
1067
+
1068
+ const itemTextures = pending3DItems
1069
+ .map((q) => (q.kind === "quad" ? q.texture : q.texture))
1070
+ .filter((t): t is GPUTexture => Boolean(t));
1071
+
1072
+ const scene3dTex = ctx.renderer.getTemporaryTexture(
1073
+ nativeWidth,
1074
+ nativeHeight,
1075
+ [curTex, targetTexture, ...curExcludes, ...itemTextures],
1076
+ );
1077
+ const scene3dView = scene3dTex.createView();
1078
+
1079
+ let linearDepthTex: GPUTexture | undefined;
1080
+ let linearDepthView: GPUTextureView | undefined;
1081
+ if (dofEnabled) {
1082
+ linearDepthTex = r3d.getOrCreateLinearDepthTexture(
1083
+ nativeWidth,
1084
+ nativeHeight,
1085
+ );
1086
+ linearDepthView = linearDepthTex.createView();
1087
+ }
1088
+
1089
+ const {
1090
+ pass: pass3d,
1091
+ cameraBindGroup,
1092
+ cameraBuffer,
1093
+ lightsBindGroup,
1094
+ } = r3d.beginPass(
1095
+ encoder,
1096
+ scene3dView,
1097
+ nativeWidth,
1098
+ nativeHeight,
1099
+ camera,
1100
+ "clear",
1101
+ { r: 0, g: 0, b: 0, a: 0 },
1102
+ linearDepthView,
1103
+ sceneLights,
1104
+ );
1105
+
1106
+ for (const item of pending3DItems) {
1107
+ if (item.kind === "quad") {
1108
+ r3d.drawTextureQuad(
1109
+ pass3d,
1110
+ cameraBindGroup,
1111
+ lightsBindGroup,
1112
+ item.texture,
1113
+ item.opts,
1114
+ dofEnabled,
1115
+ );
1116
+ } else if (item.kind === "slug") {
1117
+ r3d.drawSlugText(
1118
+ pass3d,
1119
+ cameraBindGroup,
1120
+ lightsBindGroup,
1121
+ item.batches,
1122
+ item.opts,
1123
+ item.stackBase,
1124
+ dofEnabled,
1125
+ );
1126
+ } else if (item.kind === "mesh") {
1127
+ r3d.drawMesh3D(
1128
+ pass3d,
1129
+ cameraBindGroup,
1130
+ lightsBindGroup,
1131
+ item.meshBuffers ?? item.mesh,
1132
+ item.texture,
1133
+ item.opts,
1134
+ dofEnabled,
1135
+ );
1136
+ }
1137
+ }
1138
+
1139
+ pass3d.end();
1140
+
1141
+ let final3dTex = scene3dTex;
1142
+ if (dofEnabled && linearDepthTex) {
1143
+ const dofResultTex = ctx.renderer.getTemporaryTexture(
1144
+ nativeWidth,
1145
+ nativeHeight,
1146
+ [curTex, targetTexture, scene3dTex, ...curExcludes],
1147
+ );
1148
+ r3d.applyDepthOfField(
1149
+ encoder,
1150
+ scene3dTex,
1151
+ linearDepthTex,
1152
+ dofResultTex.createView(),
1153
+ cameraBuffer,
1154
+ );
1155
+ final3dTex = dofResultTex;
1156
+ }
1157
+
1158
+ const nextTex = ctx.renderer.composite(
1159
+ encoder,
1160
+ curTex,
1161
+ final3dTex,
1162
+ "normal",
1163
+ [targetTexture, ...curExcludes],
1164
+ );
1165
+ pending3DItems.length = 0;
1166
+ return { finalTex: nextTex, finalView: nextTex.createView() };
1167
+ };
1168
+
1169
+ // 5. Draw the node tree into the native texture (z-sorted per level).
1170
+ let nodeIndex = 0;
1171
+
1172
+ const renderNodeTree = async (
1173
+ vms: VirtualMediaData[],
1174
+ initialTex: GPUTexture,
1175
+ initialView: GPUTextureView,
1176
+ excludes: GPUTexture[],
1177
+ accumulatedOpacity = 1.0,
1178
+ parentMatrix = new DOMMatrix(),
1179
+ parentRect: Rect | null = null,
1180
+ targetW: number = nativeWidth,
1181
+ targetH: number = nativeHeight,
1182
+ parent3DMatrix: Mat4 | null = null,
1183
+ isRootScene = false,
1184
+ ): Promise<{ finalTex: GPUTexture; finalView: GPUTextureView }> => {
1185
+ const sorted = [...vms].sort((a, b) => {
1186
+ const za = (a.operation as any)?.zIndex ?? 0;
1187
+ const zb = (b.operation as any)?.zIndex ?? 0;
1188
+ return za - zb;
1189
+ });
1190
+ let activeTex = initialTex;
1191
+ let activeView = initialView;
1192
+ let activeExcludes = excludes;
1193
+ for (const child of sorted) {
1194
+ const lop = child.operation as any;
1195
+ if (lop.kind === "camera" || lop.kind === "light") continue;
1196
+ const targetId = lop.id || lop.inputHandleId;
1197
+ const target = targetId ? targetsById[targetId] : undefined;
1198
+ const isChild3D =
1199
+ Boolean(target?.is3D ?? lop.is3D) ||
1200
+ Boolean(camera && ((target?.z ?? lop.z ?? 0) !== 0 || lop.is3D)) ||
1201
+ Boolean(parent3DMatrix);
1202
+
1203
+ if (isRootScene && !isChild3D && pending3DItems.length > 0) {
1204
+ const flushed = flush3DPass(activeTex, activeView, activeExcludes);
1205
+ if (flushed.finalTex !== activeTex) {
1206
+ activeExcludes = [...activeExcludes, flushed.finalTex];
1207
+ }
1208
+ activeTex = flushed.finalTex;
1209
+ activeView = flushed.finalView;
1210
+ }
1211
+
1212
+ if (lop.op !== "CompositorLayer") {
1213
+ await drawChild(
1214
+ child,
1215
+ {
1216
+ containerWidth: targetW,
1217
+ containerHeight: targetH,
1218
+ renderId: `${props.renderId}-c${nodeIndex}-${lop.op}`,
1219
+ fps,
1220
+ isVideoMode,
1221
+ frame: shiftedFrame,
1222
+ // The child's effect passes must not borrow textures still in use here.
1223
+ excludeTextures: [targetTexture, ...activeExcludes],
1224
+ },
1225
+ activeView,
1226
+ activeTex,
1227
+ targetW,
1228
+ targetH,
1229
+ );
1230
+ continue;
1231
+ }
1232
+ nodeIndex += 1;
1233
+ const res = await renderLayerVM(
1234
+ child,
1235
+ nodeIndex,
1236
+ activeTex,
1237
+ activeView,
1238
+ activeExcludes,
1239
+ accumulatedOpacity,
1240
+ parentMatrix,
1241
+ parentRect,
1242
+ targetW,
1243
+ targetH,
1244
+ parent3DMatrix,
1245
+ isRootScene,
1246
+ );
1247
+ if (res) {
1248
+ if (res.finalTex !== activeTex) {
1249
+ activeExcludes = [...activeExcludes, res.finalTex];
1250
+ }
1251
+ activeTex = res.finalTex;
1252
+ activeView = res.finalView;
1253
+ }
1254
+ }
1255
+
1256
+ if (isRootScene && pending3DItems.length > 0) {
1257
+ const flushed = flush3DPass(activeTex, activeView, activeExcludes);
1258
+ if (flushed.finalTex !== activeTex) {
1259
+ activeExcludes = [...activeExcludes, flushed.finalTex];
1260
+ }
1261
+ activeTex = flushed.finalTex;
1262
+ activeView = flushed.finalView;
1263
+ }
1264
+ return { finalTex: activeTex, finalView: activeView };
1265
+ };
1266
+
1267
+ const renderLayerVM = async (
1268
+ nodeVM: VirtualMediaData,
1269
+ i: number,
1270
+ destTex: GPUTexture,
1271
+ destView: GPUTextureView,
1272
+ baseExcludes: GPUTexture[],
1273
+ accumulatedOpacity: number,
1274
+ parentMatrix: DOMMatrix,
1275
+ parentRect: Rect | null,
1276
+ targetW: number = nativeWidth,
1277
+ targetH: number = nativeHeight,
1278
+ parent3DMatrix: Mat4 | null = null,
1279
+ isRootScene = false,
1280
+ ): Promise<
1281
+ { finalTex: GPUTexture; finalView: GPUTextureView } | undefined
1282
+ > => {
1283
+ // Textures queued for the pending 3D flush are still live: a later
1284
+ // layer of the same size must not be handed one from the pool.
1285
+ const excludes = [
1286
+ ...baseExcludes,
1287
+ ...pending3DItems
1288
+ .map((q) => q.texture)
1289
+ .filter((t): t is GPUTexture => Boolean(t)),
1290
+ ];
1291
+ const lop = nodeVM.operation as any;
1292
+ if (lop.kind === "camera" || lop.kind === "light") return;
1293
+ const targetId = lop.id || lop.inputHandleId;
1294
+ const target = targetId ? targetsById[targetId] : undefined;
1295
+ if (target?.hidden || lop.hidden) return;
1296
+
1297
+ const kind = lop.kind;
1298
+ const isContainer = kind === "flex" || kind === "block" || kind === "box";
1299
+
1300
+ // Temporal gate (video mode): clip-relative duration.
1301
+ if (isVideoMode) {
1302
+ const startFrame = lop.startFrame ?? 0;
1303
+ const durationFrames = lop.durationFrames;
1304
+ if (shiftedFrame < startFrame) {
1305
+ return;
1306
+ }
1307
+ if (
1308
+ durationFrames !== undefined &&
1309
+ shiftedFrame >= startFrame + durationFrames
1310
+ ) {
1311
+ return;
1312
+ }
1313
+ }
1314
+ if (kind === "model3d") {
1315
+ const r3d = getRenderer3D(
1316
+ ctx.device,
1317
+ ctx.renderer.format ?? "rgba8unorm",
1318
+ sampleCount3d,
1319
+ );
1320
+ let modelSrc = lop.modelData ?? lop.src;
1321
+
1322
+ if (!modelSrc && lop.text3dOptions) {
1323
+ // The node's Layer3D.extrudedText options: geometry options plus a family name.
1324
+ const tOpts = lop.text3dOptions as Omit<
1325
+ ExtrudedTextGeometryOptions,
1326
+ "font"
1327
+ > & { fontFamily?: string };
1328
+ const font =
1329
+ (tOpts.fontFamily
1330
+ ? SlugFontCache.getParsed(tOpts.fontFamily)
1331
+ : null) ??
1332
+ SlugFontCache.getParsed("Inter") ??
1333
+ SlugFontCache.getParsed("Unbounded") ??
1334
+ SlugFontCache.getFirstParsed();
1335
+ if (font) {
1336
+ const res = generateExtrudedTextGeometry({
1337
+ ...tOpts,
1338
+ // SlugFontCache holds fontkit fonts under a narrower type.
1339
+ font: font as unknown as ExtrudedTextGeometryOptions["font"],
1340
+ });
1341
+ modelSrc = res.modelData;
1342
+ lop.modelData = modelSrc;
1343
+ }
1344
+ }
1345
+
1346
+ if (!modelSrc) return;
1347
+
1348
+ let model: Model3DData | undefined;
1349
+ try {
1350
+ model =
1351
+ typeof modelSrc === "object" &&
1352
+ modelSrc !== null &&
1353
+ "meshes" in modelSrc
1354
+ ? (modelSrc as Model3DData)
1355
+ : r3d.loadModel(modelSrc, lop.modelFormat ?? "auto", {
1356
+ center: lop.center,
1357
+ normalizeSize: lop.normalizeSize,
1358
+ });
1359
+ } catch (err) {
1360
+ console.error("Failed to load 3D model:", err);
1361
+ return;
1362
+ }
1363
+
1364
+ if (!model || model.meshes.length === 0) return;
1365
+
1366
+ const posX = target?.x ?? lop.x ?? 0;
1367
+ const posY = target?.y ?? lop.y ?? 0;
1368
+ const posZ = target?.z ?? lop.z ?? 0;
1369
+
1370
+ const rotX = target?.rotateX ?? lop.rotateX ?? 0;
1371
+ const rotY = target?.rotateY ?? lop.rotateY ?? 0;
1372
+ const rotZ =
1373
+ (target?.rotateZ ?? lop.rotateZ ?? 0) !== 0
1374
+ ? (target?.rotateZ ?? lop.rotateZ ?? 0)
1375
+ : (target?.rotation ?? lop.rotation ?? 0);
1376
+
1377
+ const sc = target?.scale ?? lop.scale ?? 1;
1378
+ const scX = (target?.scaleX ?? lop.scaleX ?? 1) * sc;
1379
+ const scY = (target?.scaleY ?? lop.scaleY ?? 1) * sc;
1380
+ const scZ = (target?.scaleZ ?? lop.scaleZ ?? 1) * sc;
1381
+
1382
+ let localM = Matrix4Math.identity();
1383
+ localM = Matrix4Math.multiply(
1384
+ localM,
1385
+ Matrix4Math.translate(posX, posY, posZ),
1386
+ );
1387
+
1388
+ const rxRad = (rotX * Math.PI) / 180;
1389
+ const ryRad = (rotY * Math.PI) / 180;
1390
+ const rzRad = (rotZ * Math.PI) / 180;
1391
+
1392
+ if (rzRad !== 0)
1393
+ localM = Matrix4Math.multiply(localM, Matrix4Math.rotateZ(rzRad));
1394
+ if (ryRad !== 0)
1395
+ localM = Matrix4Math.multiply(localM, Matrix4Math.rotateY(ryRad));
1396
+ if (rxRad !== 0)
1397
+ localM = Matrix4Math.multiply(localM, Matrix4Math.rotateX(rxRad));
1398
+
1399
+ localM = Matrix4Math.multiply(localM, Matrix4Math.scale(scX, scY, scZ));
1400
+
1401
+ const finalModelMatrix = parent3DMatrix
1402
+ ? Matrix4Math.multiply(parent3DMatrix, localM)
1403
+ : localM;
1404
+
1405
+ const colorParsed =
1406
+ target?.colorTint || target?.color || lop.colorTint || lop.color
1407
+ ? parseColor(
1408
+ target?.colorTint ??
1409
+ target?.color ??
1410
+ lop.colorTint ??
1411
+ lop.color,
1412
+ )
1413
+ : undefined;
1414
+
1415
+ const colorTint: [number, number, number, number] = colorParsed
1416
+ ? [colorParsed.r, colorParsed.g, colorParsed.b, colorParsed.a]
1417
+ : [1, 1, 1, 1];
1418
+
1419
+ const modelOpacity =
1420
+ (target?.opacity ?? lop.opacity ?? 1) * accumulatedOpacity;
1421
+
1422
+ // Animation evaluation
1423
+ let skinningMatrices: Float32Array | undefined;
1424
+ const animProgress =
1425
+ target?.animationProgress ?? lop.animationProgress ?? 0;
1426
+ const animTime = target?.animationTime ?? lop.animationTime ?? 0;
1427
+ const animSpeed = target?.animationSpeed ?? lop.animationSpeed ?? 1.0;
1428
+ const clip =
1429
+ (lop.animationName
1430
+ ? model.animations.find((a) => a.name === lop.animationName)
1431
+ : undefined) ?? model.animations[0];
1432
+ let clipSec = 0;
1433
+ if (clip && clip.duration > 0) {
1434
+ clipSec =
1435
+ animProgress > 0
1436
+ ? animProgress * clip.duration
1437
+ : animTime + ((shiftedFrame ?? 0) / (fps || 30)) * animSpeed;
1438
+ if (lop.loop ?? true) clipSec %= clip.duration;
1439
+ }
1440
+
1441
+ // A scene graph (glTF) is posed node by node: unskinned meshes ride
1442
+ // their node, skinned ones take their skin's joint matrices.
1443
+ const sceneRoot = Matrix4Math.multiply(
1444
+ finalModelMatrix,
1445
+ model.rootTransform ?? Matrix4Math.identity(),
1446
+ );
1447
+ const posed = model.nodes ? poseNodes(model, clip, clipSec) : undefined;
1448
+ const skinCache = new Map<number, Float32Array>();
1449
+ const placeMesh = (
1450
+ mesh: Mesh3DData,
1451
+ ): { matrix: Mat4; joints?: Float32Array } => {
1452
+ if (!posed) return { matrix: sceneRoot };
1453
+ if (mesh.skin !== undefined) {
1454
+ let joints = skinCache.get(mesh.skin);
1455
+ if (!joints) {
1456
+ joints = jointMatrices(model, mesh.skin, posed);
1457
+ skinCache.set(mesh.skin, joints);
1458
+ }
1459
+ return { matrix: sceneRoot, joints };
1460
+ }
1461
+ if (mesh.node !== undefined)
1462
+ return {
1463
+ matrix: Matrix4Math.multiply(sceneRoot, posed[mesh.node]!),
1464
+ };
1465
+ return { matrix: sceneRoot };
1466
+ };
1467
+
1468
+ if (!posed && clip && clip.duration > 0) {
1469
+ const currentSec = clipSec;
1470
+ if (model.skeleton && model.skeleton.bones.length > 0) {
1471
+ const numBones = Math.min(128, model.skeleton.bones.length);
1472
+ skinningMatrices = new Float32Array(128 * 16);
1473
+ for (let b = 0; b < 128; b++) {
1474
+ skinningMatrices[b * 16] = 1;
1475
+ skinningMatrices[b * 16 + 5] = 1;
1476
+ skinningMatrices[b * 16 + 10] = 1;
1477
+ skinningMatrices[b * 16 + 15] = 1;
1478
+ }
1479
+
1480
+ for (const track of clip.tracks) {
1481
+ const boneIdx = model.skeleton.boneIndicesByName[track.nodeName];
1482
+ if (boneIdx !== undefined && boneIdx < numBones) {
1483
+ const times = track.times;
1484
+ let sampleIdx = 0;
1485
+ for (let k = 0; k < times.length - 1; k++) {
1486
+ if (currentSec >= times[k]! && currentSec <= times[k + 1]!) {
1487
+ sampleIdx = k;
1488
+ break;
1489
+ }
1490
+ }
1491
+ const t0 = times[sampleIdx] ?? 0;
1492
+ const t1 = times[sampleIdx + 1] ?? t0;
1493
+ const alpha = t1 > t0 ? (currentSec - t0) / (t1 - t0) : 0;
1494
+
1495
+ if (track.translations) {
1496
+ const x0 = track.translations[sampleIdx * 3] ?? 0;
1497
+ const y0 = track.translations[sampleIdx * 3 + 1] ?? 0;
1498
+ const z0 = track.translations[sampleIdx * 3 + 2] ?? 0;
1499
+ const x1 = track.translations[(sampleIdx + 1) * 3] ?? x0;
1500
+ const y1 = track.translations[(sampleIdx + 1) * 3 + 1] ?? y0;
1501
+ const z1 = track.translations[(sampleIdx + 1) * 3 + 2] ?? z0;
1502
+ const curX = x0 + (x1 - x0) * alpha;
1503
+ const curY = y0 + (y1 - y0) * alpha;
1504
+ const curZ = z0 + (z1 - z0) * alpha;
1505
+
1506
+ const boneMat = Matrix4Math.translate(curX, curY, curZ);
1507
+ const bone = model.skeleton.bones[boneIdx];
1508
+ const skinMat = bone
1509
+ ? Matrix4Math.multiply(boneMat, bone.inverseBindMatrix)
1510
+ : boneMat;
1511
+ skinningMatrices.set(skinMat, boneIdx * 16);
1512
+ }
1513
+ }
1514
+ }
1515
+ }
1516
+ }
1517
+
1518
+ // Blended materials draw after everything opaque behind them.
1519
+ const isBlend = (m: Mesh3DData) =>
1520
+ m.materialName
1521
+ ? model.materials[m.materialName]?.alphaMode === "blend"
1522
+ : false;
1523
+ const drawOrder = [...model.meshes].sort(
1524
+ (a, b) => Number(isBlend(a)) - Number(isBlend(b)),
1525
+ );
1526
+ for (const mesh of drawOrder) {
1527
+ const mat = mesh.materialName
1528
+ ? model.materials[mesh.materialName]
1529
+ : undefined;
1530
+ const placed = placeMesh(mesh);
1531
+ const meshSkin = placed.joints ?? skinningMatrices;
1532
+
1533
+ let meshColorTint: [number, number, number, number];
1534
+ if (colorParsed) {
1535
+ if (mat?.diffuseColor) {
1536
+ meshColorTint = [
1537
+ mat.diffuseColor[0] * colorTint[0],
1538
+ mat.diffuseColor[1] * colorTint[1],
1539
+ mat.diffuseColor[2] * colorTint[2],
1540
+ (mat.diffuseColor[3] ?? 1.0) * colorTint[3],
1541
+ ];
1542
+ } else {
1543
+ meshColorTint = colorTint;
1544
+ }
1545
+ } else if (mat?.diffuseColor) {
1546
+ meshColorTint = mat.diffuseColor;
1547
+ } else {
1548
+ meshColorTint = [0.8, 0.8, 0.85, 1.0];
1549
+ }
1550
+
1551
+ // Audio latent 3D mesh deformation
1552
+ const audioDeformConfig = (target?.audioDeform ?? lop.audioDeform) as
1553
+ | MeshAudioDeformConfig
1554
+ | undefined;
1555
+
1556
+ let meshAudioDeform: DrawMesh3DOpts["audioDeform"];
1557
+ if (audioDeformConfig) {
1558
+ const currentSec = (shiftedFrame ?? 0) / (fps || 30);
1559
+ const timeMs = currentSec * 1000;
1560
+
1561
+ let latentBuffer = (audioDeformConfig as any).latentBuffer as
1562
+ | GPUBuffer
1563
+ | undefined;
1564
+ if (!latentBuffer && (audioDeformConfig as any).audioBuffer) {
1565
+ latentBuffer = (audioDeformConfig as any)
1566
+ .audioBuffer as GPUBuffer;
1567
+ }
1568
+
1569
+ if (!latentBuffer) {
1570
+ const directAudio = (audioDeformConfig as any).audioData as
1571
+ | Float32Array[]
1572
+ | { channels: Float32Array[]; sampleRate: number }
1573
+ | undefined;
1574
+
1575
+ if (directAudio) {
1576
+ const cacheKey = (lop.id ?? "audio-mesh") + "-direct-audio";
1577
+ let trackCache = audioTrackLatentCache.get(cacheKey);
1578
+ if (!trackCache) {
1579
+ const chs = Array.isArray(directAudio)
1580
+ ? directAudio
1581
+ : directAudio.channels;
1582
+ const sRate = Array.isArray(directAudio)
1583
+ ? 48000
1584
+ : directAudio.sampleRate;
1585
+ const totalF = Math.max(
1586
+ 10,
1587
+ Math.ceil(((chs[0]?.length ?? 0) / sRate) * (fps || 30)) +
1588
+ 10,
1589
+ );
1590
+ trackCache = new AudioLatentTrackCache(
1591
+ chs,
1592
+ sRate,
1593
+ fps || 30,
1594
+ totalF,
1595
+ );
1596
+ audioTrackLatentCache.set(cacheKey, trackCache);
1597
+ }
1598
+ latentBuffer = trackCache.getGpuBuffer(
1599
+ ctx.device,
1600
+ shiftedFrame ?? 0,
1601
+ );
1602
+ }
1603
+ }
1604
+
1605
+ if (!latentBuffer && audioDeformConfig.audioTrackId) {
1606
+ const trackId = audioDeformConfig.audioTrackId;
1607
+ let foundAudioUrl: string | undefined;
1608
+ const searchVms = (list: VirtualMediaData[]) => {
1609
+ for (const v of list) {
1610
+ const op = v.operation as Record<string, unknown> | undefined;
1611
+ if (op) {
1612
+ if (op.id === trackId || op.inputHandleId === trackId) {
1613
+ foundAudioUrl =
1614
+ (op.inputHandleId as string) ||
1615
+ (op.src as string) ||
1616
+ (op.url as string);
1617
+ return;
1618
+ }
1619
+ if (v.children) searchVms(v.children);
1620
+ }
1621
+ }
1622
+ };
1623
+ if (virtualMedia.children) searchVms(virtualMedia.children);
1624
+ if (
1625
+ !foundAudioUrl &&
1626
+ (trackId.endsWith(".mp3") ||
1627
+ trackId.endsWith(".wav") ||
1628
+ trackId.endsWith(".ogg"))
1629
+ ) {
1630
+ foundAudioUrl = trackId;
1631
+ }
1632
+
1633
+ if (foundAudioUrl) {
1634
+ try {
1635
+ let trackCache = audioTrackLatentCache.get(foundAudioUrl);
1636
+ if (!trackCache) {
1637
+ const decoded = await decodeAudioSource(foundAudioUrl);
1638
+ if (decoded && decoded.channels.length > 0) {
1639
+ const sRate = decoded.sampleRate || 48000;
1640
+ const ch0Len = decoded.channels[0]?.length ?? 0;
1641
+ const durationSec =
1642
+ (decoded as any).durationSec ?? ch0Len / sRate;
1643
+ const totalF = Math.max(
1644
+ 10,
1645
+ Math.ceil(durationSec * (fps || 30)) + 10,
1646
+ );
1647
+ trackCache = new AudioLatentTrackCache(
1648
+ decoded.channels,
1649
+ sRate,
1650
+ fps || 30,
1651
+ totalF,
1652
+ );
1653
+ audioTrackLatentCache.set(foundAudioUrl, trackCache);
1654
+ }
1655
+ }
1656
+ if (trackCache) {
1657
+ latentBuffer = trackCache.getGpuBuffer(
1658
+ ctx.device,
1659
+ shiftedFrame ?? 0,
1660
+ );
1661
+ }
1662
+ } catch (err) {
1663
+ console.warn(
1664
+ "[webgpu-renderer] Failed to decode audio for deformWithAudio:",
1665
+ err,
1666
+ );
1667
+ }
1668
+ }
1669
+ }
1670
+
1671
+ if (latentBuffer) {
1672
+ let amplitudeMultiplier =
1673
+ audioDeformConfig.amplitudeMultiplier ?? 1.0;
1674
+ if (
1675
+ typeof amplitudeMultiplier !== "number" &&
1676
+ typeof (amplitudeMultiplier as any)?.get === "function"
1677
+ ) {
1678
+ const frameCtx = {
1679
+ frame: shiftedFrame ?? 0,
1680
+ fps: fps || 30,
1681
+ time: currentSec,
1682
+ duration: 0,
1683
+ durationMs: 0,
1684
+ progress: 0,
1685
+ deltaTime: 1 / (fps || 30),
1686
+ };
1687
+ amplitudeMultiplier = Number(
1688
+ (amplitudeMultiplier as any).get(frameCtx),
1689
+ );
1690
+ } else if (
1691
+ typeof amplitudeMultiplier !== "number" &&
1692
+ typeof (amplitudeMultiplier as any)?.value === "number"
1693
+ ) {
1694
+ amplitudeMultiplier = (amplitudeMultiplier as any).value;
1695
+ }
1696
+
1697
+ meshAudioDeform = {
1698
+ config: {
1699
+ ...audioDeformConfig,
1700
+ amplitudeMultiplier: Number.isFinite(
1701
+ amplitudeMultiplier as number,
1702
+ )
1703
+ ? (amplitudeMultiplier as number)
1704
+ : 1.0,
1705
+ },
1706
+ latentBuffer,
1707
+ timeMs,
1708
+ sampleRate: 48000,
1709
+ };
1710
+ }
1711
+ }
1712
+
1713
+ const meshOpts: DrawMesh3DOpts = {
1714
+ modelMatrix: placed.matrix,
1715
+ opacity: modelOpacity * (mat?.opacity ?? 1.0),
1716
+ colorTint: meshColorTint,
1717
+ twoSided: target?.twoSided ?? lop.twoSided ?? mat?.twoSided ?? true,
1718
+ wireframe: Boolean(target?.wireframe ?? lop.wireframe),
1719
+ isSkinned: Boolean(
1720
+ meshSkin || (mesh.jointIndices && mesh.jointWeights),
1721
+ ),
1722
+ skinningMatrices: meshSkin,
1723
+ material: target?.material ?? lop.material ?? mat?.shading ?? "lit",
1724
+ shadeColor: mat?.shadeColor,
1725
+ alphaCutoff: mat?.alphaCutoff,
1726
+ textureWrap: mat?.textureWrap,
1727
+ shininess:
1728
+ target?.shininess ?? lop.shininess ?? mat?.shininess ?? 32,
1729
+ roughness: target?.roughness ?? lop.roughness ?? mat?.roughness,
1730
+ specularIntensity:
1731
+ target?.specularIntensity ?? lop.specularIntensity ?? 0.5,
1732
+ ambientIntensity:
1733
+ target?.ambientIntensity ?? lop.ambientIntensity ?? 1.0,
1734
+ metallic: target?.metallic ?? lop.metallic ?? mat?.metallic,
1735
+ audioDeform: meshAudioDeform,
1736
+ };
1737
+
1738
+ pending3DItems.push({
1739
+ kind: "mesh",
1740
+ mesh,
1741
+ texture: mat ? await r3d.materialTexture(mat) : undefined,
1742
+ opts: meshOpts,
1743
+ });
1744
+ }
1745
+
1746
+ return { finalTex: destTex, finalView: destView };
1747
+ }
1748
+
1749
+ const rect = rects[lop.id];
1750
+ if (!rect) {
1751
+ if (isContainer) {
1752
+ return await renderNodeTree(
1753
+ nodeVM.children ?? [],
1754
+ destTex,
1755
+ destView,
1756
+ excludes,
1757
+ accumulatedOpacity,
1758
+ parentMatrix,
1759
+ parentRect,
1760
+ targetW,
1761
+ targetH,
1762
+ parent3DMatrix,
1763
+ isRootScene,
1764
+ );
1765
+ }
1766
+ return;
1767
+ }
1768
+
1769
+ // Affine = layout rect + animated delta (CSS-transform semantics: a
1770
+ // track on x/y translates the node within/out of its laid-out slot).
1771
+ const baseX = lop.x ?? 0;
1772
+ const baseY = lop.y ?? 0;
1773
+ const sampledX = target?.x ?? baseX;
1774
+ const sampledY = target?.y ?? baseY;
1775
+
1776
+ let localX = 0;
1777
+ let localY = 0;
1778
+ if (parentRect) {
1779
+ const localLayoutX = rect.x - parentRect.x;
1780
+ const localLayoutY = rect.y - parentRect.y;
1781
+ localX = localLayoutX + (sampledX - baseX);
1782
+ localY = localLayoutY + (sampledY - baseY);
1783
+ } else {
1784
+ localX = rect.x + (sampledX - baseX);
1785
+ localY = rect.y + (sampledY - baseY);
1786
+ }
1787
+
1788
+ const scale = target?.scale ?? lop.scale ?? 1;
1789
+ const rotation = target?.rotation ?? lop.rotation ?? 0;
1790
+ const layerOpacity =
1791
+ (target?.opacity ?? lop.opacity ?? 1) * accumulatedOpacity;
1792
+
1793
+ const rawAnimators = target?.animators ?? lop.animators;
1794
+ const isPathText =
1795
+ kind === "text" && Boolean(lop.pathOptions || target?.pathOptions);
1796
+ const hasKineticAnimators =
1797
+ kind === "text" &&
1798
+ Array.isArray(rawAnimators) &&
1799
+ rawAnimators.length > 0;
1800
+ const hasFormation =
1801
+ kind === "text" &&
1802
+ Array.isArray(rawAnimators) &&
1803
+ rawAnimators.some((a: unknown) => {
1804
+ const anim = a as
1805
+ | {
1806
+ props?: { formation?: unknown };
1807
+ formation?: unknown;
1808
+ }
1809
+ | null
1810
+ | undefined;
1811
+ return (
1812
+ anim?.props?.formation !== undefined ||
1813
+ anim?.formation !== undefined
1814
+ );
1815
+ });
1816
+ let padX = 0;
1817
+ let padY = 0;
1818
+ if (hasFormation) {
1819
+ let formationRadius = 0;
1820
+ let formationPitch = 0;
1821
+ for (const a of rawAnimators as unknown[]) {
1822
+ const anim = a as
1823
+ | {
1824
+ props?: { formation?: { radius?: number; pitch?: number } };
1825
+ formation?: { radius?: number; pitch?: number };
1826
+ }
1827
+ | null
1828
+ | undefined;
1829
+ const f = anim?.props?.formation ?? anim?.formation;
1830
+ if (f) {
1831
+ if (typeof f.radius === "number") {
1832
+ formationRadius = Math.max(formationRadius, f.radius);
1833
+ }
1834
+ if (typeof f.pitch === "number") {
1835
+ formationPitch = Math.max(formationPitch, f.pitch);
1836
+ }
1837
+ }
1838
+ }
1839
+ padX = Math.max(160, Math.ceil(formationRadius + 100));
1840
+ padY = Math.max(
1841
+ 100,
1842
+ Math.ceil(formationRadius + formationPitch * 15 + 100),
1843
+ );
1844
+ } else if (!isPathText && hasKineticAnimators) {
1845
+ padX = Math.max(80, Math.ceil(rect.width * 0.2));
1846
+ padY = Math.max(50, Math.ceil(rect.height * 0.5));
1847
+ }
1848
+ // Path text draws into a canvas-sized layer so glyphs following a path
1849
+ // beyond the node box aren't clipped. Path coordinates stay local to
1850
+ // the node (like every other child), so the layer still sits at the
1851
+ // node's layout position.
1852
+ const layerW = isPathText ? targetW : rect.width + padX * 2;
1853
+ const layerH = isPathText ? targetH : rect.height + padY * 2;
1854
+ if (!isPathText && hasKineticAnimators) {
1855
+ localX -= padX;
1856
+ localY -= padY;
1857
+ }
1858
+ // Anchor-aware pivot (H2): scale/rotation happen AROUND the node's
1859
+ // anchor point (0.5/0.5 = center) instead of its top-left corner.
1860
+ // For path text the anchor is a point of the node box, not of the
1861
+ // oversized layer, so a badge spins around its own centre.
1862
+ const anchorX = lop.anchorX ?? 0.5;
1863
+ const anchorY = lop.anchorY ?? 0.5;
1864
+ const localMatrix = toDOMMatrix(
1865
+ buildLayerMatrix({
1866
+ x: localX,
1867
+ y: localY,
1868
+ width: layerW,
1869
+ height: layerH,
1870
+ rotation,
1871
+ scale,
1872
+ anchorX: isPathText ? (anchorX * rect.width) / layerW : anchorX,
1873
+ anchorY: isPathText ? (anchorY * rect.height) / layerH : anchorY,
1874
+ }),
1875
+ );
1876
+ const layerMatrix = parentMatrix.multiply(localMatrix);
1877
+ const blendMode = (lop.blendMode as BlendMode) || "normal";
1878
+
1879
+ let activeTex = destTex;
1880
+ let activeView = destView;
1881
+
1882
+ const is3D = has3DTransform(target, lop);
1883
+ const rotateX = target?.rotateX ?? lop.rotateX ?? 0;
1884
+ const rotateY = target?.rotateY ?? lop.rotateY ?? 0;
1885
+ const rotateZ = target?.rotateZ ?? lop.rotateZ ?? 0;
1886
+ const translateZ = target?.translateZ ?? lop.translateZ ?? 0;
1887
+ const perspective = target?.perspective ?? lop.perspective ?? 0;
1888
+ const perspectiveOriginX =
1889
+ target?.perspectiveOriginX ??
1890
+ lop.perspectiveOriginX ??
1891
+ lop.anchorX ??
1892
+ 0.5;
1893
+ const perspectiveOriginY =
1894
+ target?.perspectiveOriginY ??
1895
+ lop.perspectiveOriginY ??
1896
+ lop.anchorY ??
1897
+ 0.5;
1898
+ const backfaceVisibility =
1899
+ target?.backfaceVisibility ?? lop.backfaceVisibility ?? "visible";
1900
+
1901
+ let quad3D: Quad2D | null = null;
1902
+ if (is3D) {
1903
+ const m3d = Transform3DMath.buildLayer3DMatrix({
1904
+ x: localX,
1905
+ y: localY,
1906
+ width: layerW,
1907
+ height: layerH,
1908
+ rotateXDeg: rotateX,
1909
+ rotateYDeg: rotateY,
1910
+ rotateZDeg: rotateZ !== 0 ? rotateZ : rotation,
1911
+ scaleX: scale,
1912
+ scaleY: scale,
1913
+ perspectivePx: perspective,
1914
+ originXRatio: perspectiveOriginX,
1915
+ originYRatio: perspectiveOriginY,
1916
+ translateZPx: translateZ,
1917
+ });
1918
+
1919
+ let q = Transform3DMath.projectRectangleCorners(m3d, layerW, layerH);
1920
+ if (!parentMatrix.isIdentity) {
1921
+ q = {
1922
+ topLeft: parentMatrix.transformPoint(
1923
+ new DOMPoint(q.topLeft.x, q.topLeft.y),
1924
+ ),
1925
+ topRight: parentMatrix.transformPoint(
1926
+ new DOMPoint(q.topRight.x, q.topRight.y),
1927
+ ),
1928
+ bottomLeft: parentMatrix.transformPoint(
1929
+ new DOMPoint(q.bottomLeft.x, q.bottomLeft.y),
1930
+ ),
1931
+ bottomRight: parentMatrix.transformPoint(
1932
+ new DOMPoint(q.bottomRight.x, q.bottomRight.y),
1933
+ ),
1934
+ };
1935
+ }
1936
+
1937
+ if (
1938
+ backfaceVisibility === "hidden" &&
1939
+ Transform3DMath.computeSignedArea(q) < 0
1940
+ ) {
1941
+ return; // Culled by backface visibility
1942
+ }
1943
+
1944
+ quad3D = q;
1945
+ }
1946
+
1947
+ const isTrue3D =
1948
+ Boolean(target?.is3D ?? lop.is3D) ||
1949
+ Boolean(camera && ((target?.z ?? lop.z ?? 0) !== 0 || lop.is3D));
1950
+
1951
+ // Container rendering
1952
+ if (isContainer) {
1953
+ if (lop.transformStyle === "preserve-3d" && isTrue3D) {
1954
+ let current3DMatrix: Mat4 = Matrix4Math.identity();
1955
+ const containerX =
1956
+ parent3DMatrix !== null
1957
+ ? (target?.x ?? lop.x ?? 0)
1958
+ : (target?.x ?? lop.x ?? localX + layerW * 0.5);
1959
+ const containerY =
1960
+ parent3DMatrix !== null
1961
+ ? (target?.y ?? lop.y ?? 0)
1962
+ : (target?.y ?? lop.y ?? localY + layerH * 0.5);
1963
+ const containerZ = target?.z ?? lop.z ?? 0;
1964
+ current3DMatrix = Matrix4Math.multiply(
1965
+ current3DMatrix,
1966
+ Matrix4Math.translate(containerX, containerY, containerZ),
1967
+ );
1968
+ const rxRad = ((target?.rotateX ?? lop.rotateX ?? 0) * Math.PI) / 180;
1969
+ const ryRad = ((target?.rotateY ?? lop.rotateY ?? 0) * Math.PI) / 180;
1970
+ const rzRad =
1971
+ ((target?.rotateZ ?? lop.rotateZ ?? 0) !== 0
1972
+ ? (target?.rotateZ ?? lop.rotateZ ?? 0)
1973
+ : rotation) *
1974
+ (Math.PI / 180);
1975
+ if (rzRad !== 0)
1976
+ current3DMatrix = Matrix4Math.multiply(
1977
+ current3DMatrix,
1978
+ Matrix4Math.rotateZ(rzRad),
1979
+ );
1980
+ if (ryRad !== 0)
1981
+ current3DMatrix = Matrix4Math.multiply(
1982
+ current3DMatrix,
1983
+ Matrix4Math.rotateY(ryRad),
1984
+ );
1985
+ if (rxRad !== 0)
1986
+ current3DMatrix = Matrix4Math.multiply(
1987
+ current3DMatrix,
1988
+ Matrix4Math.rotateX(rxRad),
1989
+ );
1990
+ const csX = (target?.scaleX ?? lop.scaleX ?? 1) * scale;
1991
+ const csY = (target?.scaleY ?? lop.scaleY ?? 1) * scale;
1992
+ const csZ = (target?.scaleZ ?? lop.scaleZ ?? 1) * scale;
1993
+ if (csX !== 1 || csY !== 1 || csZ !== 1)
1994
+ current3DMatrix = Matrix4Math.multiply(
1995
+ current3DMatrix,
1996
+ Matrix4Math.scale(csX, csY, csZ),
1997
+ );
1998
+
1999
+ if (parent3DMatrix) {
2000
+ current3DMatrix = Matrix4Math.multiply(
2001
+ parent3DMatrix,
2002
+ current3DMatrix,
2003
+ );
2004
+ }
2005
+
2006
+ if (nodeVM.children && nodeVM.children.length > 0) {
2007
+ const res = await renderNodeTree(
2008
+ nodeVM.children,
2009
+ activeTex,
2010
+ activeView,
2011
+ excludes,
2012
+ layerOpacity,
2013
+ new DOMMatrix(),
2014
+ null,
2015
+ layerW,
2016
+ layerH,
2017
+ current3DMatrix,
2018
+ false,
2019
+ );
2020
+ activeTex = res.finalTex;
2021
+ activeView = res.finalView;
2022
+ }
2023
+ return { finalTex: activeTex, finalView: activeView };
2024
+ }
2025
+
2026
+ if (isTrue3D) {
2027
+ const texW = Math.max(1, Math.ceil(layerW));
2028
+ const texH = Math.max(1, Math.ceil(layerH));
2029
+ const groupExcludes = [
2030
+ activeTex,
2031
+ targetTexture,
2032
+ ...excludes,
2033
+ ...pending3DItems
2034
+ .map((q) => q.texture)
2035
+ .filter((t): t is GPUTexture => Boolean(t)),
2036
+ ];
2037
+ const groupTex = ctx.renderer.getTemporaryTexture(
2038
+ texW,
2039
+ texH,
2040
+ groupExcludes,
2041
+ );
2042
+ const groupView = groupTex.createView();
2043
+
2044
+ const groupClearPass = ctx.renderer.beginFrame(
2045
+ encoder,
2046
+ groupView,
2047
+ { r: 0, g: 0, b: 0, a: 0 },
2048
+ texW,
2049
+ texH,
2050
+ "clear",
2051
+ );
2052
+ groupClearPass.end();
2053
+
2054
+ const br = Math.min(
2055
+ lop.borderRadius ?? lop.strokeRadius ?? 0,
2056
+ layerW / 2,
2057
+ layerH / 2,
2058
+ );
2059
+
2060
+ if (shouldPaintContainerBackground(kind, lop.background)) {
2061
+ const bgPass = ctx.renderer.beginFrame(
2062
+ encoder,
2063
+ groupView,
2064
+ { r: 0, g: 0, b: 0, a: 0 },
2065
+ texW,
2066
+ texH,
2067
+ "load",
2068
+ );
2069
+ ctx.renderer.drawRect(
2070
+ bgPass,
2071
+ { x: 0, y: 0, width: layerW, height: layerH },
2072
+ lop.background,
2073
+ 0,
2074
+ { opacity: 1 },
2075
+ );
2076
+ bgPass.end();
2077
+ }
2078
+
2079
+ let activeGroupTex = groupTex;
2080
+ let activeGroupView = groupView;
2081
+ if (nodeVM.children && nodeVM.children.length > 0) {
2082
+ const res = await renderNodeTree(
2083
+ nodeVM.children,
2084
+ groupTex,
2085
+ groupView,
2086
+ [groupTex, ...groupExcludes],
2087
+ 1.0,
2088
+ new DOMMatrix(),
2089
+ rect,
2090
+ texW,
2091
+ texH,
2092
+ null,
2093
+ false,
2094
+ );
2095
+ activeGroupTex = res.finalTex;
2096
+ activeGroupView = res.finalView;
2097
+ }
2098
+
2099
+ const borderResult =
2100
+ !isTrue3D && lop.borderWidth !== undefined && lop.borderColor
2101
+ ? createBorderTexture(
2102
+ ctx,
2103
+ encoder,
2104
+ layerW,
2105
+ layerH,
2106
+ br,
2107
+ lop.borderWidth,
2108
+ lop.borderColor,
2109
+ lop.strokeAlign ?? "inside",
2110
+ [activeGroupTex, ...groupExcludes],
2111
+ )
2112
+ : null;
2113
+
2114
+ if (borderResult) {
2115
+ const borderPass = ctx.renderer.beginFrame(
2116
+ encoder,
2117
+ activeGroupView,
2118
+ { r: 0, g: 0, b: 0, a: 0 },
2119
+ texW,
2120
+ texH,
2121
+ "load",
2122
+ );
2123
+ ctx.renderer.drawTexture(
2124
+ borderPass,
2125
+ borderResult.borderTex,
2126
+ {
2127
+ x: borderResult.ox,
2128
+ y: borderResult.oy,
2129
+ width: borderResult.ow,
2130
+ height: borderResult.oh,
2131
+ },
2132
+ { opacity: 1 },
2133
+ );
2134
+ borderPass.end();
2135
+ }
2136
+
2137
+ let modelMatrix: Mat4 | undefined;
2138
+ if (parent3DMatrix) {
2139
+ let localM = Matrix4Math.identity();
2140
+ const childX = target?.x ?? lop.x ?? 0;
2141
+ const childY = target?.y ?? lop.y ?? 0;
2142
+ const childZ = target?.z ?? lop.z ?? 0;
2143
+ localM = Matrix4Math.multiply(
2144
+ localM,
2145
+ Matrix4Math.translate(childX, childY, childZ),
2146
+ );
2147
+ const rxRad =
2148
+ ((target?.rotateX ?? lop.rotateX ?? 0) * Math.PI) / 180;
2149
+ const ryRad =
2150
+ ((target?.rotateY ?? lop.rotateY ?? 0) * Math.PI) / 180;
2151
+ const rzRad =
2152
+ ((target?.rotateZ ?? lop.rotateZ ?? 0) !== 0
2153
+ ? (target?.rotateZ ?? lop.rotateZ ?? 0)
2154
+ : rotation) *
2155
+ (Math.PI / 180);
2156
+ if (rzRad !== 0)
2157
+ localM = Matrix4Math.multiply(localM, Matrix4Math.rotateZ(rzRad));
2158
+ if (ryRad !== 0)
2159
+ localM = Matrix4Math.multiply(localM, Matrix4Math.rotateY(ryRad));
2160
+ if (rxRad !== 0)
2161
+ localM = Matrix4Math.multiply(localM, Matrix4Math.rotateX(rxRad));
2162
+
2163
+ const sx = layerW * scale;
2164
+ const sy = layerH * scale;
2165
+ const sz = target?.scaleZ ?? lop.scaleZ ?? 1;
2166
+ localM = Matrix4Math.multiply(
2167
+ localM,
2168
+ Matrix4Math.scale(sx, sy, sz),
2169
+ );
2170
+
2171
+ const ax = lop.anchorX ?? 0.5;
2172
+ const ay = lop.anchorY ?? 0.5;
2173
+ const pivotX = 0.5 - ax;
2174
+ const pivotY = 0.5 - ay;
2175
+ if (pivotX !== 0 || pivotY !== 0) {
2176
+ localM = Matrix4Math.multiply(
2177
+ localM,
2178
+ Matrix4Math.translate(pivotX, pivotY, 0),
2179
+ );
2180
+ }
2181
+
2182
+ modelMatrix = Matrix4Math.multiply(parent3DMatrix, localM);
2183
+ }
2184
+
2185
+ const quadOpts: DrawQuad3DOpts = {
2186
+ position: [
2187
+ localX + layerW * 0.5,
2188
+ localY + layerH * 0.5,
2189
+ target?.z ?? lop.z ?? 0,
2190
+ ],
2191
+ width: layerW,
2192
+ height: layerH,
2193
+ textureExtent: [
2194
+ layerW / activeGroupTex.width,
2195
+ layerH / activeGroupTex.height,
2196
+ ],
2197
+ rotation: [
2198
+ target?.rotateX ?? lop.rotateX ?? 0,
2199
+ target?.rotateY ?? lop.rotateY ?? 0,
2200
+ (target?.rotateZ ?? lop.rotateZ ?? 0) !== 0
2201
+ ? (target?.rotateZ ?? lop.rotateZ ?? 0)
2202
+ : rotation,
2203
+ ],
2204
+ scale: [scale, scale, target?.scaleZ ?? lop.scaleZ ?? 1],
2205
+ anchor: [lop.anchorX ?? 0.5, lop.anchorY ?? 0.5],
2206
+ borderRadius: br,
2207
+ borderWidth: lop.borderWidth,
2208
+ borderColor: lop.borderColor,
2209
+ opacity: layerOpacity,
2210
+ twoSided: target?.twoSided ?? lop.twoSided ?? true,
2211
+ material: target?.material ?? lop.material,
2212
+ shininess: target?.shininess ?? lop.shininess,
2213
+ roughness: target?.roughness ?? lop.roughness,
2214
+ specularIntensity:
2215
+ target?.specularIntensity ?? lop.specularIntensity,
2216
+ ambientIntensity: target?.ambientIntensity ?? lop.ambientIntensity,
2217
+ metallic: target?.metallic ?? lop.metallic,
2218
+ ...(modelMatrix && { modelMatrix }),
2219
+ };
2220
+
2221
+ pending3DItems.push({
2222
+ kind: "quad",
2223
+ texture: activeGroupTex,
2224
+ opts: quadOpts,
2225
+ });
2226
+ return { finalTex: activeTex, finalView: activeView };
2227
+ }
2228
+
2229
+ if (is3D && quad3D) {
2230
+ const texW = Math.max(1, Math.ceil(layerW));
2231
+ const texH = Math.max(1, Math.ceil(layerH));
2232
+ const groupExcludes = [activeTex, targetTexture, ...excludes];
2233
+ const groupTex = ctx.renderer.getTemporaryTexture(
2234
+ texW,
2235
+ texH,
2236
+ groupExcludes,
2237
+ );
2238
+ const groupView = groupTex.createView();
2239
+
2240
+ const groupClearPass = ctx.renderer.beginFrame(
2241
+ encoder,
2242
+ groupView,
2243
+ { r: 0, g: 0, b: 0, a: 0 },
2244
+ texW,
2245
+ texH,
2246
+ "clear",
2247
+ );
2248
+ groupClearPass.end();
2249
+
2250
+ const br = Math.min(
2251
+ lop.borderRadius ?? lop.strokeRadius ?? 0,
2252
+ layerW / 2,
2253
+ layerH / 2,
2254
+ );
2255
+
2256
+ if (shouldPaintContainerBackground(kind, lop.background)) {
2257
+ const bgPass = ctx.renderer.beginFrame(
2258
+ encoder,
2259
+ groupView,
2260
+ { r: 0, g: 0, b: 0, a: 0 },
2261
+ texW,
2262
+ texH,
2263
+ "load",
2264
+ );
2265
+ ctx.renderer.drawRect(
2266
+ bgPass,
2267
+ { x: 0, y: 0, width: layerW, height: layerH },
2268
+ lop.background,
2269
+ br,
2270
+ { opacity: 1 },
2271
+ );
2272
+ bgPass.end();
2273
+ }
2274
+
2275
+ let activeGroupTex = groupTex;
2276
+ let activeGroupView = groupView;
2277
+ if (nodeVM.children && nodeVM.children.length > 0) {
2278
+ const res = await renderNodeTree(
2279
+ nodeVM.children,
2280
+ groupTex,
2281
+ groupView,
2282
+ [groupTex, ...groupExcludes],
2283
+ 1.0,
2284
+ new DOMMatrix(),
2285
+ rect,
2286
+ texW,
2287
+ texH,
2288
+ null,
2289
+ false,
2290
+ );
2291
+ activeGroupTex = res.finalTex;
2292
+ activeGroupView = res.finalView;
2293
+ }
2294
+
2295
+ const borderResult =
2296
+ lop.borderWidth !== undefined && lop.borderColor
2297
+ ? createBorderTexture(
2298
+ ctx,
2299
+ encoder,
2300
+ layerW,
2301
+ layerH,
2302
+ br,
2303
+ lop.borderWidth,
2304
+ lop.borderColor,
2305
+ lop.strokeAlign ?? "inside",
2306
+ [activeGroupTex, ...groupExcludes],
2307
+ )
2308
+ : null;
2309
+
2310
+ if (borderResult) {
2311
+ const borderPass = ctx.renderer.beginFrame(
2312
+ encoder,
2313
+ activeGroupView,
2314
+ { r: 0, g: 0, b: 0, a: 0 },
2315
+ texW,
2316
+ texH,
2317
+ "load",
2318
+ );
2319
+ ctx.renderer.drawTexture(
2320
+ borderPass,
2321
+ borderResult.borderTex,
2322
+ {
2323
+ x: borderResult.ox,
2324
+ y: borderResult.oy,
2325
+ width: borderResult.ow,
2326
+ height: borderResult.oh,
2327
+ },
2328
+ { opacity: 1 },
2329
+ );
2330
+ borderPass.end();
2331
+ }
2332
+
2333
+ const container3DShutter =
2334
+ target?.motionBlurShutter ?? lop.motionBlurShutter ?? 0;
2335
+ if (container3DShutter > 0 && fps && shiftedFrame !== undefined) {
2336
+ const currentT = shiftedFrame / fps;
2337
+ const prevT = (shiftedFrame - 1) / fps;
2338
+ tl.seek(prevT);
2339
+ const prevTarget = targetId ? targetsById[targetId] : undefined;
2340
+ const prevX = prevTarget?.x ?? baseX;
2341
+ const prevY = prevTarget?.y ?? baseY;
2342
+ tl.seek(currentT);
2343
+
2344
+ const vx = sampledX - prevX;
2345
+ const vy = sampledY - prevY;
2346
+
2347
+ if (Math.hypot(vx, vy) > 0.5) {
2348
+ activeGroupTex = ctx.renderer.applyMotionBlur(
2349
+ encoder,
2350
+ activeGroupTex,
2351
+ vx,
2352
+ vy,
2353
+ container3DShutter,
2354
+ 16,
2355
+ [activeTex, targetTexture, ...excludes],
2356
+ );
2357
+ activeGroupView = activeGroupTex.createView();
2358
+ }
2359
+ }
2360
+
2361
+ if (blendMode === "normal" || blendMode === "source-over") {
2362
+ drawProjectedQuad(
2363
+ ctx,
2364
+ encoder,
2365
+ activeView,
2366
+ activeGroupTex,
2367
+ quad3D,
2368
+ targetW,
2369
+ targetH,
2370
+ layerOpacity,
2371
+ );
2372
+ } else {
2373
+ const compositeExcludes = [
2374
+ activeTex,
2375
+ targetTexture,
2376
+ activeGroupTex,
2377
+ ...excludes,
2378
+ ];
2379
+ if (borderResult) compositeExcludes.push(borderResult.borderTex);
2380
+ const layerTex = ctx.renderer.getTemporaryTexture(
2381
+ targetW,
2382
+ targetH,
2383
+ compositeExcludes,
2384
+ );
2385
+ const layerView = layerTex.createView();
2386
+ const layerClearPass = ctx.renderer.beginFrame(
2387
+ encoder,
2388
+ layerView,
2389
+ { r: 0, g: 0, b: 0, a: 0 },
2390
+ targetW,
2391
+ targetH,
2392
+ "clear",
2393
+ );
2394
+ layerClearPass.end();
2395
+
2396
+ drawProjectedQuad(
2397
+ ctx,
2398
+ encoder,
2399
+ layerView,
2400
+ activeGroupTex,
2401
+ quad3D,
2402
+ targetW,
2403
+ targetH,
2404
+ layerOpacity,
2405
+ );
2406
+
2407
+ activeTex = ctx.renderer.composite(
2408
+ encoder,
2409
+ activeTex,
2410
+ layerTex,
2411
+ blendMode,
2412
+ [targetTexture, ...excludes],
2413
+ );
2414
+ activeView = activeTex.createView();
2415
+ }
2416
+
2417
+ return { finalTex: activeTex, finalView: activeView };
2418
+ }
2419
+ const shouldClip = lop.overflow !== "visible";
2420
+ const scissorRect = shouldClip
2421
+ ? computeContainerScissor(layerMatrix, layerW, layerH)
2422
+ : null;
2423
+ // A scissor clips to the box; rounded corners need a mask pass.
2424
+ const clipRadius = shouldClip
2425
+ ? Math.min(
2426
+ target?.borderRadius ?? lop.borderRadius ?? 0,
2427
+ layerW / 2,
2428
+ layerH / 2,
2429
+ )
2430
+ : 0;
2431
+
2432
+ // Transparent optimized flex/block — only when there is no
2433
+ // background to paint (a flex/block with a background takes the
2434
+ // group-texture path below).
2435
+ if (
2436
+ (kind === "flex" || kind === "block") &&
2437
+ clipRadius <= 0 &&
2438
+ !shouldPaintContainerBackground(kind, lop.background) &&
2439
+ layerOpacity === 1 &&
2440
+ blendMode === "normal" &&
2441
+ !lop.borderWidth &&
2442
+ !lop.borderColor &&
2443
+ (typeof lop.relighting !== "object" || !lop.relighting) &&
2444
+ (typeof lop.relight !== "object" || !lop.relight) &&
2445
+ (typeof target?.relighting !== "object" || !target?.relighting) &&
2446
+ (typeof target?.relight !== "object" || !target?.relight) &&
2447
+ (typeof lop.deflicker !== "object" || !lop.deflicker) &&
2448
+ (typeof target?.deflicker !== "object" || !target?.deflicker)
2449
+ ) {
2450
+ if (scissorRect) {
2451
+ ctx.renderer.pushScissor(scissorRect);
2452
+ }
2453
+ const res = await renderNodeTree(
2454
+ nodeVM.children ?? [],
2455
+ activeTex,
2456
+ activeView,
2457
+ excludes,
2458
+ accumulatedOpacity,
2459
+ layerMatrix,
2460
+ rect,
2461
+ targetW,
2462
+ targetH,
2463
+ null,
2464
+ isRootScene,
2465
+ );
2466
+ if (scissorRect) {
2467
+ ctx.renderer.popScissor();
2468
+ }
2469
+ return res;
2470
+ }
2471
+
2472
+ // Otherwise, draw container background + children + border onto a temporary group texture,
2473
+ // and composite/blend the group texture onto destTex.
2474
+ const groupExcludes = [activeTex, targetTexture, ...excludes];
2475
+ const groupTex = ctx.renderer.getTemporaryTexture(
2476
+ targetW,
2477
+ targetH,
2478
+ groupExcludes,
2479
+ );
2480
+ const groupView = groupTex.createView();
2481
+
2482
+ const groupClearPass = ctx.renderer.beginFrame(
2483
+ encoder,
2484
+ groupView,
2485
+ { r: 0, g: 0, b: 0, a: 0 },
2486
+ targetW,
2487
+ targetH,
2488
+ "clear",
2489
+ );
2490
+ groupClearPass.end();
2491
+
2492
+ const br = Math.min(
2493
+ target?.borderRadius ?? lop.borderRadius ?? lop.strokeRadius ?? 0,
2494
+ layerW / 2,
2495
+ layerH / 2,
2496
+ );
2497
+
2498
+ // Background fill pass — any container kind (flex/block/box).
2499
+ if (shouldPaintContainerBackground(kind, lop.background)) {
2500
+ const bgPass = ctx.renderer.beginFrame(
2501
+ encoder,
2502
+ groupView,
2503
+ { r: 0, g: 0, b: 0, a: 0 },
2504
+ targetW,
2505
+ targetH,
2506
+ "load",
2507
+ );
2508
+ ctx.renderer.pushTransform(layerMatrix);
2509
+ ctx.renderer.drawRect(
2510
+ bgPass,
2511
+ { x: 0, y: 0, width: layerW, height: layerH },
2512
+ lop.background,
2513
+ br,
2514
+ { opacity: layerOpacity },
2515
+ );
2516
+ ctx.renderer.popTransform();
2517
+ bgPass.end();
2518
+ }
2519
+
2520
+ let activeGroupTex = groupTex;
2521
+ let activeGroupView = groupView;
2522
+ if (nodeVM.children && nodeVM.children.length > 0) {
2523
+ if (scissorRect) {
2524
+ ctx.renderer.pushScissor(scissorRect);
2525
+ }
2526
+ const res = await renderNodeTree(
2527
+ nodeVM.children,
2528
+ groupTex,
2529
+ groupView,
2530
+ [groupTex, ...groupExcludes],
2531
+ layerOpacity,
2532
+ layerMatrix,
2533
+ rect,
2534
+ targetW,
2535
+ targetH,
2536
+ null,
2537
+ false,
2538
+ );
2539
+ if (scissorRect) {
2540
+ ctx.renderer.popScissor();
2541
+ }
2542
+ activeGroupTex = res.finalTex;
2543
+ activeGroupView = res.finalView;
2544
+ }
2545
+
2546
+ // overflow: hidden with a border radius: keep only what falls
2547
+ // inside the rounded box (background and children alike).
2548
+ if (clipRadius > 0) {
2549
+ const maskTex = ctx.renderer.getTemporaryTexture(targetW, targetH, [
2550
+ activeGroupTex,
2551
+ ...groupExcludes,
2552
+ ]);
2553
+ const maskPass = ctx.renderer.beginFrame(
2554
+ encoder,
2555
+ maskTex.createView(),
2556
+ { r: 0, g: 0, b: 0, a: 0 },
2557
+ targetW,
2558
+ targetH,
2559
+ "clear",
2560
+ );
2561
+ ctx.renderer.pushTransform(layerMatrix);
2562
+ ctx.renderer.drawRect(
2563
+ maskPass,
2564
+ { x: 0, y: 0, width: layerW, height: layerH },
2565
+ "#ffffff",
2566
+ clipRadius,
2567
+ );
2568
+ ctx.renderer.popTransform();
2569
+ maskPass.end();
2570
+ activeGroupTex = ctx.renderer.composite(
2571
+ encoder,
2572
+ activeGroupTex,
2573
+ maskTex,
2574
+ "mask-in",
2575
+ [activeGroupTex, maskTex, ...groupExcludes],
2576
+ );
2577
+ activeGroupView = activeGroupTex.createView();
2578
+ }
2579
+
2580
+ const borderResult =
2581
+ lop.borderWidth !== undefined && lop.borderColor
2582
+ ? createBorderTexture(
2583
+ ctx,
2584
+ encoder,
2585
+ layerW,
2586
+ layerH,
2587
+ br,
2588
+ lop.borderWidth,
2589
+ lop.borderColor,
2590
+ lop.strokeAlign ?? "inside",
2591
+ [activeGroupTex, ...groupExcludes],
2592
+ )
2593
+ : null;
2594
+
2595
+ if (borderResult) {
2596
+ const borderPass = ctx.renderer.beginFrame(
2597
+ encoder,
2598
+ activeGroupView,
2599
+ { r: 0, g: 0, b: 0, a: 0 },
2600
+ targetW,
2601
+ targetH,
2602
+ "load",
2603
+ );
2604
+ ctx.renderer.pushTransform(layerMatrix);
2605
+ ctx.renderer.drawTexture(
2606
+ borderPass,
2607
+ borderResult.borderTex,
2608
+ {
2609
+ x: borderResult.ox,
2610
+ y: borderResult.oy,
2611
+ width: borderResult.ow,
2612
+ height: borderResult.oh,
2613
+ },
2614
+ { opacity: layerOpacity },
2615
+ );
2616
+ ctx.renderer.popTransform();
2617
+ borderPass.end();
2618
+ }
2619
+
2620
+ const containerShutter =
2621
+ target?.motionBlurShutter ?? lop.motionBlurShutter ?? 0;
2622
+ if (containerShutter > 0 && fps && shiftedFrame !== undefined) {
2623
+ const currentT = shiftedFrame / fps;
2624
+ const prevT = (shiftedFrame - 1) / fps;
2625
+ tl.seek(prevT);
2626
+ const prevTarget = targetId ? targetsById[targetId] : undefined;
2627
+ const prevX = prevTarget?.x ?? baseX;
2628
+ const prevY = prevTarget?.y ?? baseY;
2629
+ tl.seek(currentT);
2630
+
2631
+ const vx = sampledX - prevX;
2632
+ const vy = sampledY - prevY;
2633
+
2634
+ if (Math.hypot(vx, vy) > 0.5) {
2635
+ activeGroupTex = ctx.renderer.applyMotionBlur(
2636
+ encoder,
2637
+ activeGroupTex,
2638
+ vx,
2639
+ vy,
2640
+ containerShutter,
2641
+ 16,
2642
+ [activeTex, targetTexture, ...excludes],
2643
+ );
2644
+ activeGroupView = activeGroupTex.createView();
2645
+ }
2646
+ }
2647
+
2648
+ // Temporal De-flickering & Optical Flow Warping
2649
+ const deflickerFromEffects = Array.isArray(lop.effects)
2650
+ ? lop.effects.find(
2651
+ (e: unknown) =>
2652
+ (e as { op?: string })?.op === "TemporalDeflicker" ||
2653
+ (e as { op?: string })?.op === "Deflicker" ||
2654
+ (e as { constructor?: { name?: string } })?.constructor
2655
+ ?.name === "TemporalDeflicker",
2656
+ )
2657
+ : undefined;
2658
+ const rawDeflicker =
2659
+ target?.deflicker ?? lop.deflicker ?? deflickerFromEffects;
2660
+ const deflickerOpts =
2661
+ typeof rawDeflicker === "object" && rawDeflicker !== null
2662
+ ? rawDeflicker
2663
+ : undefined;
2664
+ if (deflickerOpts) {
2665
+ const deflickerPipe = getTemporalDeflickerPipeline(
2666
+ ctx.device,
2667
+ lop.id || `layer_${i}`,
2668
+ );
2669
+ if (shiftedFrame === 0) {
2670
+ deflickerPipe.reset();
2671
+ }
2672
+ activeGroupTex = deflickerPipe.execute(
2673
+ activeGroupTex,
2674
+ deflickerOpts,
2675
+ encoder,
2676
+ );
2677
+ activeGroupView = activeGroupTex.createView();
2678
+ }
2679
+
2680
+ // Screen-Space 3D Relighting
2681
+ const relightEffect = Array.isArray(lop.effects)
2682
+ ? lop.effects.find(
2683
+ (e: unknown) =>
2684
+ (e as { op?: string })?.op === "Relight3D" ||
2685
+ (e as { constructor?: { name?: string } })?.constructor
2686
+ ?.name === "Relight3D",
2687
+ )
2688
+ : undefined;
2689
+ const rawRelight =
2690
+ target?.relighting ??
2691
+ lop.relighting ??
2692
+ (typeof lop.relight === "object" ? lop.relight : undefined) ??
2693
+ relightEffect;
2694
+ const relightOpts =
2695
+ typeof rawRelight === "object" && rawRelight !== null
2696
+ ? rawRelight
2697
+ : undefined;
2698
+ if (relightOpts) {
2699
+ const relightPipeline = getScreenSpaceRelightPipeline(
2700
+ ctx.device,
2701
+ ctx.renderer.format ?? "rgba8unorm",
2702
+ );
2703
+ const r3dInstance = getRenderer3D(
2704
+ ctx.device,
2705
+ ctx.renderer.format ?? "rgba8unorm",
2706
+ sampleCount3d,
2707
+ );
2708
+
2709
+ const activeLights = synthesizeRelightLights(
2710
+ relightOpts as Record<string, unknown>,
2711
+ sceneLights,
2712
+ rect,
2713
+ shiftedFrame,
2714
+ fps ?? 24,
2715
+ );
2716
+
2717
+ const lightsBuffer =
2718
+ r3dInstance.getOrCreateLightsBuffer(activeLights);
2719
+
2720
+ const relitTex = ctx.renderer.getTemporaryTexture(targetW, targetH, [
2721
+ activeGroupTex,
2722
+ activeTex,
2723
+ targetTexture,
2724
+ ...excludes,
2725
+ ]);
2726
+ const relitView = relitTex.createView();
2727
+
2728
+ const normalTex = relightOpts.normalTexture ?? relightOpts.normalMap;
2729
+
2730
+ relightPipeline.execute(
2731
+ encoder,
2732
+ relitView,
2733
+ activeGroupTex,
2734
+ normalTex,
2735
+ lightsBuffer,
2736
+ {
2737
+ viewportWidth: targetW,
2738
+ viewportHeight: targetH,
2739
+ layerX: 0,
2740
+ layerY: 0,
2741
+ roughness: resolveVal(
2742
+ target?.roughness ??
2743
+ target?.relightRoughness ??
2744
+ relightOpts.specularRoughness ??
2745
+ relightOpts.roughness,
2746
+ 0.35,
2747
+ shiftedFrame,
2748
+ fps ?? 24,
2749
+ ),
2750
+ specularStrength: resolveVal(
2751
+ target?.specularStrength ??
2752
+ target?.relightSpecularStrength ??
2753
+ relightOpts.specularStrength,
2754
+ 0.7,
2755
+ shiftedFrame,
2756
+ fps ?? 24,
2757
+ ),
2758
+ metallic: resolveVal(
2759
+ target?.metallic ??
2760
+ target?.relightMetallic ??
2761
+ relightOpts.metallic,
2762
+ 0.0,
2763
+ shiftedFrame,
2764
+ fps ?? 24,
2765
+ ),
2766
+ ambientIntensity: resolveVal(
2767
+ target?.ambientIntensity ??
2768
+ target?.relightAmbientIntensity ??
2769
+ relightOpts.ambientIntensity,
2770
+ 0.15,
2771
+ shiftedFrame,
2772
+ fps ?? 24,
2773
+ ),
2774
+ depthScale: resolveVal(
2775
+ target?.depthScale ??
2776
+ target?.relightDepthScale ??
2777
+ relightOpts.depthScale,
2778
+ 1.0,
2779
+ shiftedFrame,
2780
+ fps ?? 24,
2781
+ ),
2782
+ opacity: 1.0,
2783
+ },
2784
+ "clear",
2785
+ );
2786
+
2787
+ activeGroupTex = relitTex;
2788
+ activeGroupView = relitView;
2789
+ }
2790
+
2791
+ // Always composite container groups using the composite shader
2792
+ const compositeExcludes = [activeTex, targetTexture, ...excludes];
2793
+ if (borderResult) compositeExcludes.push(borderResult.borderTex);
2794
+ const nextDestTex = ctx.renderer.composite(
2795
+ encoder,
2796
+ activeTex,
2797
+ activeGroupTex,
2798
+ blendMode,
2799
+ compositeExcludes,
2800
+ );
2801
+ activeTex = nextDestTex;
2802
+ activeView = activeTex.createView();
2803
+
2804
+ return { finalTex: activeTex, finalView: activeView };
2805
+ }
2806
+
2807
+ // Leaf layers (text/media)
2808
+ if (!(layerW > 0 && layerH > 0))
2809
+ return { finalTex: activeTex, finalView: activeView };
2810
+
2811
+ const br = Math.min(
2812
+ lop.borderRadius ?? lop.strokeRadius ?? 0,
2813
+ layerW / 2,
2814
+ layerH / 2,
2815
+ );
2816
+ const texW = Math.ceil(layerW);
2817
+ const texH = Math.ceil(layerH);
2818
+
2819
+ // Suspend any parent container scissors while rendering isolated offscreen localTex
2820
+ const savedScissors = ctx.renderer.suspendScissors?.() ?? [];
2821
+
2822
+ const localTex = ctx.renderer.getTemporaryTexture(texW, texH, [
2823
+ activeTex,
2824
+ targetTexture,
2825
+ ...excludes,
2826
+ ]);
2827
+ const localView = localTex.createView();
2828
+
2829
+ const localClearPass = ctx.renderer.beginFrame(
2830
+ encoder,
2831
+ localView,
2832
+ { r: 0, g: 0, b: 0, a: 0 },
2833
+ texW,
2834
+ texH,
2835
+ "clear",
2836
+ );
2837
+ localClearPass.end();
2838
+
2839
+ // Content: media → bound source VM; text → synthetic text op.
2840
+ let content: VirtualMediaData | undefined;
2841
+ let isCaption = false;
2842
+ if (kind === "media") {
2843
+ content = nodeVM.children?.[0];
2844
+ isCaption =
2845
+ content?.operation?.dataType === "Caption" ||
2846
+ lop.dataType === "Caption" ||
2847
+ (content?.operation as any)?.srtText !== undefined;
2848
+ } else if (kind === "text") {
2849
+ const fullText = lop.text ?? "";
2850
+ let displayText = fullText;
2851
+
2852
+ // Kinetic typography animators (Section 5)
2853
+ let resolvedAnimators = rawAnimators;
2854
+ if (Array.isArray(rawAnimators) && rawAnimators.length > 0) {
2855
+ resolvedAnimators = rawAnimators.map((animator: any, idx: number) => {
2856
+ const a = {
2857
+ ...animator,
2858
+ transform: { ...(animator.transform ?? {}) },
2859
+ };
2860
+ if (idx === 0) {
2861
+ const offsetVal =
2862
+ typeof target?.offset === "number"
2863
+ ? target.offset
2864
+ : typeof target?.animatorOffset === "number"
2865
+ ? target.animatorOffset
2866
+ : undefined;
2867
+ const rangeStartVal =
2868
+ typeof target?.rangeStart === "number"
2869
+ ? target.rangeStart
2870
+ : typeof target?.animatorRangeStart === "number"
2871
+ ? target.animatorRangeStart
2872
+ : undefined;
2873
+ const rangeEndVal =
2874
+ typeof target?.rangeEnd === "number"
2875
+ ? target.rangeEnd
2876
+ : typeof target?.animatorRangeEnd === "number"
2877
+ ? target.animatorRangeEnd
2878
+ : undefined;
2879
+
2880
+ if (offsetVal !== undefined) a.offset = offsetVal;
2881
+ if (rangeStartVal !== undefined) a.rangeStart = rangeStartVal;
2882
+ if (rangeEndVal !== undefined) a.rangeEnd = rangeEndVal;
2883
+
2884
+ if (Array.isArray(a.selectors)) {
2885
+ a.selectors = a.selectors.map((s: any) => {
2886
+ if (s.type === "range") {
2887
+ return {
2888
+ ...s,
2889
+ ...(offsetVal !== undefined ? { offset: offsetVal } : {}),
2890
+ ...(rangeStartVal !== undefined
2891
+ ? { start: rangeStartVal }
2892
+ : {}),
2893
+ ...(rangeEndVal !== undefined
2894
+ ? { end: rangeEndVal }
2895
+ : {}),
2896
+ };
2897
+ }
2898
+ return s;
2899
+ });
2900
+ }
2901
+ }
2902
+ return a;
2903
+ });
2904
+ }
2905
+
2906
+ const hasKineticAnimators =
2907
+ Array.isArray(resolvedAnimators) && resolvedAnimators.length > 0;
2908
+ const hasTypewriter = Boolean(lop.typewriter || target?.typewriter);
2909
+
2910
+ // Check if there is an active text track or progress value evaluated by GSAP
2911
+ const textTrack = lop.animation?.tracks?.find(
2912
+ (t: { prop: string }) => t.prop === "text",
2913
+ );
2914
+ const hasTextAnimation =
2915
+ !hasKineticAnimators &&
2916
+ !hasTypewriter &&
2917
+ (textTrack !== undefined || typeof target?.text === "number");
2918
+
2919
+ if (hasTextAnimation && fullText.length > 0) {
2920
+ const progress =
2921
+ typeof target?.text === "number"
2922
+ ? Math.max(0, Math.min(1, target.text))
2923
+ : 1;
2924
+
2925
+ if (progress >= 1) {
2926
+ displayText = fullText;
2927
+ } else if (progress <= 0) {
2928
+ displayText = "";
2929
+ } else {
2930
+ // Determine preset style (default to typewriter)
2931
+ const presetType =
2932
+ textTrack?.keyframes?.find(
2933
+ (k: { presetType?: string }) => k.presetType,
2934
+ )?.presetType ?? "typewriter";
2935
+
2936
+ const textNumeric =
2937
+ typeof target?.text === "number" ? target.text : null;
2938
+ const isDirectCount = textNumeric !== null && textNumeric > 1.0001;
2939
+
2940
+ if (presetType === "word-reveal" || presetType === "karaoke") {
2941
+ const tokens = fullText.split(/(\s+)/);
2942
+ const wordIndices: number[] = [];
2943
+ tokens.forEach((token: string, idx: number) => {
2944
+ if (token.trim().length > 0) wordIndices.push(idx);
2945
+ });
2946
+ const visibleWordCount =
2947
+ isDirectCount && textNumeric !== null
2948
+ ? Math.min(wordIndices.length, Math.round(textNumeric))
2949
+ : Math.round(wordIndices.length * progress);
2950
+ if (visibleWordCount === 0) {
2951
+ displayText = "";
2952
+ } else {
2953
+ const lastTokenIdx =
2954
+ wordIndices[
2955
+ Math.min(wordIndices.length - 1, visibleWordCount - 1)
2956
+ ]!;
2957
+ displayText = tokens.slice(0, lastTokenIdx + 1).join("");
2958
+ }
2959
+ } else if (presetType === "line-reveal") {
2960
+ const lines = fullText.split("\n");
2961
+ const visibleLineCount =
2962
+ isDirectCount && textNumeric !== null
2963
+ ? Math.min(lines.length, Math.round(textNumeric))
2964
+ : Math.round(lines.length * progress);
2965
+ displayText = lines
2966
+ .slice(0, Math.max(0, visibleLineCount))
2967
+ .join("\n");
2968
+ } else {
2969
+ // typewriter (character-level reveal)
2970
+ const visibleChars =
2971
+ isDirectCount && textNumeric !== null
2972
+ ? Math.min(fullText.length, Math.round(textNumeric))
2973
+ : Math.round(fullText.length * progress);
2974
+ displayText = fullText.slice(0, visibleChars);
2975
+ }
2976
+ }
2977
+ }
2978
+
2979
+ content = {
2980
+ metadata: {},
2981
+ operation: {
2982
+ op: "text",
2983
+ text: displayText,
2984
+ animators: resolvedAnimators,
2985
+ pathOptions: target?.pathOptions ?? lop.pathOptions,
2986
+ typewriter: target?.typewriter ?? lop.typewriter,
2987
+ marquee: target?.marquee ?? (lop as { marquee?: unknown }).marquee,
2988
+ textProgress:
2989
+ typeof target?.text === "number" ? target.text : undefined,
2990
+ firstMargin: target?.firstMargin ?? lop.firstMargin,
2991
+ lastMargin: target?.lastMargin ?? lop.lastMargin,
2992
+ baselineShift: target?.baselineShift ?? lop.baselineShift,
2993
+ signals:
2994
+ (virtualMedia as any)?.operation?.signals ??
2995
+ (virtualMedia as any)?.signals,
2996
+ fontSize: target?.fontSize ?? lop.fontSize,
2997
+ fontFamily: lop.fontFamily,
2998
+ fontStyle: lop.fontStyle,
2999
+ fontWeight: lop.fontWeight,
3000
+ fill: target?.fill ?? target?.color ?? lop.fill,
3001
+ align:
3002
+ lop.align === "start"
3003
+ ? "left"
3004
+ : lop.align === "end"
3005
+ ? "right"
3006
+ : lop.align,
3007
+ verticalAlign: lop.verticalAlign,
3008
+ letterSpacing: target?.letterSpacing ?? lop.letterSpacing,
3009
+ lineHeight: lop.lineHeight,
3010
+ padding: lop.padding,
3011
+ textBackgroundColor:
3012
+ target?.textBackgroundColor ??
3013
+ target?.background ??
3014
+ lop.background,
3015
+ borderRadius: lop.borderRadius,
3016
+ stroke: target?.stroke ?? target?.strokeColor ?? lop.stroke,
3017
+ strokeWidth: target?.strokeWidth ?? lop.strokeWidth,
3018
+ strokeAlign: lop.strokeAlign,
3019
+ textShadow: lop.textShadow,
3020
+ shadows: lop.shadows,
3021
+ x: isPathText ? 0 : padX,
3022
+ y: isPathText ? 0 : padY,
3023
+ width: isPathText
3024
+ ? targetW
3025
+ : lop.width !== undefined
3026
+ ? rect.width
3027
+ : undefined,
3028
+ height: isPathText
3029
+ ? targetH
3030
+ : lop.height !== undefined
3031
+ ? rect.height
3032
+ : undefined,
3033
+ },
3034
+ children: [],
3035
+ } as unknown as VirtualMediaData;
3036
+ }
3037
+
3038
+ // A true-3D text layer hands its glyphs to the 3D pass as outlines, to
3039
+ // be resolved per screen pixel on its plane: a texture of the text
3040
+ // blurs up close and aliases at a glancing angle. Whatever else the
3041
+ // layer draws (a backdrop, emoji) still lands in its texture.
3042
+ let glyphBatches: SlugGlyphBatch[] | null = null;
3043
+ let layerHasRaster = true;
3044
+ if (content) {
3045
+ const contentMeta = getActiveMediaMetadata(content);
3046
+ const contentDurationMs = contentMeta?.durationMs ?? composeDuration;
3047
+ const layerDurationFrames = Math.max(
3048
+ 1,
3049
+ Math.round((contentDurationMs / 1000) * (fps ?? 24)),
3050
+ );
3051
+ const layerDurationInMS = (layerDurationFrames / (fps ?? 24)) * 1000;
3052
+ const contentIsImageCompositor =
3053
+ content.operation?.op === "Compositor" &&
3054
+ content.operation?.dataType === "Image";
3055
+
3056
+ const captureGlyphs = isTrue3D && kind === "text";
3057
+ const rasterDrawsBefore = ctx.renderer.rasterDrawCount;
3058
+ if (captureGlyphs) ctx.renderer.slugPipeline.beginCapture();
3059
+ try {
3060
+ await drawChild(
3061
+ content,
3062
+ {
3063
+ containerWidth: layerW,
3064
+ containerHeight: layerH,
3065
+ renderId: `${props.renderId}-l${i}`,
3066
+ durationMs: layerDurationInMS,
3067
+ opacity: 1.0,
3068
+ fps,
3069
+ // A media layer's effect chain (decode → passes) allocates from the
3070
+ // shared pool: keep it off the destination this layer composites onto,
3071
+ // or the raw frame lands there wherever the clip does not cover it.
3072
+ excludeTextures: [activeTex, targetTexture, ...excludes],
3073
+ isVideoMode: isVideoMode && !contentIsImageCompositor,
3074
+ ...(isVideoMode && {
3075
+ frame:
3076
+ Math.max(0, shiftedFrame - (lop.startFrame ?? 0)) +
3077
+ (lop.mediaStartFrame ??
3078
+ lop.trimStartFrames ??
3079
+ (lop.trimStartSec != null
3080
+ ? Math.round(lop.trimStartSec * (fps ?? 24))
3081
+ : 0)),
3082
+ }),
3083
+ ...(isCaption && {
3084
+ fontFamily: lop.fontFamily,
3085
+ fontSize: target?.fontSize ?? lop.fontSize,
3086
+ fontWeight: lop.fontWeight,
3087
+ fontStyle: lop.fontStyle,
3088
+ fill: lop.fill,
3089
+ color: lop.fill,
3090
+ align:
3091
+ lop.align === "start"
3092
+ ? "left"
3093
+ : lop.align === "end"
3094
+ ? "right"
3095
+ : (lop.align ?? "center"),
3096
+ verticalAlign: lop.verticalAlign ?? lop.textAlignVertical,
3097
+ lineHeight: lop.lineHeight,
3098
+ letterSpacing: lop.letterSpacing,
3099
+ textBackgroundColor: lop.background ?? lop.textBackgroundColor,
3100
+ borderRadius: lop.borderRadius ?? lop.strokeRadius,
3101
+ strokeRadius: lop.strokeRadius ?? lop.borderRadius,
3102
+ stroke: lop.stroke,
3103
+ strokeWidth: lop.strokeWidth,
3104
+ shadows: lop.shadows,
3105
+ padding: lop.padding,
3106
+ maxWidth: lop.maxWidth ?? layerW,
3107
+ }),
3108
+ },
3109
+ localView,
3110
+ localTex,
3111
+ texW,
3112
+ texH,
3113
+ );
3114
+ } finally {
3115
+ if (captureGlyphs) {
3116
+ glyphBatches = ctx.renderer.slugPipeline.endCapture();
3117
+ }
3118
+ }
3119
+ layerHasRaster = ctx.renderer.rasterDrawCount !== rasterDrawsBefore;
3120
+ } else if (kind === "shape") {
3121
+ const shapePass = ctx.renderer.beginFrame(
3122
+ encoder,
3123
+ localView,
3124
+ { r: 0, g: 0, b: 0, a: 0 },
3125
+ texW,
3126
+ texH,
3127
+ "load",
3128
+ );
3129
+
3130
+ const shapeType = lop.shapeType ?? lop.shape ?? "rect";
3131
+ const shapeConfig: ShapeGeometryConfig = {
3132
+ shapeType,
3133
+ width: layerW,
3134
+ height: layerH,
3135
+ path: lop.d ?? lop.path,
3136
+ cornerRadius:
3137
+ target?.cornerRadius ??
3138
+ lop.cornerRadius ??
3139
+ lop.borderRadius ??
3140
+ lop.radius ??
3141
+ 0,
3142
+ starPoints: lop.starPoints ?? 5,
3143
+ starInnerRadiusRatio:
3144
+ target?.starInnerRadiusRatio ?? lop.starInnerRadiusRatio ?? 0.5,
3145
+ polygonSides: lop.polygonSides ?? 5,
3146
+ };
3147
+
3148
+ const fillType = lop.fillType ?? "solid";
3149
+ const fillColor =
3150
+ target?.fillColor ??
3151
+ target?.fill ??
3152
+ lop.fillColor ??
3153
+ lop.fill ??
3154
+ "#38bdf8";
3155
+ const color2 =
3156
+ target?.gradientEndColor ??
3157
+ lop.gradientEndColor ??
3158
+ lop.fillColor2 ??
3159
+ "#9333ea";
3160
+
3161
+ let startX = lop.fillStartX;
3162
+ let startY = lop.fillStartY;
3163
+ let endX = lop.fillEndX;
3164
+ let endY = lop.fillEndY;
3165
+ if (
3166
+ fillType === "linear" &&
3167
+ startX === undefined &&
3168
+ lop.gradientAngle !== undefined
3169
+ ) {
3170
+ const rad = (lop.gradientAngle * Math.PI) / 180;
3171
+ const cx = layerW / 2;
3172
+ const cy = layerH / 2;
3173
+ // Edge to edge along the angle (0° left→right, 90° top→bottom),
3174
+ // matching the shape generator's SVG gradients.
3175
+ const half =
3176
+ (Math.abs(layerW * Math.cos(rad)) +
3177
+ Math.abs(layerH * Math.sin(rad))) /
3178
+ 2;
3179
+ startX = cx - Math.cos(rad) * half;
3180
+ startY = cy - Math.sin(rad) * half;
3181
+ endX = cx + Math.cos(rad) * half;
3182
+ endY = cy + Math.sin(rad) * half;
3183
+ }
3184
+
3185
+ const fillConfig: ShapeFillConfig = {
3186
+ type: fillType,
3187
+ color: fillColor,
3188
+ color2,
3189
+ startX,
3190
+ startY,
3191
+ endX,
3192
+ endY,
3193
+ cx: lop.fillCx,
3194
+ cy: lop.fillCy,
3195
+ radius: lop.fillRadius,
3196
+ };
3197
+
3198
+ const strokeWidth = target?.strokeWidth ?? lop.strokeWidth ?? 0;
3199
+ const strokeColor =
3200
+ target?.strokeColor ?? lop.strokeColor ?? lop.stroke ?? "#ffffff";
3201
+ const strokeLineCap = lop.strokeLineCap ?? "round";
3202
+ const strokeDashArray = lop.strokeDashArray;
3203
+ const strokeDashOffset =
3204
+ target?.strokeDashOffset ?? lop.strokeDashOffset ?? 0;
3205
+ const trimStart = target?.trimStart ?? lop.trimStart ?? 0;
3206
+ const trimEnd = target?.trimEnd ?? lop.trimEnd ?? 1;
3207
+ const trimOffset = target?.trimOffset ?? lop.trimOffset ?? 0;
3208
+
3209
+ ctx.renderer.drawShape(
3210
+ shapePass,
3211
+ shapeConfig,
3212
+ fillConfig,
3213
+ strokeWidth > 0
3214
+ ? {
3215
+ color: strokeColor,
3216
+ width: strokeWidth,
3217
+ cap: strokeLineCap,
3218
+ trimStart,
3219
+ trimEnd,
3220
+ trimOffset,
3221
+ dashArray: strokeDashArray,
3222
+ dashOffset: strokeDashOffset,
3223
+ }
3224
+ : undefined,
3225
+ );
3226
+
3227
+ shapePass.end();
3228
+ }
3229
+
3230
+ // Temporal De-flickering & Optical Flow Warping
3231
+ const leafDeflickerEffect = Array.isArray(lop.effects)
3232
+ ? lop.effects.find(
3233
+ (e: unknown) =>
3234
+ (e as { op?: string })?.op === "TemporalDeflicker" ||
3235
+ (e as { constructor?: { name?: string } })?.constructor?.name ===
3236
+ "TemporalDeflicker",
3237
+ )
3238
+ : undefined;
3239
+ const deflickerOpts =
3240
+ target?.deflicker ?? lop.deflicker ?? leafDeflickerEffect;
3241
+
3242
+ // Screen-Space 3D Relighting
3243
+ const leafRelightEffect = Array.isArray(lop.effects)
3244
+ ? lop.effects.find(
3245
+ (e: unknown) =>
3246
+ (e as { op?: string })?.op === "Relight3D" ||
3247
+ (e as { constructor?: { name?: string } })?.constructor?.name ===
3248
+ "Relight3D",
3249
+ )
3250
+ : undefined;
3251
+ const rawLeafRelight =
3252
+ target?.relighting ??
3253
+ lop.relighting ??
3254
+ (typeof lop.relight === "object" ? lop.relight : undefined) ??
3255
+ leafRelightEffect;
3256
+ const relightOpts =
3257
+ typeof rawLeafRelight === "object" && rawLeafRelight !== null
3258
+ ? rawLeafRelight
3259
+ : undefined;
3260
+
3261
+ // Directional velocity motion blur
3262
+ const shutterAngle =
3263
+ target?.motionBlurShutter ?? lop.motionBlurShutter ?? 0;
3264
+
3265
+ // Effects work on the layer's pixels: glyphs held back for the 3D
3266
+ // pass are drawn into its texture after all.
3267
+ if (
3268
+ glyphBatches &&
3269
+ glyphBatches.length > 0 &&
3270
+ (deflickerOpts || relightOpts || shutterAngle > 0)
3271
+ ) {
3272
+ const glyphPass = ctx.renderer.beginFrame(
3273
+ encoder,
3274
+ localView,
3275
+ { r: 0, g: 0, b: 0, a: 0 },
3276
+ texW,
3277
+ texH,
3278
+ "load",
3279
+ );
3280
+ ctx.renderer.slugPipeline.drawBatches(
3281
+ glyphPass,
3282
+ glyphBatches,
3283
+ texW,
3284
+ texH,
3285
+ );
3286
+ glyphPass.end();
3287
+ glyphBatches = null;
3288
+ layerHasRaster = true;
3289
+ }
3290
+
3291
+ let finalTex = localTex;
3292
+ if (!isTrue3D && br > 0 && !isCaption && kind !== "shape") {
3293
+ const maskTex = ctx.renderer.getTemporaryTexture(texW, texH, [
3294
+ localTex,
3295
+ activeTex,
3296
+ targetTexture,
3297
+ ...excludes,
3298
+ ]);
3299
+ const maskView = maskTex.createView();
3300
+ const maskClearPass = ctx.renderer.beginFrame(
3301
+ encoder,
3302
+ maskView,
3303
+ { r: 0, g: 0, b: 0, a: 0 },
3304
+ texW,
3305
+ texH,
3306
+ "clear",
3307
+ );
3308
+ maskClearPass.end();
3309
+
3310
+ const maskPass = ctx.renderer.beginFrame(
3311
+ encoder,
3312
+ maskView,
3313
+ { r: 0, g: 0, b: 0, a: 0 },
3314
+ texW,
3315
+ texH,
3316
+ "load",
3317
+ );
3318
+ ctx.renderer.drawRect(
3319
+ maskPass,
3320
+ { x: 0, y: 0, width: layerW, height: layerH },
3321
+ { r: 1, g: 1, b: 1, a: 1 },
3322
+ br,
3323
+ );
3324
+ maskPass.end();
3325
+
3326
+ finalTex = ctx.renderer.composite(
3327
+ encoder,
3328
+ localTex,
3329
+ maskTex,
3330
+ "mask-in",
3331
+ [activeTex, targetTexture, ...excludes],
3332
+ );
3333
+ }
3334
+
3335
+ // Temporal De-flickering & Optical Flow Warping
3336
+ if (deflickerOpts) {
3337
+ const deflickerPipe = getTemporalDeflickerPipeline(
3338
+ ctx.device,
3339
+ lop.id || `layer_${i}`,
3340
+ );
3341
+ if (shiftedFrame === 0) {
3342
+ deflickerPipe.reset();
3343
+ }
3344
+ finalTex = deflickerPipe.execute(finalTex, deflickerOpts, encoder);
3345
+ }
3346
+
3347
+ // Screen-Space 3D Relighting
3348
+ if (relightOpts) {
3349
+ const relightPipeline = getScreenSpaceRelightPipeline(
3350
+ ctx.device,
3351
+ ctx.renderer.format ?? "rgba8unorm",
3352
+ );
3353
+ const r3dInstance = getRenderer3D(
3354
+ ctx.device,
3355
+ ctx.renderer.format ?? "rgba8unorm",
3356
+ sampleCount3d,
3357
+ );
3358
+ const activeLights = synthesizeRelightLights(
3359
+ relightOpts as Record<string, unknown>,
3360
+ sceneLights,
3361
+ { x: sampledX, y: sampledY, width: texW, height: texH },
3362
+ shiftedFrame,
3363
+ fps ?? 24,
3364
+ );
3365
+ const lightsBuffer = r3dInstance.getOrCreateLightsBuffer(activeLights);
3366
+
3367
+ const relitTex = ctx.renderer.getTemporaryTexture(texW, texH, [
3368
+ finalTex,
3369
+ activeTex,
3370
+ targetTexture,
3371
+ ...excludes,
3372
+ ]);
3373
+ const relitView = relitTex.createView();
3374
+
3375
+ const normalTex = relightOpts.normalTexture ?? relightOpts.normalMap;
3376
+
3377
+ relightPipeline.execute(
3378
+ encoder,
3379
+ relitView,
3380
+ finalTex,
3381
+ normalTex,
3382
+ lightsBuffer,
3383
+ {
3384
+ viewportWidth: texW,
3385
+ viewportHeight: texH,
3386
+ layerX: sampledX,
3387
+ layerY: sampledY,
3388
+ roughness: resolveVal(
3389
+ target?.roughness ??
3390
+ target?.relightRoughness ??
3391
+ relightOpts.specularRoughness ??
3392
+ relightOpts.roughness,
3393
+ 0.35,
3394
+ shiftedFrame,
3395
+ fps ?? 24,
3396
+ ),
3397
+ specularStrength: resolveVal(
3398
+ target?.specularStrength ??
3399
+ target?.relightSpecularStrength ??
3400
+ relightOpts.specularStrength,
3401
+ 0.7,
3402
+ shiftedFrame,
3403
+ fps ?? 24,
3404
+ ),
3405
+ metallic: resolveVal(
3406
+ target?.metallic ??
3407
+ target?.relightMetallic ??
3408
+ relightOpts.metallic,
3409
+ 0.0,
3410
+ shiftedFrame,
3411
+ fps ?? 24,
3412
+ ),
3413
+ ambientIntensity: resolveVal(
3414
+ target?.ambientIntensity ??
3415
+ target?.relightAmbientIntensity ??
3416
+ relightOpts.ambientIntensity,
3417
+ 0.15,
3418
+ shiftedFrame,
3419
+ fps ?? 24,
3420
+ ),
3421
+ depthScale: resolveVal(
3422
+ target?.depthScale ??
3423
+ target?.relightDepthScale ??
3424
+ relightOpts.depthScale,
3425
+ 1.0,
3426
+ shiftedFrame,
3427
+ fps ?? 24,
3428
+ ),
3429
+ opacity: 1.0,
3430
+ },
3431
+ "clear",
3432
+ );
3433
+
3434
+ finalTex = relitTex;
3435
+ }
3436
+
3437
+ // Directional velocity motion blur (last: the shutter integrates the final look)
3438
+ let blurPadX = 0;
3439
+ let blurPadY = 0;
3440
+ if (shutterAngle > 0 && fps && shiftedFrame !== undefined) {
3441
+ const currentT = shiftedFrame / fps;
3442
+ const prevT = (shiftedFrame - 1) / fps;
3443
+ tl.seek(prevT);
3444
+ const prevTarget = targetId ? targetsById[targetId] : undefined;
3445
+ const prevX = prevTarget?.x ?? baseX;
3446
+ const prevY = prevTarget?.y ?? baseY;
3447
+ tl.seek(currentT);
3448
+
3449
+ const vx = sampledX - prevX;
3450
+ const vy = sampledY - prevY;
3451
+
3452
+ if (Math.hypot(vx, vy) > 0.5) {
3453
+ // The blur only samples inside its texture: give flat layers room
3454
+ // to streak past their own bounds instead of smearing edge pixels.
3455
+ if (!isTrue3D && !(is3D && quad3D)) {
3456
+ const reach = shutterAngle / 360;
3457
+ blurPadX = Math.ceil(Math.abs(vx) * reach);
3458
+ blurPadY = Math.ceil(Math.abs(vy) * reach);
3459
+ const paddedTex = ctx.renderer.getTemporaryTexture(
3460
+ texW + blurPadX * 2,
3461
+ texH + blurPadY * 2,
3462
+ [finalTex, activeTex, targetTexture, ...excludes],
3463
+ );
3464
+ const padPass = ctx.renderer.beginFrame(
3465
+ encoder,
3466
+ paddedTex.createView(),
3467
+ { r: 0, g: 0, b: 0, a: 0 },
3468
+ paddedTex.width,
3469
+ paddedTex.height,
3470
+ "clear",
3471
+ );
3472
+ ctx.renderer.drawTexture(
3473
+ padPass,
3474
+ finalTex,
3475
+ { x: blurPadX, y: blurPadY, width: texW, height: texH },
3476
+ { opacity: 1.0 },
3477
+ );
3478
+ padPass.end();
3479
+ finalTex = paddedTex;
3480
+ }
3481
+ // ~1 tap per 2 px of streak so fast moves smear instead of ghosting.
3482
+ const streakPx = Math.hypot(vx, vy) * (shutterAngle / 360) * 2;
3483
+ const samples = Math.min(128, Math.max(16, Math.ceil(streakPx / 2)));
3484
+ finalTex = ctx.renderer.applyMotionBlur(
3485
+ encoder,
3486
+ finalTex,
3487
+ vx,
3488
+ vy,
3489
+ shutterAngle,
3490
+ samples,
3491
+ [activeTex, targetTexture, ...excludes],
3492
+ );
3493
+ }
3494
+ }
3495
+
3496
+ const borderExcludes = [
3497
+ localTex,
3498
+ finalTex,
3499
+ activeTex,
3500
+ targetTexture,
3501
+ ...excludes,
3502
+ ];
3503
+ const borderResult =
3504
+ !isTrue3D && lop.borderWidth !== undefined && lop.borderColor
3505
+ ? createBorderTexture(
3506
+ ctx,
3507
+ encoder,
3508
+ layerW,
3509
+ layerH,
3510
+ br,
3511
+ lop.borderWidth,
3512
+ lop.borderColor,
3513
+ lop.strokeAlign ?? "inside",
3514
+ borderExcludes,
3515
+ )
3516
+ : null;
3517
+
3518
+ // Restore parent container scissors before compositing onto container/screen activeView
3519
+ if (savedScissors.length > 0) {
3520
+ ctx.renderer.resumeScissors?.(savedScissors);
3521
+ }
3522
+
3523
+ if (isTrue3D) {
3524
+ const renderedTex = finalTex;
3525
+ if (borderResult) {
3526
+ const borderPass = ctx.renderer.beginFrame(
3527
+ encoder,
3528
+ renderedTex.createView(),
3529
+ { r: 0, g: 0, b: 0, a: 0 },
3530
+ texW,
3531
+ texH,
3532
+ "load",
3533
+ );
3534
+ ctx.renderer.drawTexture(
3535
+ borderPass,
3536
+ borderResult.borderTex,
3537
+ {
3538
+ x: borderResult.ox,
3539
+ y: borderResult.oy,
3540
+ width: borderResult.ow,
3541
+ height: borderResult.oh,
3542
+ },
3543
+ { opacity: 1.0 },
3544
+ );
3545
+ borderPass.end();
3546
+ }
3547
+
3548
+ let modelMatrix: Mat4 | undefined;
3549
+ if (parent3DMatrix) {
3550
+ let localM = Matrix4Math.identity();
3551
+ const childX = target?.x ?? lop.x ?? 0;
3552
+ const childY = target?.y ?? lop.y ?? 0;
3553
+ const childZ = target?.z ?? lop.z ?? 0;
3554
+ localM = Matrix4Math.multiply(
3555
+ localM,
3556
+ Matrix4Math.translate(childX, childY, childZ),
3557
+ );
3558
+ const rxRad = ((target?.rotateX ?? lop.rotateX ?? 0) * Math.PI) / 180;
3559
+ const ryRad = ((target?.rotateY ?? lop.rotateY ?? 0) * Math.PI) / 180;
3560
+ const rzRad =
3561
+ ((target?.rotateZ ?? lop.rotateZ ?? 0) !== 0
3562
+ ? (target?.rotateZ ?? lop.rotateZ ?? 0)
3563
+ : rotation) *
3564
+ (Math.PI / 180);
3565
+ if (rzRad !== 0)
3566
+ localM = Matrix4Math.multiply(localM, Matrix4Math.rotateZ(rzRad));
3567
+ if (ryRad !== 0)
3568
+ localM = Matrix4Math.multiply(localM, Matrix4Math.rotateY(ryRad));
3569
+ if (rxRad !== 0)
3570
+ localM = Matrix4Math.multiply(localM, Matrix4Math.rotateX(rxRad));
3571
+
3572
+ const sx = layerW * scale;
3573
+ const sy = layerH * scale;
3574
+ const sz = target?.scaleZ ?? lop.scaleZ ?? 1;
3575
+ localM = Matrix4Math.multiply(localM, Matrix4Math.scale(sx, sy, sz));
3576
+
3577
+ const ax = lop.anchorX ?? 0.5;
3578
+ const ay = lop.anchorY ?? 0.5;
3579
+ const pivotX = 0.5 - ax;
3580
+ const pivotY = 0.5 - ay;
3581
+ if (pivotX !== 0 || pivotY !== 0) {
3582
+ localM = Matrix4Math.multiply(
3583
+ localM,
3584
+ Matrix4Math.translate(pivotX, pivotY, 0),
3585
+ );
3586
+ }
3587
+
3588
+ modelMatrix = Matrix4Math.multiply(parent3DMatrix, localM);
3589
+ }
3590
+
3591
+ const quadOpts: DrawQuad3DOpts = {
3592
+ position: [
3593
+ localX + layerW * 0.5,
3594
+ localY + layerH * 0.5,
3595
+ target?.z ?? lop.z ?? 0,
3596
+ ],
3597
+ width: layerW,
3598
+ height: layerH,
3599
+ textureExtent: [layerW / texW, layerH / texH],
3600
+ rotation: [
3601
+ target?.rotateX ?? lop.rotateX ?? 0,
3602
+ target?.rotateY ?? lop.rotateY ?? 0,
3603
+ (target?.rotateZ ?? lop.rotateZ ?? 0) !== 0
3604
+ ? (target?.rotateZ ?? lop.rotateZ ?? 0)
3605
+ : rotation,
3606
+ ],
3607
+ scale: [scale, scale, target?.scaleZ ?? lop.scaleZ ?? 1],
3608
+ anchor: [lop.anchorX ?? 0.5, lop.anchorY ?? 0.5],
3609
+ borderRadius: br,
3610
+ borderWidth: lop.borderWidth,
3611
+ borderColor: lop.borderColor,
3612
+ opacity: layerOpacity,
3613
+ twoSided: target?.twoSided ?? lop.twoSided ?? true,
3614
+ material: target?.material ?? lop.material,
3615
+ shininess: target?.shininess ?? lop.shininess,
3616
+ roughness: target?.roughness ?? lop.roughness,
3617
+ specularIntensity: target?.specularIntensity ?? lop.specularIntensity,
3618
+ ambientIntensity: target?.ambientIntensity ?? lop.ambientIntensity,
3619
+ metallic: target?.metallic ?? lop.metallic,
3620
+ ...(modelMatrix && { modelMatrix }),
3621
+ };
3622
+
3623
+ // The texture quad carries the layer's backdrop and border; a
3624
+ // text layer that drew neither needs only its glyphs.
3625
+ const quadHasContent =
3626
+ layerHasRaster ||
3627
+ Boolean(
3628
+ quadOpts.borderWidth &&
3629
+ quadOpts.borderWidth > 0 &&
3630
+ quadOpts.borderColor,
3631
+ );
3632
+ if (!glyphBatches || quadHasContent) {
3633
+ pending3DItems.push({
3634
+ kind: "quad",
3635
+ texture: renderedTex,
3636
+ opts: quadOpts,
3637
+ });
3638
+ }
3639
+ if (glyphBatches && glyphBatches.length > 0) {
3640
+ pending3DItems.push({
3641
+ kind: "slug",
3642
+ batches: glyphBatches,
3643
+ opts: quadOpts,
3644
+ stackBase: quadHasContent ? 1 : 0,
3645
+ });
3646
+ }
3647
+ return { finalTex: activeTex, finalView: activeView };
3648
+ }
3649
+
3650
+ if (is3D && quad3D) {
3651
+ const renderedTex = finalTex;
3652
+ if (borderResult) {
3653
+ const borderPass = ctx.renderer.beginFrame(
3654
+ encoder,
3655
+ renderedTex.createView(),
3656
+ { r: 0, g: 0, b: 0, a: 0 },
3657
+ texW,
3658
+ texH,
3659
+ "load",
3660
+ );
3661
+ ctx.renderer.drawTexture(
3662
+ borderPass,
3663
+ borderResult.borderTex,
3664
+ {
3665
+ x: borderResult.ox,
3666
+ y: borderResult.oy,
3667
+ width: borderResult.ow,
3668
+ height: borderResult.oh,
3669
+ },
3670
+ { opacity: 1.0 },
3671
+ );
3672
+ borderPass.end();
3673
+ }
3674
+
3675
+ if (blendMode === "normal" || blendMode === "source-over") {
3676
+ drawProjectedQuad(
3677
+ ctx,
3678
+ encoder,
3679
+ activeView,
3680
+ renderedTex,
3681
+ quad3D,
3682
+ targetW,
3683
+ targetH,
3684
+ layerOpacity,
3685
+ );
3686
+ } else {
3687
+ const layerExcludes = [
3688
+ activeTex,
3689
+ targetTexture,
3690
+ renderedTex,
3691
+ ...excludes,
3692
+ ];
3693
+ if (borderResult) layerExcludes.push(borderResult.borderTex);
3694
+ const layerTex = ctx.renderer.getTemporaryTexture(
3695
+ targetW,
3696
+ targetH,
3697
+ layerExcludes,
3698
+ );
3699
+ const layerView = layerTex.createView();
3700
+ const layerClearPass = ctx.renderer.beginFrame(
3701
+ encoder,
3702
+ layerView,
3703
+ { r: 0, g: 0, b: 0, a: 0 },
3704
+ targetW,
3705
+ targetH,
3706
+ "clear",
3707
+ );
3708
+ layerClearPass.end();
3709
+
3710
+ drawProjectedQuad(
3711
+ ctx,
3712
+ encoder,
3713
+ layerView,
3714
+ renderedTex,
3715
+ quad3D,
3716
+ targetW,
3717
+ targetH,
3718
+ layerOpacity,
3719
+ );
3720
+
3721
+ const nextCompTex = ctx.renderer.composite(
3722
+ encoder,
3723
+ activeTex,
3724
+ layerTex,
3725
+ blendMode,
3726
+ [targetTexture, ...excludes],
3727
+ );
3728
+ activeTex = nextCompTex;
3729
+ activeView = activeTex.createView();
3730
+ }
3731
+
3732
+ return { finalTex: activeTex, finalView: activeView };
3733
+ }
3734
+
3735
+ if (blendMode === "normal" || blendMode === "source-over") {
3736
+ const drawPass = ctx.renderer.beginFrame(
3737
+ encoder,
3738
+ activeView,
3739
+ { r: 0, g: 0, b: 0, a: 0 },
3740
+ targetW,
3741
+ targetH,
3742
+ "load",
3743
+ );
3744
+ ctx.renderer.pushTransform(layerMatrix);
3745
+ ctx.renderer.drawTexture(
3746
+ drawPass,
3747
+ finalTex,
3748
+ {
3749
+ x: -blurPadX,
3750
+ y: -blurPadY,
3751
+ width: layerW + blurPadX * 2,
3752
+ height: layerH + blurPadY * 2,
3753
+ },
3754
+ { opacity: layerOpacity },
3755
+ );
3756
+
3757
+ if (borderResult) {
3758
+ ctx.renderer.drawTexture(
3759
+ drawPass,
3760
+ borderResult.borderTex,
3761
+ {
3762
+ x: borderResult.ox,
3763
+ y: borderResult.oy,
3764
+ width: borderResult.ow,
3765
+ height: borderResult.oh,
3766
+ },
3767
+ { opacity: layerOpacity },
3768
+ );
3769
+ }
3770
+
3771
+ ctx.renderer.popTransform();
3772
+ drawPass.end();
3773
+ } else {
3774
+ const layerExcludes = [finalTex, activeTex, targetTexture, ...excludes];
3775
+ if (borderResult) layerExcludes.push(borderResult.borderTex);
3776
+ const layerTex = ctx.renderer.getTemporaryTexture(
3777
+ targetW,
3778
+ targetH,
3779
+ layerExcludes,
3780
+ );
3781
+ const layerView = layerTex.createView();
3782
+
3783
+ const layerClearPass = ctx.renderer.beginFrame(
3784
+ encoder,
3785
+ layerView,
3786
+ { r: 0, g: 0, b: 0, a: 0 },
3787
+ targetW,
3788
+ targetH,
3789
+ "clear",
3790
+ );
3791
+ layerClearPass.end();
3792
+
3793
+ const drawPass = ctx.renderer.beginFrame(
3794
+ encoder,
3795
+ layerView,
3796
+ { r: 0, g: 0, b: 0, a: 0 },
3797
+ targetW,
3798
+ targetH,
3799
+ "load",
3800
+ );
3801
+ ctx.renderer.pushTransform(layerMatrix);
3802
+ ctx.renderer.drawTexture(
3803
+ drawPass,
3804
+ finalTex,
3805
+ {
3806
+ x: -blurPadX,
3807
+ y: -blurPadY,
3808
+ width: layerW + blurPadX * 2,
3809
+ height: layerH + blurPadY * 2,
3810
+ },
3811
+ { opacity: layerOpacity },
3812
+ );
3813
+
3814
+ if (borderResult) {
3815
+ ctx.renderer.drawTexture(
3816
+ drawPass,
3817
+ borderResult.borderTex,
3818
+ {
3819
+ x: borderResult.ox,
3820
+ y: borderResult.oy,
3821
+ width: borderResult.ow,
3822
+ height: borderResult.oh,
3823
+ },
3824
+ { opacity: layerOpacity },
3825
+ );
3826
+ }
3827
+
3828
+ ctx.renderer.popTransform();
3829
+ drawPass.end();
3830
+
3831
+ const nextCompTex = ctx.renderer.composite(
3832
+ encoder,
3833
+ activeTex,
3834
+ layerTex,
3835
+ blendMode,
3836
+ [targetTexture, ...excludes],
3837
+ );
3838
+ activeTex = nextCompTex;
3839
+ activeView = activeTex.createView();
3840
+ }
3841
+
3842
+ return { finalTex: activeTex, finalView: activeView };
3843
+ };
3844
+
3845
+ const initialExcludes = [
3846
+ compTex,
3847
+ targetTexture,
3848
+ ...(props.excludeTextures || []),
3849
+ ];
3850
+ const renderResult = await renderNodeTree(
3851
+ virtualMedia.children ?? [],
3852
+ compTex,
3853
+ compView,
3854
+ initialExcludes,
3855
+ 1.0,
3856
+ new DOMMatrix(),
3857
+ null,
3858
+ nativeWidth,
3859
+ nativeHeight,
3860
+ null,
3861
+ true,
3862
+ );
3863
+ currentCompTex = renderResult.finalTex;
3864
+
3865
+ ctx.renderer.popTransform();
3866
+ ctx.renderer.popScissor();
3867
+
3868
+ // 4. Composite the native texture back to targetView
3869
+ const finalPass = ctx.renderer.beginFrame(
3870
+ encoder,
3871
+ targetView,
3872
+ { r: 0, g: 0, b: 0, a: 0 },
3873
+ targetWidth,
3874
+ targetHeight,
3875
+ "load",
3876
+ );
3877
+
3878
+ ctx.renderer.drawTexture(
3879
+ finalPass,
3880
+ currentCompTex,
3881
+ {
3882
+ x: 0,
3883
+ y: 0,
3884
+ width: props.containerWidth ?? targetWidth,
3885
+ height: props.containerHeight ?? targetHeight,
3886
+ },
3887
+ { opacity },
3888
+ );
3889
+
3890
+ args.pass = finalPass;
3891
+ }
3892
+ };
3893
+
3894
+ function createBorderTexture(
3895
+ ctx: RenderContextValue,
3896
+ encoder: GPUCommandEncoder,
3897
+ w: number,
3898
+ h: number,
3899
+ br: number,
3900
+ borderWidth: number,
3901
+ borderColor: string | Color,
3902
+ strokeAlign: string,
3903
+ excludeTextures: GPUTexture[],
3904
+ ): {
3905
+ borderTex: GPUTexture;
3906
+ ox: number;
3907
+ oy: number;
3908
+ ow: number;
3909
+ oh: number;
3910
+ } | null {
3911
+ if (w <= 0 || h <= 0 || !borderColor || !borderWidth || borderWidth <= 0) {
3912
+ return null;
3913
+ }
3914
+
3915
+ let ox = 0;
3916
+ let oy = 0;
3917
+ let ow = w;
3918
+ let oh = h;
3919
+ let outerRadius = br;
3920
+ let innerRadius = br;
3921
+
3922
+ if (strokeAlign === "inside") {
3923
+ ox = 0;
3924
+ oy = 0;
3925
+ ow = w;
3926
+ oh = h;
3927
+ outerRadius = br;
3928
+ innerRadius = Math.max(0, br - borderWidth);
3929
+ } else if (strokeAlign === "outside") {
3930
+ ox = -borderWidth;
3931
+ oy = -borderWidth;
3932
+ ow = w + 2 * borderWidth;
3933
+ oh = h + 2 * borderWidth;
3934
+ outerRadius = br + borderWidth;
3935
+ innerRadius = br;
3936
+ } else {
3937
+ // center
3938
+ ox = -borderWidth / 2;
3939
+ oy = -borderWidth / 2;
3940
+ ow = w + borderWidth;
3941
+ oh = h + borderWidth;
3942
+ outerRadius = br + borderWidth / 2;
3943
+ innerRadius = Math.max(0, br - borderWidth / 2);
3944
+ }
3945
+
3946
+ if (br <= 0) {
3947
+ outerRadius = 0;
3948
+ innerRadius = 0;
3949
+ }
3950
+
3951
+ if (ow <= 0 || oh <= 0) {
3952
+ return null;
3953
+ }
3954
+
3955
+ const borderTexW = Math.ceil(ow);
3956
+ const borderTexH = Math.ceil(oh);
3957
+
3958
+ const outerTex = ctx.renderer.getTemporaryTexture(
3959
+ borderTexW,
3960
+ borderTexH,
3961
+ excludeTextures,
3962
+ );
3963
+ const outerView = outerTex.createView();
3964
+
3965
+ const outerClearPass = ctx.renderer.beginFrame(
3966
+ encoder,
3967
+ outerView,
3968
+ { r: 0, g: 0, b: 0, a: 0 },
3969
+ borderTexW,
3970
+ borderTexH,
3971
+ "clear",
3972
+ );
3973
+ outerClearPass.end();
3974
+
3975
+ const outerDrawPass = ctx.renderer.beginFrame(
3976
+ encoder,
3977
+ outerView,
3978
+ { r: 0, g: 0, b: 0, a: 0 },
3979
+ borderTexW,
3980
+ borderTexH,
3981
+ "load",
3982
+ );
3983
+ ctx.renderer.drawRect(
3984
+ outerDrawPass,
3985
+ { x: 0, y: 0, width: ow, height: oh },
3986
+ borderColor,
3987
+ outerRadius,
3988
+ );
3989
+ outerDrawPass.end();
3990
+
3991
+ const innerW = ow - 2 * borderWidth;
3992
+ const innerH = oh - 2 * borderWidth;
3993
+
3994
+ if (innerW > 0 && innerH > 0) {
3995
+ const innerTex = ctx.renderer.getTemporaryTexture(borderTexW, borderTexH, [
3996
+ outerTex,
3997
+ ...excludeTextures,
3998
+ ]);
3999
+ const innerView = innerTex.createView();
4000
+
4001
+ const innerClearPass = ctx.renderer.beginFrame(
4002
+ encoder,
4003
+ innerView,
4004
+ { r: 0, g: 0, b: 0, a: 0 },
4005
+ borderTexW,
4006
+ borderTexH,
4007
+ "clear",
4008
+ );
4009
+ innerClearPass.end();
4010
+
4011
+ const innerDrawPass = ctx.renderer.beginFrame(
4012
+ encoder,
4013
+ innerView,
4014
+ { r: 0, g: 0, b: 0, a: 0 },
4015
+ borderTexW,
4016
+ borderTexH,
4017
+ "load",
4018
+ );
4019
+ ctx.renderer.drawRect(
4020
+ innerDrawPass,
4021
+ { x: borderWidth, y: borderWidth, width: innerW, height: innerH },
4022
+ { r: 1, g: 1, b: 1, a: 1 },
4023
+ innerRadius,
4024
+ );
4025
+ innerDrawPass.end();
4026
+
4027
+ const borderTex = ctx.renderer.composite(
4028
+ encoder,
4029
+ outerTex,
4030
+ innerTex,
4031
+ "mask-out",
4032
+ excludeTextures,
4033
+ );
4034
+ return { borderTex, ox, oy, ow, oh };
4035
+ }
4036
+
4037
+ return { borderTex: outerTex, ox, oy, ow, oh };
4038
+ }