@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.
- package/.turbo/turbo-build.log +22 -0
- package/.turbo/turbo-test.log +21 -0
- package/CHANGELOG.md +32 -0
- package/LICENCE +202 -0
- package/SKILL.md +370 -0
- package/dist/index-B0ir9Z7o.d.mts +139 -0
- package/dist/index-B0ir9Z7o.d.mts.map +1 -0
- package/dist/index.d.mts +230 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +674 -0
- package/dist/index.mjs.map +1 -0
- package/dist/renderer.d.mts +2 -0
- package/dist/renderer.mjs +3 -0
- package/dist/renderers-DoYYCzgA.mjs +3326 -0
- package/dist/renderers-DoYYCzgA.mjs.map +1 -0
- package/package.json +51 -3
- package/src/index.ts +2 -0
- package/src/renderers/audio-processor.ts +123 -0
- package/src/renderers/container-bg.test.ts +22 -0
- package/src/renderers/container-bg.ts +15 -0
- package/src/renderers/index.ts +9 -0
- package/src/renderers/transform.test.ts +120 -0
- package/src/renderers/transform.ts +101 -0
- package/src/renderers/transform3d.test.ts +290 -0
- package/src/renderers/transform3d.ts +428 -0
- package/src/renderers/webgpu-renderer.ts +4038 -0
- package/src/shared/audio-reactive.test.ts +224 -0
- package/src/shared/bezier-solver.test.ts +92 -0
- package/src/shared/bezier-solver.ts +257 -0
- package/src/shared/compiler.test.ts +1309 -0
- package/src/shared/compiler.ts +996 -0
- package/src/shared/config.ts +44 -0
- package/src/shared/index.ts +3 -0
- package/src/shared/kinetic-typography.test.ts +138 -0
- package/src/shared/presets.test.ts +217 -0
- package/src/shared/presets.ts +644 -0
- package/src/shared/processor.ts +105 -0
- package/tsconfig.json +13 -0
- package/tsdown.config.ts +13 -0
- 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
|
+
|