@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,1309 @@
1
+ import gsapModule from "gsap";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { compileLayerTimeline, compileTimeline } from "./compiler.js";
4
+
5
+ const gsap: any = (gsapModule as any).timeline
6
+ ? gsapModule
7
+ : (gsapModule as any).default || gsapModule;
8
+
9
+ describe("Compositor GSAP Compiler", () => {
10
+ it("should compile a 2-keyframe fromTo tween correctly", () => {
11
+ const virtualMedia = {
12
+ operation: {
13
+ op: "Compositor",
14
+ width: 1920,
15
+ height: 1080,
16
+ },
17
+ children: [
18
+ {
19
+ operation: {
20
+ op: "CompositorLayer",
21
+ id: "layer-1",
22
+ x: 100,
23
+ startFrame: 0,
24
+ durationFrames: 100,
25
+ animation: {
26
+ tracks: [
27
+ {
28
+ id: "track-1",
29
+ prop: "x" as const,
30
+ keyframes: [
31
+ { id: "kf-1", frame: 0, value: 0 },
32
+ {
33
+ id: "kf-2",
34
+ frame: 24,
35
+ value: 200,
36
+ ease: { name: "none" as const, dir: "out" as const },
37
+ },
38
+ ],
39
+ },
40
+ ],
41
+ },
42
+ },
43
+ },
44
+ ],
45
+ };
46
+
47
+ const { tl, targetsById } = compileTimeline("render-1", virtualMedia, {
48
+ fps: 24,
49
+ durationSec: 5,
50
+ });
51
+ const stub = targetsById["layer-1"];
52
+ expect(stub).toBeDefined();
53
+
54
+ // Seek to frame 0
55
+ tl.seek(0);
56
+ expect(stub.x).toBe(0);
57
+
58
+ // Seek to frame 12 (halfway, linear)
59
+ tl.seek(12 / 24);
60
+ expect(stub.x).toBe(100);
61
+
62
+ // Seek to frame 24 (end of tween)
63
+ tl.seek(24 / 24);
64
+ expect(stub.x).toBe(200);
65
+
66
+ // Seek to frame 48 (should hold last value)
67
+ tl.seek(48 / 24);
68
+ expect(stub.x).toBe(200);
69
+ });
70
+
71
+ it("busts the compile cache when a layer fontSize changes", () => {
72
+ // The compiled LayerStub carries fontSize, and both the layout measure
73
+ // and the text draw prefer the stub's value over the live op — so a
74
+ // fontSize edit MUST invalidate the LRU cache or the stale size is
75
+ // used for the full TTL. Regression for the editor's
76
+ // "font size doesn't re-render" bug.
77
+ const makeMedia = (fontSize: number) => ({
78
+ operation: { op: "Compositor", width: 1920, height: 1080 },
79
+ children: [
80
+ {
81
+ operation: {
82
+ op: "CompositorLayer",
83
+ id: "text-1",
84
+ kind: "text",
85
+ fontSize,
86
+ startFrame: 0,
87
+ durationFrames: 100,
88
+ },
89
+ },
90
+ ],
91
+ });
92
+
93
+ const { targetsById: first } = compileTimeline(
94
+ "render-fontsize-cache",
95
+ makeMedia(24),
96
+ { fps: 24, durationSec: 5 },
97
+ );
98
+ expect(first["text-1"]?.fontSize).toBe(24);
99
+
100
+ // Same renderId, only fontSize changed: the cache key must change.
101
+ const { targetsById: second } = compileTimeline(
102
+ "render-fontsize-cache",
103
+ makeMedia(48),
104
+ { fps: 24, durationSec: 5 },
105
+ );
106
+ expect(second["text-1"]?.fontSize).toBe(48);
107
+ });
108
+
109
+ it("should compile a 2-keyframe to tween correctly (when start matches base)", () => {
110
+ const virtualMedia = {
111
+ operation: {
112
+ op: "Compositor",
113
+ width: 1920,
114
+ height: 1080,
115
+ },
116
+ children: [
117
+ {
118
+ operation: {
119
+ op: "CompositorLayer",
120
+ id: "layer-1",
121
+ x: 100,
122
+ startFrame: 0,
123
+ durationFrames: 100,
124
+ animation: {
125
+ tracks: [
126
+ {
127
+ id: "track-1",
128
+ prop: "x" as const,
129
+ keyframes: [
130
+ { id: "kf-1", frame: 0, value: 100 },
131
+ {
132
+ id: "kf-2",
133
+ frame: 24,
134
+ value: 200,
135
+ ease: { name: "none" as const, dir: "out" as const },
136
+ },
137
+ ],
138
+ },
139
+ ],
140
+ },
141
+ },
142
+ },
143
+ ],
144
+ };
145
+
146
+ const { tl, targetsById } = compileTimeline("render-2", virtualMedia, {
147
+ fps: 24,
148
+ durationSec: 5,
149
+ });
150
+ const stub = targetsById["layer-1"];
151
+ expect(stub).toBeDefined();
152
+
153
+ tl.seek(0);
154
+ expect(stub.x).toBe(100);
155
+
156
+ tl.seek(12 / 24);
157
+ expect(stub.x).toBe(150);
158
+
159
+ tl.seek(24 / 24);
160
+ expect(stub.x).toBe(200);
161
+ });
162
+
163
+ it("should compile a multi-keyframe track using keyframes array correctly", () => {
164
+ const virtualMedia = {
165
+ operation: {
166
+ op: "Compositor",
167
+ width: 1920,
168
+ height: 1080,
169
+ },
170
+ children: [
171
+ {
172
+ operation: {
173
+ op: "CompositorLayer",
174
+ id: "layer-1",
175
+ x: 100,
176
+ startFrame: 0,
177
+ durationFrames: 100,
178
+ animation: {
179
+ tracks: [
180
+ {
181
+ id: "track-1",
182
+ prop: "x" as const,
183
+ keyframes: [
184
+ { id: "kf-1", frame: 0, value: 0 },
185
+ {
186
+ id: "kf-2",
187
+ frame: 12,
188
+ value: 100,
189
+ ease: { name: "none" as const, dir: "out" as const },
190
+ },
191
+ {
192
+ id: "kf-3",
193
+ frame: 36,
194
+ value: 300,
195
+ ease: { name: "none" as const, dir: "out" as const },
196
+ },
197
+ ],
198
+ },
199
+ ],
200
+ },
201
+ },
202
+ },
203
+ ],
204
+ };
205
+
206
+ const { tl, targetsById } = compileTimeline("render-3", virtualMedia, {
207
+ fps: 24,
208
+ durationSec: 5,
209
+ });
210
+ const stub = targetsById["layer-1"];
211
+ expect(stub).toBeDefined();
212
+
213
+ tl.seek(0);
214
+ expect(stub.x).toBe(0);
215
+
216
+ // Frame 6 (halfway to frame 12, 0 -> 100)
217
+ tl.seek(6 / 24);
218
+ expect(stub.x).toBe(50);
219
+
220
+ // Frame 12
221
+ tl.seek(12 / 24);
222
+ expect(stub.x).toBe(100);
223
+
224
+ // Frame 24 (halfway from 12 -> 36, 100 -> 300)
225
+ tl.seek(24 / 24);
226
+ expect(stub.x).toBe(200);
227
+
228
+ // Frame 36
229
+ tl.seek(36 / 24);
230
+ expect(stub.x).toBe(300);
231
+ });
232
+
233
+ it("should compile discrete properties hidden and muted correctly", () => {
234
+ const virtualMedia = {
235
+ operation: {
236
+ op: "Compositor",
237
+ width: 1920,
238
+ height: 1080,
239
+ },
240
+ children: [
241
+ {
242
+ operation: {
243
+ op: "CompositorLayer",
244
+ id: "layer-1",
245
+ hidden: false,
246
+ muted: false,
247
+ startFrame: 0,
248
+ durationFrames: 100,
249
+ animation: {
250
+ tracks: [
251
+ {
252
+ id: "track-h",
253
+ prop: "hidden" as const,
254
+ keyframes: [
255
+ { id: "kf-h1", frame: 12, value: true },
256
+ { id: "kf-h2", frame: 24, value: false },
257
+ ],
258
+ },
259
+ ],
260
+ },
261
+ },
262
+ },
263
+ ],
264
+ };
265
+
266
+ const { tl, targetsById } = compileTimeline("render-4", virtualMedia, {
267
+ fps: 24,
268
+ durationSec: 5,
269
+ });
270
+ const stub = targetsById["layer-1"];
271
+ expect(stub).toBeDefined();
272
+
273
+ tl.seek(0);
274
+ expect(stub.hidden).toBe(false);
275
+
276
+ tl.seek(11 / 24);
277
+ expect(stub.hidden).toBe(false);
278
+
279
+ tl.seek(13 / 24);
280
+ expect(stub.hidden).toBe(true);
281
+
282
+ tl.seek(23 / 24);
283
+ expect(stub.hidden).toBe(true);
284
+
285
+ tl.seek(25 / 24);
286
+ expect(stub.hidden).toBe(false);
287
+ });
288
+
289
+ it("should support repeat and yoyo settings on numeric tracks", () => {
290
+ const virtualMedia = {
291
+ operation: {
292
+ op: "Compositor",
293
+ width: 1920,
294
+ height: 1080,
295
+ },
296
+ children: [
297
+ {
298
+ operation: {
299
+ op: "CompositorLayer",
300
+ id: "layer-1",
301
+ x: 0,
302
+ startFrame: 0,
303
+ durationFrames: 100,
304
+ animation: {
305
+ tracks: [
306
+ {
307
+ id: "track-1",
308
+ prop: "x" as const,
309
+ repeat: 1,
310
+ yoyo: true,
311
+ keyframes: [
312
+ { id: "kf-1", frame: 0, value: 0 },
313
+ {
314
+ id: "kf-2",
315
+ frame: 12,
316
+ value: 100,
317
+ ease: { name: "none" as const, dir: "out" as const },
318
+ },
319
+ ],
320
+ },
321
+ ],
322
+ },
323
+ },
324
+ },
325
+ ],
326
+ };
327
+
328
+ const { tl, targetsById } = compileTimeline("render-5", virtualMedia, {
329
+ fps: 24,
330
+ durationSec: 5,
331
+ });
332
+ const stub = targetsById["layer-1"];
333
+ expect(stub).toBeDefined();
334
+
335
+ // Start (0s)
336
+ tl.seek(0);
337
+ expect(stub.x).toBe(0);
338
+
339
+ // Peak of loop 1 (12f / 0.5s)
340
+ tl.seek(12 / 24);
341
+ expect(stub.x).toBe(100);
342
+
343
+ // Loop 2 starts returning because of yoyo (18f / 0.75s)
344
+ tl.seek(18 / 24);
345
+ expect(stub.x).toBe(50);
346
+
347
+ // Loop 2 finishes returning to 0 (24f / 1.0s)
348
+ tl.seek(24 / 24);
349
+ expect(stub.x).toBe(0);
350
+ });
351
+
352
+ it("should place tweens at absolute position based on layer startFrame", () => {
353
+ const virtualMedia = {
354
+ operation: {
355
+ op: "Compositor",
356
+ width: 1920,
357
+ height: 1080,
358
+ },
359
+ children: [
360
+ {
361
+ operation: {
362
+ op: "CompositorLayer",
363
+ id: "layer-1",
364
+ x: 100,
365
+ startFrame: 24, // starts at 1.0s
366
+ durationFrames: 100,
367
+ animation: {
368
+ tracks: [
369
+ {
370
+ id: "track-1",
371
+ prop: "x" as const,
372
+ keyframes: [
373
+ { id: "kf-1", frame: 0, value: 100 },
374
+ {
375
+ id: "kf-2",
376
+ frame: 24,
377
+ value: 200,
378
+ ease: { name: "none" as const, dir: "out" as const },
379
+ },
380
+ ],
381
+ },
382
+ ],
383
+ },
384
+ },
385
+ },
386
+ ],
387
+ };
388
+
389
+ const { tl, targetsById } = compileTimeline("render-6", virtualMedia, {
390
+ fps: 24,
391
+ durationSec: 5,
392
+ });
393
+ const stub = targetsById["layer-1"];
394
+ expect(stub).toBeDefined();
395
+
396
+ // Seek before layer starts (e.g. 0.5s)
397
+ tl.seek(12 / 24);
398
+ expect(stub.x).toBe(100);
399
+
400
+ // Seek at layer start (1.0s)
401
+ tl.seek(24 / 24);
402
+ expect(stub.x).toBe(100);
403
+
404
+ // Seek during tween (1.5s / 36f absolute)
405
+ tl.seek(36 / 24);
406
+ expect(stub.x).toBe(150);
407
+
408
+ // Seek at tween end (2.0s / 48f absolute)
409
+ tl.seek(48 / 24);
410
+ expect(stub.x).toBe(200);
411
+ });
412
+
413
+ it("should hold the correct base/pre-seeded values at limits", () => {
414
+ const virtualMedia = {
415
+ operation: {
416
+ op: "Compositor",
417
+ width: 1920,
418
+ height: 1080,
419
+ },
420
+ children: [
421
+ {
422
+ operation: {
423
+ op: "CompositorLayer",
424
+ id: "layer-1",
425
+ x: 100,
426
+ startFrame: 0,
427
+ durationFrames: 100,
428
+ animation: {
429
+ tracks: [
430
+ {
431
+ id: "track-1",
432
+ prop: "x" as const,
433
+ keyframes: [
434
+ { id: "kf-1", frame: 12, value: 200 },
435
+ {
436
+ id: "kf-2",
437
+ frame: 24,
438
+ value: 300,
439
+ ease: { name: "none" as const, dir: "out" as const },
440
+ },
441
+ ],
442
+ },
443
+ ],
444
+ },
445
+ },
446
+ },
447
+ ],
448
+ };
449
+
450
+ const { tl, targetsById } = compileTimeline("render-7", virtualMedia, {
451
+ fps: 24,
452
+ durationSec: 5,
453
+ });
454
+ const stub = targetsById["layer-1"];
455
+ expect(stub).toBeDefined();
456
+
457
+ // Before first keyframe (at frame 6), should hold the layer base value 100
458
+ tl.seek(6 / 24);
459
+ expect(stub.x).toBe(100);
460
+
461
+ // At first keyframe frame 12
462
+ tl.seek(12 / 24);
463
+ expect(stub.x).toBe(200);
464
+
465
+ // During tween
466
+ tl.seek(18 / 24);
467
+ expect(stub.x).toBe(250);
468
+
469
+ // At last keyframe
470
+ tl.seek(24 / 24);
471
+ expect(stub.x).toBe(300);
472
+
473
+ // After last keyframe (at frame 48), should hold the last keyframe value
474
+ tl.seek(48 / 24);
475
+ expect(stub.x).toBe(300);
476
+ });
477
+
478
+ it("should support spring easing function solver", () => {
479
+ const virtualMedia = {
480
+ operation: {
481
+ op: "Compositor",
482
+ width: 1920,
483
+ height: 1080,
484
+ },
485
+ children: [
486
+ {
487
+ operation: {
488
+ op: "CompositorLayer",
489
+ id: "layer-1",
490
+ x: 0,
491
+ startFrame: 0,
492
+ durationFrames: 100,
493
+ animation: {
494
+ tracks: [
495
+ {
496
+ id: "track-1",
497
+ prop: "x" as const,
498
+ keyframes: [
499
+ { id: "kf-1", frame: 0, value: 0 },
500
+ {
501
+ id: "kf-2",
502
+ frame: 24,
503
+ value: 100,
504
+ ease: {
505
+ name: "spring" as const,
506
+ dir: "out" as const,
507
+ params: [10, 100, 1],
508
+ },
509
+ },
510
+ ],
511
+ },
512
+ ],
513
+ },
514
+ },
515
+ },
516
+ ],
517
+ };
518
+
519
+ const { tl, targetsById } = compileTimeline("render-9", virtualMedia, {
520
+ fps: 24,
521
+ durationSec: 5,
522
+ });
523
+ const stub = targetsById["layer-1"];
524
+ expect(stub).toBeDefined();
525
+
526
+ tl.seek(0);
527
+ expect(stub.x).toBe(0);
528
+
529
+ tl.seek(24 / 24);
530
+ expect(stub.x).toBeCloseTo(100, 0);
531
+ });
532
+
533
+ it("should support spring easing function solver with custom FPS", () => {
534
+ const virtualMedia = {
535
+ operation: {
536
+ op: "Compositor",
537
+ width: 1920,
538
+ height: 1080,
539
+ },
540
+ children: [
541
+ {
542
+ operation: {
543
+ op: "CompositorLayer",
544
+ id: "layer-1",
545
+ x: 0,
546
+ startFrame: 0,
547
+ durationFrames: 100,
548
+ animation: {
549
+ tracks: [
550
+ {
551
+ id: "track-1",
552
+ prop: "x" as const,
553
+ keyframes: [
554
+ { id: "kf-1", frame: 0, value: 0 },
555
+ {
556
+ id: "kf-2",
557
+ frame: 60,
558
+ value: 100,
559
+ ease: {
560
+ name: "spring" as const,
561
+ dir: "out" as const,
562
+ params: [10, 100, 1],
563
+ },
564
+ },
565
+ ],
566
+ },
567
+ ],
568
+ },
569
+ },
570
+ },
571
+ ],
572
+ };
573
+
574
+ const { tl, targetsById } = compileTimeline(
575
+ "render-9-fps-60",
576
+ virtualMedia,
577
+ {
578
+ fps: 60,
579
+ durationSec: 5,
580
+ },
581
+ );
582
+ const stub = targetsById["layer-1"];
583
+ expect(stub).toBeDefined();
584
+
585
+ tl.seek(0);
586
+ expect(stub.x).toBe(0);
587
+
588
+ tl.seek(60 / 60);
589
+ expect(stub.x).toBeCloseTo(100, 0);
590
+ });
591
+ });
592
+
593
+ describe("Compositor Layer Timeline Compiler", () => {
594
+ it("should compile and interpolate volume keyframes correctly", () => {
595
+ const animation = {
596
+ tracks: [
597
+ {
598
+ id: "track-vol",
599
+ prop: "volume" as const,
600
+ keyframes: [
601
+ { id: "kf-1", frame: 0, value: 0.5 },
602
+ {
603
+ id: "kf-2",
604
+ frame: 24,
605
+ value: 1.0,
606
+ ease: { name: "none" as const, dir: "out" as const },
607
+ },
608
+ ],
609
+ },
610
+ ],
611
+ };
612
+
613
+ const { tl, stub } = compileLayerTimeline(
614
+ "layer-1",
615
+ animation,
616
+ 0.5,
617
+ false,
618
+ 24,
619
+ );
620
+ expect(stub).toBeDefined();
621
+
622
+ // Seek to frame 0
623
+ tl.seek(0);
624
+ expect(stub.volume).toBe(0.5);
625
+
626
+ // Seek to frame 12 (halfway, linear)
627
+ tl.seek(12 / 24);
628
+ expect(stub.volume).toBeCloseTo(0.75, 4);
629
+
630
+ // Seek to frame 24
631
+ tl.seek(24 / 24);
632
+ expect(stub.volume).toBe(1.0);
633
+ });
634
+
635
+ it("should handle muted keyframes correctly", () => {
636
+ const animation = {
637
+ tracks: [
638
+ {
639
+ id: "track-mute",
640
+ prop: "muted" as const,
641
+ keyframes: [
642
+ { id: "kf-1", frame: 0, value: false },
643
+ { id: "kf-2", frame: 12, value: true },
644
+ { id: "kf-3", frame: 24, value: false },
645
+ ],
646
+ },
647
+ ],
648
+ };
649
+
650
+ const { tl, stub } = compileLayerTimeline(
651
+ "layer-1",
652
+ animation,
653
+ 1.0,
654
+ false,
655
+ 24,
656
+ );
657
+
658
+ tl.seek(0);
659
+ expect(stub.muted).toBe(false);
660
+
661
+ tl.seek(12 / 24);
662
+ expect(stub.muted).toBe(true);
663
+
664
+ tl.seek(24 / 24);
665
+ expect(stub.muted).toBe(false);
666
+ });
667
+
668
+ it("should compile and interpolate fontSize keyframes correctly", () => {
669
+ const virtualMedia = {
670
+ operation: {
671
+ op: "Compositor",
672
+ width: 1920,
673
+ height: 1080,
674
+ },
675
+ children: [
676
+ {
677
+ operation: {
678
+ op: "CompositorLayer",
679
+ id: "layer-1",
680
+ fontSize: 40,
681
+ startFrame: 0,
682
+ durationFrames: 100,
683
+ animation: {
684
+ tracks: [
685
+ {
686
+ id: "track-fontsize",
687
+ prop: "fontSize" as const,
688
+ keyframes: [
689
+ { id: "kf-1", frame: 0, value: 40 },
690
+ {
691
+ id: "kf-2",
692
+ frame: 24,
693
+ value: 80,
694
+ ease: { name: "none" as const, dir: "out" as const },
695
+ },
696
+ ],
697
+ },
698
+ ],
699
+ },
700
+ },
701
+ },
702
+ ],
703
+ };
704
+
705
+ const { tl, targetsById } = compileTimeline(
706
+ "render-fontsize",
707
+ virtualMedia,
708
+ {
709
+ fps: 24,
710
+ durationSec: 5,
711
+ },
712
+ );
713
+ const stub = targetsById["layer-1"];
714
+ expect(stub).toBeDefined();
715
+
716
+ tl.seek(0);
717
+ expect(stub.fontSize).toBe(40);
718
+
719
+ // Seek to frame 12 (halfway, linear interpolation)
720
+ tl.seek(12 / 24);
721
+ expect(stub.fontSize).toBe(60);
722
+
723
+ // Seek to frame 24 (end)
724
+ tl.seek(24 / 24);
725
+ expect(stub.fontSize).toBe(80);
726
+ });
727
+
728
+ it("should gracefully handle and clamp invalid spring easing parameters to prevent NaN output", () => {
729
+ const virtualMedia = {
730
+ operation: {
731
+ op: "Compositor",
732
+ width: 1920,
733
+ height: 1080,
734
+ },
735
+ children: [
736
+ {
737
+ operation: {
738
+ op: "CompositorLayer",
739
+ id: "layer-1",
740
+ x: 0,
741
+ startFrame: 0,
742
+ durationFrames: 100,
743
+ animation: {
744
+ tracks: [
745
+ {
746
+ id: "track-1",
747
+ prop: "x" as const,
748
+ keyframes: [
749
+ { id: "kf-1", frame: 0, value: 0 },
750
+ {
751
+ id: "kf-2",
752
+ frame: 24,
753
+ value: 100,
754
+ ease: {
755
+ name: "spring" as const,
756
+ dir: "out" as const,
757
+ params: [0, -5, 0],
758
+ },
759
+ },
760
+ ],
761
+ },
762
+ ],
763
+ },
764
+ },
765
+ },
766
+ ],
767
+ };
768
+
769
+ const { tl, targetsById } = compileTimeline(
770
+ "render-invalid-spring",
771
+ virtualMedia,
772
+ {
773
+ fps: 24,
774
+ durationSec: 5,
775
+ },
776
+ );
777
+ const stub = targetsById["layer-1"];
778
+ expect(stub).toBeDefined();
779
+
780
+ tl.seek(0);
781
+ expect(Number.isNaN(stub.x)).toBe(false);
782
+
783
+ tl.seek(12 / 24);
784
+ expect(Number.isNaN(stub.x)).toBe(false);
785
+
786
+ tl.seek(24 / 24);
787
+ expect(Number.isNaN(stub.x)).toBe(false);
788
+ });
789
+ });
790
+
791
+ describe("keyframe tail truncation (review M8)", () => {
792
+ afterEach(() => {
793
+ vi.restoreAllMocks();
794
+ });
795
+
796
+ const spyWarn = () => vi.spyOn(console, "warn").mockImplementation(() => {});
797
+
798
+ const vmWithTrack = (
799
+ keyframes: Array<{ frame: number; value: number }>,
800
+ durationFrames: number,
801
+ ) => ({
802
+ operation: { op: "Compositor", width: 1920, height: 1080 },
803
+ children: [
804
+ {
805
+ operation: {
806
+ op: "CompositorLayer",
807
+ id: "layer-1",
808
+ startFrame: 0,
809
+ durationFrames,
810
+ animation: {
811
+ tracks: [
812
+ {
813
+ id: "track-1",
814
+ prop: "x" as const,
815
+ keyframes: keyframes.map((kf, i) => ({
816
+ id: `kf-${i}`,
817
+ ...kf,
818
+ })),
819
+ },
820
+ ],
821
+ },
822
+ },
823
+ },
824
+ ],
825
+ });
826
+
827
+ it("warns and holds the last playable value when keyframes exceed durationFrames", () => {
828
+ const warn = spyWarn();
829
+ const { tl, targetsById } = compileTimeline(
830
+ "m8-truncate-tail",
831
+ vmWithTrack(
832
+ [
833
+ { frame: 0, value: 0 },
834
+ { frame: 12, value: 50 },
835
+ { frame: 48, value: 100 },
836
+ ],
837
+ 24,
838
+ ),
839
+ { fps: 24, durationSec: 5 },
840
+ );
841
+ const stub = targetsById["layer-1"];
842
+
843
+ tl.seek(12 / 24);
844
+ expect(stub.x).toBe(50); // tween to the last playable keyframe
845
+ tl.seek(24 / 24);
846
+ expect(stub.x).toBe(50); // tail beyond the clip holds — no jump to 100
847
+
848
+ expect(warn).toHaveBeenCalledTimes(1);
849
+ const msg = String(warn.mock.calls[0][0]);
850
+ expect(msg).toContain("truncated");
851
+ expect(msg).toContain("track-1");
852
+ expect(msg).toContain("layer-1");
853
+ });
854
+
855
+ it("degrades to a static set when only the first keyframe survives", () => {
856
+ const warn = spyWarn();
857
+ const { tl, targetsById } = compileTimeline(
858
+ "m8-static-degrade",
859
+ vmWithTrack(
860
+ [
861
+ { frame: 0, value: 10 },
862
+ { frame: 500, value: 90 },
863
+ ],
864
+ 100,
865
+ ),
866
+ { fps: 24, durationSec: 5 },
867
+ );
868
+ const stub = targetsById["layer-1"];
869
+
870
+ tl.seek(100 / 24);
871
+ expect(stub.x).toBe(10); // never animates — static set, now with a warning
872
+
873
+ expect(warn).toHaveBeenCalledTimes(1);
874
+ expect(String(warn.mock.calls[0][0])).toContain("1 keyframe(s)");
875
+ });
876
+
877
+ it("does not warn when all keyframes fit the clip window", () => {
878
+ const warn = spyWarn();
879
+ compileTimeline(
880
+ "m8-no-truncation",
881
+ vmWithTrack(
882
+ [
883
+ { frame: 0, value: 0 },
884
+ { frame: 24, value: 100 },
885
+ ],
886
+ 24,
887
+ ),
888
+ { fps: 24, durationSec: 5 },
889
+ );
890
+ expect(warn).not.toHaveBeenCalled();
891
+ });
892
+
893
+ it("warns only once per cache key (per-frame seeks reuse the cached timeline)", () => {
894
+ const warn = spyWarn();
895
+ const input = vmWithTrack(
896
+ [
897
+ { frame: 0, value: 0 },
898
+ { frame: 30, value: 100 },
899
+ ],
900
+ 24,
901
+ );
902
+ compileTimeline("m8-dedupe", input, { fps: 24, durationSec: 5 });
903
+ compileTimeline("m8-dedupe", input, { fps: 24, durationSec: 5 });
904
+ expect(warn).toHaveBeenCalledTimes(1);
905
+ });
906
+
907
+ it("re-warns for a fresh cache key (a new render of the same broken doc)", () => {
908
+ const warn = spyWarn();
909
+ const input = vmWithTrack(
910
+ [
911
+ { frame: 0, value: 0 },
912
+ { frame: 30, value: 100 },
913
+ ],
914
+ 24,
915
+ );
916
+ compileTimeline("m8-fresh-1", input, { fps: 24, durationSec: 5 });
917
+ compileTimeline("m8-fresh-2", input, { fps: 24, durationSec: 5 });
918
+ expect(warn).toHaveBeenCalledTimes(2);
919
+ });
920
+
921
+ it("warns once across single-frame renders of the same doc (frame-by-frame probes)", () => {
922
+ const warn = spyWarn();
923
+ const input = vmWithTrack(
924
+ [
925
+ { frame: 0, value: 0 },
926
+ { frame: 31, value: 100 },
927
+ ],
928
+ 24,
929
+ );
930
+ for (const id of ["img-probe-1", "img-probe-2", "img-probe-3"]) {
931
+ compileTimeline(id, input, { fps: 24, durationSec: 5 });
932
+ }
933
+ expect(warn).toHaveBeenCalledTimes(1);
934
+ });
935
+
936
+ it("compiles and evaluates text reveal animation tracks accurately", () => {
937
+ const virtualMedia = {
938
+ operation: { op: "Compositor", width: 1920, height: 1080 },
939
+ children: [
940
+ {
941
+ operation: {
942
+ op: "CompositorLayer",
943
+ id: "text-layer-1",
944
+ kind: "text",
945
+ text: "Hello World",
946
+ startFrame: 0,
947
+ durationFrames: 60,
948
+ animation: {
949
+ tracks: [
950
+ {
951
+ id: "track-text-1",
952
+ prop: "text" as const,
953
+ keyframes: [
954
+ {
955
+ id: "kf-1",
956
+ frame: 0,
957
+ value: 0,
958
+ presetType: "typewriter",
959
+ },
960
+ {
961
+ id: "kf-2",
962
+ frame: 24,
963
+ value: 1,
964
+ ease: { name: "none" as const, dir: "out" as const },
965
+ presetType: "typewriter",
966
+ },
967
+ ],
968
+ },
969
+ ],
970
+ },
971
+ },
972
+ },
973
+ ],
974
+ };
975
+
976
+ const { tl, targetsById } = compileTimeline(
977
+ "render-text-anim",
978
+ virtualMedia,
979
+ {
980
+ fps: 24,
981
+ durationSec: 5,
982
+ },
983
+ );
984
+
985
+ const stub = targetsById["text-layer-1"];
986
+ expect(stub).toBeDefined();
987
+
988
+ // Frame 0: 0 progress
989
+ tl.seek(0);
990
+ expect(stub.text).toBe(0);
991
+
992
+ // Frame 12: 0.5 progress
993
+ tl.seek(12 / 24);
994
+ expect(stub.text).toBeCloseTo(0.5, 2);
995
+
996
+ // Frame 24: 1.0 progress
997
+ tl.seek(24 / 24);
998
+ expect(stub.text).toBe(1);
999
+
1000
+ // Frame 48: stays 1.0
1001
+ tl.seek(48 / 24);
1002
+ expect(stub.text).toBe(1);
1003
+ });
1004
+
1005
+ it("busts timeline cache when text string changes", () => {
1006
+ const makeMedia = (text: string) => ({
1007
+ operation: { op: "Compositor", width: 1920, height: 1080 },
1008
+ children: [
1009
+ {
1010
+ operation: {
1011
+ op: "CompositorLayer",
1012
+ id: "title-node",
1013
+ kind: "text",
1014
+ text,
1015
+ startFrame: 0,
1016
+ durationFrames: 60,
1017
+ },
1018
+ },
1019
+ ],
1020
+ });
1021
+
1022
+ const { targetsById: first } = compileTimeline(
1023
+ "render-text-cache",
1024
+ makeMedia("First Version"),
1025
+ { fps: 24, durationSec: 5 },
1026
+ );
1027
+ expect(first["title-node"]?.text).toBe(1);
1028
+
1029
+ const { targetsById: second } = compileTimeline(
1030
+ "render-text-cache",
1031
+ makeMedia("Second Version"),
1032
+ { fps: 24, durationSec: 5 },
1033
+ );
1034
+ expect(second["title-node"]).toBeDefined();
1035
+ });
1036
+
1037
+ it("compiles and evaluates cubic bezier and hold keyframe easing", () => {
1038
+ const virtualMedia = {
1039
+ operation: { op: "Compositor", width: 1920, height: 1080 },
1040
+ children: [
1041
+ {
1042
+ operation: {
1043
+ op: "CompositorLayer",
1044
+ id: "cubic-node",
1045
+ x: 0,
1046
+ startFrame: 0,
1047
+ durationFrames: 60,
1048
+ animation: {
1049
+ tracks: [
1050
+ {
1051
+ id: "t_cubic",
1052
+ prop: "x" as const,
1053
+ keyframes: [
1054
+ { id: "k1", frame: 0, value: 0 },
1055
+ {
1056
+ id: "k2",
1057
+ frame: 24,
1058
+ value: 100,
1059
+ ease: {
1060
+ name: "cubic" as const,
1061
+ dir: "out" as const,
1062
+ params: [0.25, 0.1, 0.25, 1.0],
1063
+ },
1064
+ },
1065
+ ],
1066
+ },
1067
+ {
1068
+ id: "t_hold",
1069
+ prop: "y" as const,
1070
+ keyframes: [
1071
+ { id: "k3", frame: 0, value: 0 },
1072
+ {
1073
+ id: "k4",
1074
+ frame: 24,
1075
+ value: 50,
1076
+ ease: {
1077
+ name: "hold" as const,
1078
+ dir: "out" as const,
1079
+ },
1080
+ },
1081
+ ],
1082
+ },
1083
+ ],
1084
+ },
1085
+ },
1086
+ },
1087
+ ],
1088
+ };
1089
+
1090
+ const { tl, targetsById } = compileTimeline(
1091
+ "render-cubic-hold",
1092
+ virtualMedia,
1093
+ { fps: 24, durationSec: 5 },
1094
+ );
1095
+
1096
+ const stub = targetsById["cubic-node"];
1097
+ expect(stub).toBeDefined();
1098
+
1099
+ // Check initial
1100
+ tl.seek(0);
1101
+ expect(stub.x).toBe(0);
1102
+ expect(stub.y).toBe(0);
1103
+
1104
+ // Check mid-way (frame 12)
1105
+ tl.seek(12 / 24);
1106
+ expect(stub.x).toBeGreaterThan(0);
1107
+ expect(stub.x).toBeLessThan(100);
1108
+ // Hold stays at initial value until the keyframe
1109
+ expect(stub.y).toBe(0);
1110
+
1111
+ // Check at destination (frame 24)
1112
+ tl.seek(24 / 24);
1113
+ expect(stub.x).toBeCloseTo(100, 0);
1114
+ expect(stub.y).toBe(50);
1115
+ });
1116
+
1117
+ it("compiles and evaluates procedural wiggle and signal tracks", () => {
1118
+ const virtualMedia: any = {
1119
+ metadata: { width: 1920, height: 1080, durationMs: 2000 },
1120
+ operation: {
1121
+ op: "Compositor",
1122
+ signals: {
1123
+ audio_pulse: [0, 0.5, 1.0, 0.2, 0.0],
1124
+ },
1125
+ },
1126
+ children: [
1127
+ {
1128
+ operation: {
1129
+ op: "CompositorLayer",
1130
+ id: "proc-node",
1131
+ x: 50,
1132
+ scale: 1,
1133
+ durationFrames: 48,
1134
+ animation: {
1135
+ tracks: [
1136
+ {
1137
+ id: "t_wiggle",
1138
+ prop: "x" as const,
1139
+ source: {
1140
+ type: "wiggle" as const,
1141
+ frequency: 3,
1142
+ amplitude: 40,
1143
+ seed: 1234,
1144
+ },
1145
+ },
1146
+ {
1147
+ id: "t_signal",
1148
+ prop: "scale" as const,
1149
+ source: {
1150
+ type: "signal" as const,
1151
+ inputHandleId: "audio_pulse",
1152
+ multiplier: 0.5,
1153
+ offset: 1.0,
1154
+ },
1155
+ },
1156
+ ],
1157
+ },
1158
+ },
1159
+ },
1160
+ ],
1161
+ };
1162
+
1163
+ const { tl, targetsById } = compileTimeline(
1164
+ "render-procedural-test",
1165
+ virtualMedia,
1166
+ { fps: 24, durationSec: 2 },
1167
+ );
1168
+
1169
+ const stub = targetsById["proc-node"];
1170
+ expect(stub).toBeDefined();
1171
+
1172
+ // Frame 0
1173
+ tl.seek(0);
1174
+ expect(stub.scale).toBe(1.0); // 0 * 0.5 + 1.0 = 1.0
1175
+
1176
+ // Frame 2 (at 24fps = 2/24 sec) -> audio_pulse[2] is 1.0 -> 1.0 * 0.5 + 1.0 = 1.5
1177
+ tl.seek(2 / 24);
1178
+ expect(stub.scale).toBeCloseTo(1.5, 4);
1179
+
1180
+ // Wiggle moves smoothly around baseline 50 +- 40
1181
+ expect(stub.x).toBeGreaterThanOrEqual(10);
1182
+ expect(stub.x).toBeLessThanOrEqual(90);
1183
+ });
1184
+ });
1185
+
1186
+ describe("Compositor timeline cache lifecycle", () => {
1187
+ /**
1188
+ * A stand-in for a real signal (Signal.fromArray / ProgrammaticSignal): its
1189
+ * per-frame cache fields mutate on every get(), which is exactly what made
1190
+ * the old content hash change every frame.
1191
+ */
1192
+ function mutableSignal() {
1193
+ return {
1194
+ _cachedFrame: -1,
1195
+ _cachedValue: 0,
1196
+ get(ctx?: { frame?: number }): number {
1197
+ const f = ctx?.frame ?? 0;
1198
+ if (this._cachedFrame === f) return this._cachedValue;
1199
+ this._cachedFrame = f;
1200
+ this._cachedValue = f % 2 === 0 ? 0 : 1;
1201
+ return this._cachedValue;
1202
+ },
1203
+ };
1204
+ }
1205
+
1206
+ function vmWithSignal(signals: Record<string, unknown>) {
1207
+ return {
1208
+ metadata: { width: 1920, height: 1080, durationMs: 2000 },
1209
+ operation: { op: "Compositor", signals },
1210
+ children: [
1211
+ {
1212
+ operation: {
1213
+ op: "CompositorLayer",
1214
+ id: "sig-node",
1215
+ x: 0,
1216
+ y: 0,
1217
+ scale: 1,
1218
+ durationFrames: 48,
1219
+ animation: {
1220
+ tracks: [
1221
+ {
1222
+ id: "t_sig",
1223
+ prop: "opacity" as const,
1224
+ source: {
1225
+ type: "signal" as const,
1226
+ inputHandleId: "gate",
1227
+ multiplier: 1,
1228
+ offset: 0,
1229
+ channel: "primary",
1230
+ },
1231
+ },
1232
+ ],
1233
+ },
1234
+ },
1235
+ },
1236
+ ],
1237
+ } as any;
1238
+ }
1239
+
1240
+ it("reuses one compiled timeline for a live signal across frames", () => {
1241
+ const signal = mutableSignal();
1242
+ const vm = vmWithSignal({ gate: signal });
1243
+
1244
+ const first = compileTimeline("signal-cache", vm, {
1245
+ fps: 24,
1246
+ durationSec: 2,
1247
+ });
1248
+ // Advance the signal exactly as the next frame's evaluation would — its
1249
+ // cached frame/value now differ from the values at the first compile.
1250
+ signal.get({ frame: 7 });
1251
+ const second = compileTimeline("signal-cache", vm, {
1252
+ fps: 24,
1253
+ durationSec: 2,
1254
+ });
1255
+
1256
+ expect(second.tl).toBe(first.tl);
1257
+ });
1258
+
1259
+ it("reuses the timeline when the operation is shallow-copied per frame", () => {
1260
+ // drawCompositionTree bakes signal values into a fresh copy of the
1261
+ // operation every frame; children keep their identity.
1262
+ const vm = vmWithSignal({ gate: [0, 1, 0] });
1263
+ const first = compileTimeline("shallow-copy", vm, {
1264
+ fps: 24,
1265
+ durationSec: 2,
1266
+ });
1267
+ const second = compileTimeline(
1268
+ "shallow-copy",
1269
+ { ...vm, operation: { ...vm.operation, gate: 1 } },
1270
+ { fps: 24, durationSec: 2 },
1271
+ );
1272
+
1273
+ expect(second.tl).toBe(first.tl);
1274
+ });
1275
+
1276
+ it("still busts the cache when plain (non-signal) signal data changes", () => {
1277
+ const vm = vmWithSignal({ gate: [0, 1, 0] });
1278
+ const first = compileTimeline("plain-signal", vm, {
1279
+ fps: 24,
1280
+ durationSec: 2,
1281
+ });
1282
+
1283
+ vm.operation.signals.gate = [1, 0, 1];
1284
+ const second = compileTimeline("plain-signal", vm, {
1285
+ fps: 24,
1286
+ durationSec: 2,
1287
+ });
1288
+
1289
+ expect(second.tl).not.toBe(first.tl);
1290
+ });
1291
+
1292
+ it("kills evicted timelines so gsap's global timeline stays bounded", () => {
1293
+ const count = () =>
1294
+ gsap.globalTimeline.getChildren(true, false, true).length;
1295
+
1296
+ const vm = vmWithSignal({ gate: mutableSignal() });
1297
+ compileTimeline("kill-cap-0", vm, { fps: 24, durationSec: 2 });
1298
+ const before = count();
1299
+
1300
+ // Far more compiled ids than the cache capacity: each insert past the
1301
+ // cap must evict AND kill, or the global timeline grows one per compile.
1302
+ for (let i = 0; i < 120; i++) {
1303
+ compileTimeline(`kill-cap-${i}`, vm, { fps: 24, durationSec: 2 });
1304
+ }
1305
+
1306
+ expect(count() - before).toBeLessThanOrEqual(50);
1307
+ });
1308
+ });
1309
+