@framefields/node-compositor 0.0.0-stage → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +32 -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,996 @@
1
+ import { resolveLayerDuration } from "@framefields/compositions";
2
+ import {
3
+ type AnimationTrack,
4
+ collectNodeOps,
5
+ type EaseRef,
6
+ evaluateTrackAtFrame,
7
+ type LayerAnimationSpec,
8
+ } from "@framefields/compositions/program";
9
+ import {
10
+ DEFAULT_DURATION_MS,
11
+ getActiveMediaMetadata,
12
+ reportRenderDiagnostic,
13
+ type VirtualMediaData,
14
+ } from "@framefields/core";
15
+ import gsapModule from "gsap";
16
+ import { solveCubicBezier } from "./bezier-solver.js";
17
+
18
+ const gsap = (gsapModule as any).timeline
19
+ ? gsapModule
20
+ : (gsapModule as any).default || gsapModule;
21
+
22
+ export interface LayerStub {
23
+ id: string;
24
+ inputHandleId: string;
25
+ trackId?: string;
26
+ x: number;
27
+ y: number;
28
+ scale: number;
29
+ rotation: number;
30
+ opacity: number;
31
+ volume: number;
32
+ width?: number;
33
+ height?: number;
34
+ hidden: boolean;
35
+ muted: boolean;
36
+ text?: number;
37
+ fontSize?: number;
38
+ fill?: string;
39
+ borderColor?: string;
40
+ backgroundColor?: string;
41
+ rotateX?: number;
42
+ rotateY?: number;
43
+ rotateZ?: number;
44
+ perspective?: number;
45
+ perspectiveOriginX?: number;
46
+ perspectiveOriginY?: number;
47
+ translateZ?: number;
48
+ backfaceVisibility?: "visible" | "hidden";
49
+ transformStyle?: "flat" | "preserve-3d";
50
+ // 3D Layer Props
51
+ is3D?: boolean;
52
+ z?: number;
53
+ scaleZ?: number;
54
+ twoSided?: boolean;
55
+ // 3D Material Props
56
+ material?: "lit" | "unlit" | "toon";
57
+ shininess?: number;
58
+ roughness?: number;
59
+ specularIntensity?: number;
60
+ ambientIntensity?: number;
61
+ metallic?: number;
62
+ // 3D Camera Props
63
+ cameraX?: number;
64
+ cameraY?: number;
65
+ cameraZ?: number;
66
+ targetX?: number;
67
+ targetY?: number;
68
+ targetZ?: number;
69
+ cameraPitch?: number;
70
+ cameraYaw?: number;
71
+ cameraRoll?: number;
72
+ cameraFov?: number;
73
+ cameraZoom?: number;
74
+ focalLength?: number;
75
+ focusDistance?: number;
76
+ fStop?: number;
77
+ maxBlurRadius?: number;
78
+ shakeTranslation?: number;
79
+ shakeRotation?: number;
80
+ orbitRadius?: number;
81
+ orbitAzimuth?: number;
82
+ orbitElevation?: number;
83
+ // 3D Model Props
84
+ animationProgress?: number;
85
+ animationTime?: number;
86
+ animationSpeed?: number;
87
+ wireframe?: boolean;
88
+ scaleX?: number;
89
+ scaleY?: number;
90
+ // 3D Light Props
91
+ lightType?: string;
92
+ intensity?: number;
93
+ radius?: number;
94
+ angle?: number;
95
+ penumbra?: number;
96
+ decay?: number;
97
+ [key: string]: unknown;
98
+ }
99
+
100
+ export type CompilerVirtualMedia = {
101
+ metadata?: Partial<VirtualMediaData["metadata"]>;
102
+ operation?: Partial<VirtualMediaData["operation"]> & Record<string, any>;
103
+ children?: CompilerNodeVM[];
104
+ };
105
+
106
+ /** A node op inside the render tree (CompositorLayer). */
107
+ export type CompilerNodeVM = CompilerVirtualMedia;
108
+
109
+ export interface CompileResult {
110
+ tl: gsap.core.Timeline;
111
+ targetsById: Record<string, LayerStub>;
112
+ /** Node ops the timeline was compiled from (tree order). */
113
+ nodes: CompilerNodeVM[];
114
+ }
115
+
116
+ // L2: compiled timelines and their LayerStubs are cached per cacheKey and
117
+ // SHARED across frames — `tl.seek()` mutates the stubs in place. This is safe
118
+ // because frame rendering is strictly single-threaded per renderId, and the
119
+ // 10s TTL bounds staleness. Do NOT render two frames of the same renderId
120
+ // concurrently without cloning `tl` + stubs first.
121
+ const cache = new Map<string, { cacheTime: number; value: CompileResult }>();
122
+
123
+ // L1: hashing the layer ops stringifies every keyframe of every layer, which
124
+ // costs tens of ms per frame on a full film. Programs are rebuilt (not mutated)
125
+ // when a composition changes, so that hash is memoized on the children array:
126
+ // the operation itself is shallow-copied each frame to bake in signal values,
127
+ // but `children` keeps its identity. Signals are cheap to key and may be plain
128
+ // data mutated in place, so they are re-keyed on every call.
129
+ const nodesHashMemo = new WeakMap<
130
+ object,
131
+ {
132
+ width: number;
133
+ height: number;
134
+ fps: number;
135
+ durationSec: number;
136
+ hash: string;
137
+ }
138
+ >();
139
+
140
+ /** Entries untouched for this long are dropped (and their timeline killed). */
141
+ const CACHE_TTL_MS = 10000;
142
+ /** Max compiled timelines retained per cache. */
143
+ const TIMELINE_CACHE_MAX = 50;
144
+ const LAYER_TIMELINE_CACHE_MAX = 50;
145
+
146
+ function fnv1a(str: string): string {
147
+ let hash = 2166136261;
148
+ for (let i = 0; i < str.length; i++) {
149
+ hash ^= str.charCodeAt(i);
150
+ hash +=
151
+ (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
152
+ }
153
+ return (hash >>> 0).toString(16);
154
+ }
155
+
156
+ // Signal objects carry per-frame caches (last frame, last value), so hashing
157
+ // their contents gives a new key every frame: the timeline would recompile
158
+ // each frame and the cache would never hit. Key live signals by identity.
159
+ const signalIds = new WeakMap<object, number>();
160
+ let nextSignalId = 0;
161
+
162
+ function signalsKey(signals: unknown): string {
163
+ if (!signals || typeof signals !== "object") return stableStringify(signals);
164
+ return Object.keys(signals)
165
+ .sort()
166
+ .map((name) => {
167
+ const s = (signals as Record<string, unknown>)[name];
168
+ if (
169
+ s &&
170
+ typeof s === "object" &&
171
+ typeof (s as { get?: unknown }).get === "function"
172
+ ) {
173
+ let id = signalIds.get(s);
174
+ if (id === undefined) {
175
+ id = nextSignalId++;
176
+ signalIds.set(s, id);
177
+ }
178
+ return `${JSON.stringify(name)}:#${id}`;
179
+ }
180
+ return `${JSON.stringify(name)}:${stableStringify(s)}`;
181
+ })
182
+ .join(",");
183
+ }
184
+
185
+ /**
186
+ * Paused gsap timelines stay attached to gsap's global timeline until killed,
187
+ * so anything dropped from a cache must be killed or it is never collected.
188
+ */
189
+ function release(value: { tl: gsap.core.Timeline }): void {
190
+ value.tl.kill();
191
+ }
192
+
193
+ /**
194
+ * Make room for one insertion: drop and kill TTL-expired entries, then evict
195
+ * the least-recently-used entry if the cache is at capacity. Every dropped
196
+ * timeline MUST be killed — see `release`.
197
+ */
198
+ function evictForInsert<T extends { tl: gsap.core.Timeline }>(
199
+ cache: Map<string, { cacheTime: number; value: T }>,
200
+ maxSize: number,
201
+ ): void {
202
+ const now = Date.now();
203
+ for (const [key, entry] of cache.entries()) {
204
+ if (now - entry.cacheTime > CACHE_TTL_MS) {
205
+ cache.delete(key);
206
+ release(entry.value);
207
+ }
208
+ }
209
+ if (cache.size >= maxSize) {
210
+ const oldestKey = cache.keys().next().value;
211
+ if (oldestKey !== undefined) {
212
+ const oldest = cache.get(oldestKey);
213
+ cache.delete(oldestKey);
214
+ if (oldest) release(oldest.value);
215
+ }
216
+ }
217
+ }
218
+
219
+ function stableStringify(obj: any): string {
220
+ if (obj === null) return "null";
221
+ if (obj === undefined) return "undefined";
222
+ if (typeof obj !== "object") return JSON.stringify(obj);
223
+ if (ArrayBuffer.isView(obj)) {
224
+ return `[TypedArray:${(obj as ArrayBufferView).byteLength}]`;
225
+ }
226
+ if (Array.isArray(obj)) {
227
+ return "[" + obj.map(stableStringify).join(",") + "]";
228
+ }
229
+ const sortedKeys = Object.keys(obj).sort();
230
+ const parts = sortedKeys.map((key) => {
231
+ return `${JSON.stringify(key)}:${stableStringify(obj[key])}`;
232
+ });
233
+ return "{" + parts.join(",") + "}";
234
+ }
235
+
236
+ export function getCachedTimeline(cacheKey: string): CompileResult | undefined {
237
+ const entry = cache.get(cacheKey);
238
+ if (entry) {
239
+ if (Date.now() - entry.cacheTime > CACHE_TTL_MS) {
240
+ cache.delete(cacheKey);
241
+ release(entry.value);
242
+ return undefined;
243
+ }
244
+ entry.cacheTime = Date.now();
245
+ cache.delete(cacheKey);
246
+ cache.set(cacheKey, entry);
247
+ return entry.value;
248
+ }
249
+ return undefined;
250
+ }
251
+
252
+ export function setCachedTimeline(cacheKey: string, value: CompileResult) {
253
+ evictForInsert(cache, TIMELINE_CACHE_MAX);
254
+ cache.set(cacheKey, { cacheTime: Date.now(), value });
255
+ }
256
+
257
+ export function mappedEase(
258
+ ease?: EaseRef,
259
+ durationFrames?: number,
260
+ fps = 24,
261
+ ): string | ((p: number) => number) {
262
+ if (!ease) return "none";
263
+ const { name, dir, params } = ease;
264
+ if (name === "none") return "none";
265
+
266
+ // Custom closed-form Spring ease solver
267
+ if ((name as string) === "spring") {
268
+ const damping = Math.max(0.001, params?.[0] ?? 10);
269
+ const stiffness = Math.max(0.001, params?.[1] ?? 100);
270
+ const mass = Math.max(0.001, params?.[2] ?? 1);
271
+
272
+ return (p: number) => {
273
+ const safeFps = fps > 0 ? fps : 24;
274
+ const durSec =
275
+ durationFrames && durationFrames > 0 ? durationFrames / safeFps : 1.0;
276
+ const t = p * durSec;
277
+
278
+ const m = mass;
279
+ const k = stiffness;
280
+ const c = damping;
281
+
282
+ const w0 = Math.sqrt(k / m);
283
+ const zeta = c / (2 * Math.sqrt(k * m));
284
+
285
+ let x = 0;
286
+ if (zeta < 1) {
287
+ const wd = w0 * Math.sqrt(1 - zeta * zeta);
288
+ const A = -1;
289
+ const B = (-zeta * w0) / wd;
290
+ x =
291
+ Math.exp(-zeta * w0 * t) *
292
+ (A * Math.cos(wd * t) + B * Math.sin(wd * t));
293
+ } else if (zeta === 1) {
294
+ const A = -1;
295
+ const B = -w0;
296
+ x = Math.exp(-w0 * t) * (A + B * t);
297
+ } else {
298
+ const r1 = -w0 * (zeta - Math.sqrt(zeta * zeta - 1));
299
+ const r2 = -w0 * (zeta + Math.sqrt(zeta * zeta - 1));
300
+ const C1 = -1 / (1 - r1 / r2);
301
+ const C2 = -1 - C1;
302
+ x = C1 * Math.exp(r1 * t) + C2 * Math.exp(r2 * t);
303
+ }
304
+ return 1 + x;
305
+ };
306
+ }
307
+
308
+ if ((name as string) === "cubic") {
309
+ const x1 = params?.[0] ?? 0.25;
310
+ const y1 = params?.[1] ?? 0.1;
311
+ const x2 = params?.[2] ?? 0.25;
312
+ const y2 = params?.[3] ?? 1.0;
313
+ return (p: number) => solveCubicBezier(x1, y1, x2, y2, p);
314
+ }
315
+
316
+ if ((name as string) === "hold") {
317
+ // GSAP rounds time to 1e-7 s, so on its own end frame a short segment's
318
+ // progress can land ~1e-5 under 1; 1e-4 of a segment is safe slack for the
319
+ // jump (it would only fire a frame early on segments over 10,000 frames).
320
+ return (p: number) => (p >= 1 - 1e-4 ? 1 : 0);
321
+ }
322
+
323
+ const easeName = name as string;
324
+ let suffix = "";
325
+ if (params && params.length > 0) {
326
+ suffix = `(${params.join(", ")})`;
327
+ } else if (name === "back") {
328
+ suffix = "(1.7)";
329
+ } else if (name === "elastic") {
330
+ suffix = "(1, 0.3)";
331
+ }
332
+
333
+ return `${easeName}.${dir}${suffix}`;
334
+ }
335
+
336
+ function compileTrackToTimeline(
337
+ tl: gsap.core.Timeline,
338
+ stub: Record<string, any>,
339
+ track: AnimationTrack,
340
+ startFrame: number,
341
+ fps: number,
342
+ durationFrames: number,
343
+ signals?: Record<string, any>,
344
+ ): { dropped: number; maxAuthoredFrame: number } {
345
+ const prop = track.prop;
346
+ const source = track.source ?? { type: "keyframe" };
347
+
348
+ if (source.type !== "keyframe") {
349
+ const endF = Number.isFinite(durationFrames)
350
+ ? durationFrames
351
+ : Math.round(fps * 5);
352
+ const frameStepSec = 1 / fps;
353
+ const kfList: Array<Record<string, unknown>> = [];
354
+
355
+ const firstVal = evaluateTrackAtFrame(track, startFrame, fps, {
356
+ startFrame,
357
+ baseValues: stub,
358
+ signals,
359
+ });
360
+ stub[prop] = firstVal;
361
+ kfList.push({
362
+ [prop]: firstVal,
363
+ duration: 0,
364
+ });
365
+
366
+ for (let f = 1; f <= endF; f++) {
367
+ const val = evaluateTrackAtFrame(track, startFrame + f, fps, {
368
+ startFrame,
369
+ baseValues: stub,
370
+ signals,
371
+ });
372
+ kfList.push({
373
+ [prop]: val,
374
+ duration: frameStepSec,
375
+ ease: "none",
376
+ });
377
+ }
378
+
379
+ tl.to(
380
+ stub,
381
+ {
382
+ keyframes: kfList,
383
+ repeat: track.repeat ?? 0,
384
+ yoyo: !!track.yoyo,
385
+ lazy: false,
386
+ overwrite: false,
387
+ immediateRender: false,
388
+ },
389
+ startFrame / fps,
390
+ );
391
+
392
+ return { dropped: 0, maxAuthoredFrame: 0 };
393
+ }
394
+
395
+ const sortedKeyframes = [...track.keyframes].sort(
396
+ (a, b) => a.frame - b.frame,
397
+ );
398
+ // M8: keyframes outside the clip window [0, durationFrames] cannot play —
399
+ // keep the clamp (the renderer's temporal gate clips the same window) but
400
+ // REPORT it: an author who keys past the node's duration otherwise gets a
401
+ // silent static mid-animation with no diagnostic.
402
+ const validKeyframes = sortedKeyframes.filter(
403
+ (kf) => kf.frame >= 0 && kf.frame <= durationFrames,
404
+ );
405
+ const dropped = sortedKeyframes.length - validKeyframes.length;
406
+ const maxAuthoredFrame =
407
+ dropped > 0 ? sortedKeyframes[sortedKeyframes.length - 1].frame : 0;
408
+
409
+ if (validKeyframes.length === 0) {
410
+ return { dropped, maxAuthoredFrame };
411
+ }
412
+
413
+ const isDiscrete = prop === "hidden" || prop === "muted";
414
+
415
+ if (isDiscrete) {
416
+ for (const kf of validKeyframes) {
417
+ const absoluteSec = (startFrame + kf.frame) / fps;
418
+ tl.set(
419
+ stub,
420
+ {
421
+ [prop]: kf.value,
422
+ immediateRender: false,
423
+ },
424
+ absoluteSec,
425
+ );
426
+ }
427
+ } else {
428
+ const repeat = track.repeat ?? 0;
429
+ const yoyo = !!track.yoyo;
430
+
431
+ if (validKeyframes.length === 1) {
432
+ const kf0 = validKeyframes[0];
433
+ const absoluteSec = (startFrame + kf0.frame) / fps;
434
+ tl.set(
435
+ stub,
436
+ {
437
+ [prop]: kf0.value,
438
+ immediateRender: false,
439
+ },
440
+ absoluteSec,
441
+ );
442
+ } else if (validKeyframes.length === 2) {
443
+ const kf0 = validKeyframes[0];
444
+ const kf1 = validKeyframes[1];
445
+ const val0 = kf0.value;
446
+ const val1 = kf1.value;
447
+ const startSec = (startFrame + kf0.frame) / fps;
448
+ const durSec = (kf1.frame - kf0.frame) / fps;
449
+ const trackDurationFrames = track.durationFrames ?? kf1.frame - kf0.frame;
450
+ const easeRef = kf1.ease ?? kf0.ease;
451
+ const easeVal = mappedEase(easeRef, trackDurationFrames, fps);
452
+
453
+ if (val0 === stub[prop]) {
454
+ tl.to(
455
+ stub,
456
+ {
457
+ [prop]: val1,
458
+ duration: durSec,
459
+ ease: easeVal,
460
+ repeat,
461
+ yoyo,
462
+ lazy: false,
463
+ overwrite: false,
464
+ immediateRender: false,
465
+ },
466
+ startSec,
467
+ );
468
+ } else {
469
+ tl.fromTo(
470
+ stub,
471
+ {
472
+ [prop]: val0,
473
+ },
474
+ {
475
+ [prop]: val1,
476
+ duration: durSec,
477
+ ease: easeVal,
478
+ repeat,
479
+ yoyo,
480
+ lazy: false,
481
+ overwrite: false,
482
+ immediateRender: false,
483
+ },
484
+ startSec,
485
+ );
486
+ }
487
+ } else {
488
+ const kf0 = validKeyframes[0];
489
+ const startSec = (startFrame + kf0.frame) / fps;
490
+
491
+ const kfList: Array<Record<string, unknown>> = [];
492
+ kfList.push({
493
+ [prop]: kf0.value,
494
+ duration: 0,
495
+ });
496
+
497
+ for (let i = 1; i < validKeyframes.length; i++) {
498
+ const prevKf = validKeyframes[i - 1];
499
+ const currKf = validKeyframes[i];
500
+ const deltaFrames = currKf.frame - prevKf.frame;
501
+ const durSec = deltaFrames / fps;
502
+ const easeVal = mappedEase(currKf.ease, deltaFrames, fps);
503
+
504
+ kfList.push({
505
+ [prop]: currKf.value,
506
+ duration: durSec,
507
+ ease: easeVal,
508
+ });
509
+ }
510
+
511
+ tl.to(
512
+ stub,
513
+ {
514
+ keyframes: kfList,
515
+ repeat,
516
+ yoyo,
517
+ lazy: false,
518
+ overwrite: false,
519
+ immediateRender: false,
520
+ },
521
+ startSec,
522
+ );
523
+ }
524
+ }
525
+
526
+ return { dropped, maxAuthoredFrame };
527
+ }
528
+
529
+ // ── M8: truncated-track diagnostics ───────────────────────────────────
530
+
531
+ export interface TruncationInfo {
532
+ layerId: string;
533
+ trackId: string;
534
+ prop: string;
535
+ dropped: number;
536
+ maxAuthoredFrame: number;
537
+ }
538
+
539
+ /**
540
+ * Dedupe per compiled cache key — a fresh render of the same broken doc SHOULD
541
+ * re-warn. Single-frame renders are the exception: each `renderFrame` call has
542
+ * its own `img-` render id, so probing a doc frame by frame would repeat the
543
+ * warning on every frame; those dedupe per document instead.
544
+ */
545
+ const truncationWarnedKeys = new Set<string>();
546
+ const TRUNCATION_WARNED_CAP = 100;
547
+
548
+ function warnTruncationOnce(cacheKey: string, truncations: TruncationInfo[]) {
549
+ if (truncationWarnedKeys.has(cacheKey)) return;
550
+ if (truncationWarnedKeys.size >= TRUNCATION_WARNED_CAP) {
551
+ truncationWarnedKeys.clear();
552
+ }
553
+ truncationWarnedKeys.add(cacheKey);
554
+
555
+ const details = truncations
556
+ .map(
557
+ (t) =>
558
+ `'${t.prop}' (track '${t.trackId}') on '${t.layerId}': ${t.dropped} keyframe(s) past the clip window (last authored frame ${t.maxAuthoredFrame})`,
559
+ )
560
+ .join("; ");
561
+ console.warn(
562
+ `[compositor] ${truncations.length} track(s) truncated — animation tails will not play: ${details}. Extend durationFrames or move those keyframes.`,
563
+ );
564
+ }
565
+
566
+ function hashNodes(
567
+ nodeOps: CompilerNodeVM[],
568
+ width: number,
569
+ height: number,
570
+ fps: number,
571
+ durationSec: number,
572
+ ): string {
573
+ const irHashInput = stableStringify({
574
+ nodes: nodeOps.map((vm) => {
575
+ const lop = (vm?.operation || {}) as any;
576
+ return {
577
+ id: lop.id,
578
+ animation: lop.animation,
579
+ startFrame: lop.startFrame,
580
+ durationFrames: lop.durationFrames,
581
+ x: lop.x,
582
+ y: lop.y,
583
+ scale: lop.scale,
584
+ rotation: lop.rotation,
585
+ opacity: lop.opacity,
586
+ volume: lop.volume,
587
+ width: lop.width,
588
+ height: lop.height,
589
+ hidden: lop.hidden,
590
+ muted: lop.muted,
591
+ backgroundColor: lop.backgroundColor,
592
+ fill: lop.fill,
593
+ borderColor: lop.borderColor,
594
+ textBackgroundColor: lop.textBackgroundColor,
595
+ rotateX: lop.rotateX,
596
+ rotateY: lop.rotateY,
597
+ rotateZ: lop.rotateZ,
598
+ perspective: lop.perspective,
599
+ perspectiveOriginX: lop.perspectiveOriginX,
600
+ perspectiveOriginY: lop.perspectiveOriginY,
601
+ translateZ: lop.translateZ,
602
+ backfaceVisibility: lop.backfaceVisibility,
603
+ transformStyle: lop.transformStyle,
604
+ is3D: lop.is3D,
605
+ z: lop.z,
606
+ scaleZ: lop.scaleZ,
607
+ twoSided: lop.twoSided,
608
+ cameraX: lop.cameraX,
609
+ cameraY: lop.cameraY,
610
+ cameraZ: lop.cameraZ,
611
+ targetX: lop.targetX,
612
+ targetY: lop.targetY,
613
+ targetZ: lop.targetZ,
614
+ cameraPitch: lop.cameraPitch,
615
+ cameraYaw: lop.cameraYaw,
616
+ cameraRoll: lop.cameraRoll,
617
+ cameraFov: lop.cameraFov,
618
+ cameraZoom: lop.cameraZoom,
619
+ focalLength: lop.focalLength,
620
+ focusDistance: lop.focusDistance,
621
+ fStop: lop.fStop,
622
+ maxBlurRadius: lop.maxBlurRadius,
623
+ shakeTranslation: lop.shakeTranslation,
624
+ shakeRotation: lop.shakeRotation,
625
+ orbitRadius: lop.orbitRadius,
626
+ orbitAzimuth: lop.orbitAzimuth,
627
+ orbitElevation: lop.orbitElevation,
628
+ // 3D Material Props
629
+ material: lop.material,
630
+ shininess: lop.shininess,
631
+ roughness: lop.roughness,
632
+ specularIntensity: lop.specularIntensity,
633
+ ambientIntensity: lop.ambientIntensity,
634
+ metallic: lop.metallic,
635
+ // 3D Light Props
636
+ lightType: lop.lightType,
637
+ intensity: lop.intensity,
638
+ radius: lop.radius,
639
+ angle: lop.angle,
640
+ penumbra: lop.penumbra,
641
+ // 3D Model Props
642
+ animationProgress: lop.animationProgress,
643
+ animationTime: lop.animationTime,
644
+ animationSpeed: lop.animationSpeed,
645
+ wireframe: lop.wireframe,
646
+ scaleX: lop.scaleX,
647
+ scaleY: lop.scaleY,
648
+ // The LayerStub bakes fontSize (compiler.ts:425) and both the
649
+ // layout measure and the text draw prefer the stub's value
650
+ // over the live op — a fontSize change MUST bust the cache or
651
+ // the rendered text keeps the stale size for the whole TTL.
652
+ fontSize: lop.fontSize,
653
+ text: lop.text,
654
+ };
655
+ }),
656
+ width,
657
+ height,
658
+ fps,
659
+ durationSec,
660
+ });
661
+ return fnv1a(irHashInput);
662
+ }
663
+
664
+ export function compileTimeline(
665
+ renderId: string,
666
+ virtualMedia: CompilerVirtualMedia | null | undefined,
667
+ options: { fps: number; durationSec: number },
668
+ ): CompileResult {
669
+ const fps = options.fps;
670
+ const durationSec = options.durationSec;
671
+ const width = virtualMedia?.metadata?.width ?? 1920;
672
+ const height = virtualMedia?.metadata?.height ?? 1080;
673
+
674
+ // Collect node ops from the render tree (pre-order: parents first).
675
+ const nodeOps = collectNodeOps((virtualMedia?.children as any) ?? []);
676
+
677
+ // LRU cache check
678
+ const memoKey = virtualMedia?.children as object | undefined;
679
+ const memo = memoKey ? nodesHashMemo.get(memoKey) : undefined;
680
+ let nodesHash: string;
681
+ if (
682
+ memo &&
683
+ memo.width === width &&
684
+ memo.height === height &&
685
+ memo.fps === fps &&
686
+ memo.durationSec === durationSec
687
+ ) {
688
+ nodesHash = memo.hash;
689
+ } else {
690
+ nodesHash = hashNodes(nodeOps, width, height, fps, durationSec);
691
+ if (memoKey) {
692
+ nodesHashMemo.set(memoKey, {
693
+ width,
694
+ height,
695
+ fps,
696
+ durationSec,
697
+ hash: nodesHash,
698
+ });
699
+ }
700
+ }
701
+ const irHash = fnv1a(
702
+ `${nodesHash}|${signalsKey((virtualMedia as any)?.operation?.signals ?? (virtualMedia as any)?.signals)}`,
703
+ );
704
+ const cacheKey = `${renderId}_${irHash}`;
705
+
706
+ const cached = getCachedTimeline(cacheKey);
707
+ if (cached) {
708
+ return cached;
709
+ }
710
+
711
+ const tl = gsap.timeline({ paused: true, smoothChildTiming: false });
712
+ const targetsById: Record<string, LayerStub> = {};
713
+ const signals =
714
+ (virtualMedia as any)?.operation?.signals ??
715
+ (virtualMedia as any)?.signals ??
716
+ {};
717
+
718
+ nodeOps.forEach((vm, index: number) => {
719
+ const lop = (vm?.operation || {}) as any;
720
+ const layerId = lop.id || lop.inputHandleId || `layer_${index}`;
721
+
722
+ const stub: LayerStub = {
723
+ id: layerId,
724
+ inputHandleId: lop.inputHandleId || `layer_${index}`,
725
+ trackId: layerId,
726
+ x:
727
+ lop.kind === "camera" || lop.kind === "light"
728
+ ? (lop.x ?? lop.cameraX)
729
+ : (lop.x ?? 0),
730
+ y:
731
+ lop.kind === "camera" || lop.kind === "light"
732
+ ? (lop.y ?? lop.cameraY)
733
+ : (lop.y ?? 0),
734
+ scale: lop.scale ?? 1,
735
+ rotation: lop.rotation ?? 0,
736
+ opacity: lop.opacity ?? 1,
737
+ volume: lop.volume ?? 1,
738
+ width: lop.width,
739
+ height: lop.height,
740
+ hidden: !!lop.hidden,
741
+ muted: !!lop.muted,
742
+ rotateX: lop.rotateX ?? 0,
743
+ rotateY: lop.rotateY ?? 0,
744
+ rotateZ: lop.rotateZ ?? 0,
745
+ perspective: lop.perspective ?? 0,
746
+ perspectiveOriginX: lop.perspectiveOriginX ?? 0.5,
747
+ perspectiveOriginY: lop.perspectiveOriginY ?? 0.5,
748
+ translateZ: lop.translateZ ?? 0,
749
+ backfaceVisibility: lop.backfaceVisibility ?? "visible",
750
+ transformStyle: lop.transformStyle ?? "flat",
751
+ fontSize: lop.fontSize,
752
+ text: 1,
753
+ fill: lop.fill,
754
+ fillColor: lop.fillColor ?? lop.fill,
755
+ strokeColor: lop.strokeColor ?? lop.stroke,
756
+ strokeWidth: lop.strokeWidth,
757
+ letterSpacing: lop.letterSpacing,
758
+ firstMargin: lop.firstMargin ?? lop.pathOptions?.firstMargin,
759
+ lastMargin: lop.lastMargin ?? lop.pathOptions?.lastMargin,
760
+ baselineShift: lop.baselineShift ?? lop.pathOptions?.baselineShift,
761
+ offset: lop.offset,
762
+ rangeStart: lop.rangeStart,
763
+ rangeEnd: lop.rangeEnd,
764
+ animators: lop.animators,
765
+ pathOptions: lop.pathOptions,
766
+ typewriter: lop.typewriter,
767
+ marquee: lop.marquee,
768
+ // 3D Layer Props
769
+ is3D:
770
+ lop.is3D ??
771
+ (lop.kind === "camera" || lop.kind === "light" || lop.kind === "model3d"
772
+ ? true
773
+ : false),
774
+ z: lop.z ?? (lop.kind === "camera" ? (lop.z ?? -1500) : 0),
775
+ scaleZ: lop.scaleZ ?? 1,
776
+ twoSided: lop.twoSided ?? true,
777
+ // 3D Material Props
778
+ material: lop.material,
779
+ shininess: lop.shininess,
780
+ roughness: lop.roughness,
781
+ specularIntensity: lop.specularIntensity,
782
+ ambientIntensity: lop.ambientIntensity,
783
+ metallic: lop.metallic,
784
+ // 3D Model Props
785
+ animationProgress: lop.animationProgress ?? 0,
786
+ animationTime: lop.animationTime ?? 0,
787
+ animationSpeed: lop.animationSpeed ?? 1,
788
+ wireframe: lop.wireframe ?? false,
789
+ scaleX: lop.scaleX ?? 1,
790
+ scaleY: lop.scaleY ?? 1,
791
+ // 3D Camera Props
792
+ cameraX: lop.cameraX ?? (lop.kind === "camera" ? lop.x : undefined),
793
+ cameraY: lop.cameraY ?? (lop.kind === "camera" ? lop.y : undefined),
794
+ cameraZ: lop.cameraZ ?? (lop.kind === "camera" ? lop.z : undefined),
795
+ targetX: lop.targetX,
796
+ targetY: lop.targetY,
797
+ targetZ: lop.targetZ,
798
+ cameraPitch: lop.cameraPitch ?? lop.pitch ?? 0,
799
+ cameraYaw: lop.cameraYaw ?? lop.yaw ?? 0,
800
+ cameraRoll: lop.cameraRoll ?? lop.roll ?? 0,
801
+ cameraFov: lop.cameraFov ?? lop.lens?.fov ?? 50,
802
+ cameraZoom: lop.cameraZoom ?? lop.lens?.zoom ?? 1,
803
+ focalLength: lop.focalLength ?? lop.lens?.focalLength ?? 50,
804
+ focusDistance: lop.focusDistance ?? lop.dof?.focusDistance,
805
+ fStop: lop.fStop ?? lop.dof?.fStop ?? 2.8,
806
+ maxBlurRadius: lop.maxBlurRadius ?? lop.dof?.maxBlurRadius ?? 24,
807
+ shakeTranslation:
808
+ lop.shakeTranslation ??
809
+ (lop.shake?.enabled ? (lop.shake?.translationAmplitude ?? 0) : 0),
810
+ shakeRotation:
811
+ lop.shakeRotation ??
812
+ (lop.shake?.enabled ? (lop.shake?.rotationAmplitude ?? 0) : 0),
813
+ orbitRadius: lop.orbitRadius,
814
+ orbitAzimuth: lop.orbitAzimuth,
815
+ orbitElevation: lop.orbitElevation,
816
+ // 3D Light Props
817
+ lightType: lop.lightType,
818
+ intensity: lop.intensity ?? (lop.kind === "light" ? 1 : undefined),
819
+ radius: lop.radius,
820
+ angle: lop.angle,
821
+ penumbra: lop.penumbra,
822
+ decay: lop.decay,
823
+ };
824
+
825
+ for (const track of lop.animation?.tracks ?? []) {
826
+ const source = track.source ?? { type: "keyframe" };
827
+ if (source.type !== "keyframe") {
828
+ (stub as Record<string, unknown>)[track.prop] = evaluateTrackAtFrame(
829
+ track,
830
+ lop.startFrame ?? 0,
831
+ fps,
832
+ { startFrame: lop.startFrame ?? 0, baseValues: stub, signals },
833
+ );
834
+ } else if (track.keyframes && track.keyframes.length > 0) {
835
+ const kf0 = track.keyframes[0];
836
+ const seeded = stub as Record<string, unknown>;
837
+ if (kf0.frame === 0) {
838
+ seeded[track.prop] = kf0.value;
839
+ } else if (seeded[track.prop] === undefined) {
840
+ // GSAP only tweens props that exist on its target; props the stub
841
+ // doesn't model (trimEnd, borderWidth, …) start from the node's own
842
+ // value, else hold the first key until it's reached.
843
+ seeded[track.prop] = lop[track.prop] ?? kf0.value;
844
+ }
845
+ }
846
+ }
847
+
848
+ targetsById[layerId] = stub;
849
+ });
850
+
851
+ // M8: keyframes beyond the clip window are clamped, but silently — a track
852
+ // whose tail was truncated plays a static value with no diagnostic. Collect
853
+ // them here and warn ONCE per compiled cache key (per-frame seeks hit the
854
+ // cache, so this is naturally once per render/preview session).
855
+ const truncations: TruncationInfo[] = [];
856
+
857
+ nodeOps.forEach((vm) => {
858
+ const lop = (vm?.operation || {}) as any;
859
+ const layerId = lop.id || lop.inputHandleId;
860
+ const stub = layerId ? targetsById[layerId] : undefined;
861
+ if (!stub) return;
862
+
863
+ const startFrame = lop.startFrame ?? 0;
864
+ let durationFrames = lop.durationFrames;
865
+ if (durationFrames === undefined) {
866
+ const child = vm.children?.[0];
867
+ const childMeta = child ? getActiveMediaMetadata(child as any) : null;
868
+ const composeDuration =
869
+ (virtualMedia as any)?.metadata?.durationMs ?? DEFAULT_DURATION_MS;
870
+ const layerType =
871
+ lop.type ??
872
+ (lop.kind === "text"
873
+ ? "Text"
874
+ : lop.kind === "media"
875
+ ? child
876
+ ? (child.operation as any)?.dataType
877
+ : undefined
878
+ : undefined);
879
+ const durationMs = resolveLayerDuration(
880
+ undefined,
881
+ childMeta?.durationMs ?? undefined,
882
+ composeDuration,
883
+ layerType,
884
+ );
885
+ durationFrames = Math.max(1, Math.round((durationMs / 1000) * fps));
886
+ }
887
+
888
+ for (const track of lop.animation?.tracks ?? []) {
889
+ const { dropped, maxAuthoredFrame } = compileTrackToTimeline(
890
+ tl,
891
+ stub,
892
+ track,
893
+ startFrame,
894
+ fps,
895
+ durationFrames,
896
+ signals,
897
+ );
898
+ if (dropped > 0) {
899
+ truncations.push({
900
+ layerId,
901
+ trackId: track.id,
902
+ prop: track.prop,
903
+ dropped,
904
+ maxAuthoredFrame,
905
+ });
906
+ }
907
+ }
908
+ });
909
+
910
+ if (truncations.length > 0) {
911
+ warnTruncationOnce(
912
+ renderId.startsWith("img-") ? `frame_${irHash}` : cacheKey,
913
+ truncations,
914
+ );
915
+ for (const t of truncations) {
916
+ reportRenderDiagnostic(renderId, {
917
+ severity: "warning",
918
+ code: "animation_truncated",
919
+ layerId: t.layerId,
920
+ message: `'${t.prop}' on '${t.layerId}': ${t.dropped} keyframe(s) past the clip window (last authored frame ${t.maxAuthoredFrame}) never play`,
921
+ });
922
+ }
923
+ }
924
+
925
+ tl.seek(1e-6);
926
+ tl.seek(0);
927
+
928
+ const compileResult: CompileResult = {
929
+ tl,
930
+ targetsById,
931
+ nodes: nodeOps,
932
+ };
933
+
934
+ setCachedTimeline(cacheKey, compileResult);
935
+
936
+ return compileResult;
937
+ }
938
+
939
+ const layerTimelineCache = new Map<
940
+ string,
941
+ {
942
+ cacheTime: number;
943
+ value: {
944
+ tl: gsap.core.Timeline;
945
+ stub: { volume: number; muted: boolean };
946
+ };
947
+ }
948
+ >();
949
+
950
+ export function compileLayerTimeline(
951
+ layerId: string,
952
+ animation: LayerAnimationSpec | undefined,
953
+ baseVolume: number,
954
+ baseMuted: boolean,
955
+ fps: number,
956
+ ): { tl: gsap.core.Timeline; stub: { volume: number; muted: boolean } } {
957
+ const irHashInput = stableStringify({
958
+ animation,
959
+ baseVolume,
960
+ baseMuted,
961
+ fps,
962
+ });
963
+ const irHash = fnv1a(irHashInput);
964
+ const cacheKey = `layer_${layerId}_${irHash}`;
965
+
966
+ const cached = layerTimelineCache.get(cacheKey);
967
+ if (cached) {
968
+ if (Date.now() - cached.cacheTime <= CACHE_TTL_MS) {
969
+ cached.cacheTime = Date.now();
970
+ return cached.value;
971
+ }
972
+ layerTimelineCache.delete(cacheKey);
973
+ release(cached.value);
974
+ }
975
+
976
+ const tl = gsap.timeline({ paused: true, smoothChildTiming: false });
977
+ const stub = {
978
+ volume: baseVolume,
979
+ muted: baseMuted,
980
+ };
981
+
982
+ const tracks: AnimationTrack[] = animation?.tracks ?? [];
983
+ for (const track of tracks) {
984
+ const prop = track.prop;
985
+ if (prop !== "volume" && prop !== "muted") continue;
986
+ compileTrackToTimeline(tl, stub, track, 0, fps, Infinity);
987
+ }
988
+
989
+ tl.seek(1e-6);
990
+ tl.seek(0);
991
+
992
+ const result = { tl, stub };
993
+ evictForInsert(layerTimelineCache, LAYER_TIMELINE_CACHE_MAX);
994
+ layerTimelineCache.set(cacheKey, { cacheTime: Date.now(), value: result });
995
+ return result;
996
+ }