@lalalic/markcut 1.0.0

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 (98) hide show
  1. package/.env.example +27 -0
  2. package/.github/user-steer.md +9 -0
  3. package/.vscode/settings.json +3 -0
  4. package/AGENTS.md +271 -0
  5. package/README.md +219 -0
  6. package/SKILL.md +209 -0
  7. package/docs/dynamic-components.md +191 -0
  8. package/docs/edit-mode.md +220 -0
  9. package/docs/json-descriptive.md +539 -0
  10. package/docs/label-mode.md +110 -0
  11. package/docs/markdown-descriptive.md +751 -0
  12. package/docs/templates.md +52 -0
  13. package/package.json +64 -0
  14. package/remotion.config.ts +5 -0
  15. package/scripts/artlist-dl.mjs +190 -0
  16. package/scripts/build-pipeline.sh +19 -0
  17. package/scripts/build-player.sh +20 -0
  18. package/src/Root.tsx +55 -0
  19. package/src/config.mjs +88 -0
  20. package/src/context/EventContext.tsx +168 -0
  21. package/src/context/index.tsx +42 -0
  22. package/src/descriptive/compiler.test.ts +1135 -0
  23. package/src/descriptive/compiler.ts +1230 -0
  24. package/src/descriptive/dsl.ts +455 -0
  25. package/src/descriptive/markdown.test.ts +866 -0
  26. package/src/descriptive/markdown.ts +674 -0
  27. package/src/descriptive/resolve.test.ts +951 -0
  28. package/src/descriptive/resolve.ts +891 -0
  29. package/src/entry.tsx +163 -0
  30. package/src/index.ts +4 -0
  31. package/src/player/browser.tsx +356 -0
  32. package/src/player/bundle/player.js +60259 -0
  33. package/src/player/bundler.mjs +269 -0
  34. package/src/player/label-server.mjs +599 -0
  35. package/src/player/pipeline.mjs +11123 -0
  36. package/src/player/pipeline.ts +117 -0
  37. package/src/player/server-shared.mjs +144 -0
  38. package/src/player/server.mjs +1006 -0
  39. package/src/render/cli-tools.ts +177 -0
  40. package/src/render/cli.mjs +628 -0
  41. package/src/schema/index.ts +259 -0
  42. package/src/types/Audio.tsx +56 -0
  43. package/src/types/Component.tsx +135 -0
  44. package/src/types/Effect.tsx +88 -0
  45. package/src/types/Folder.tsx +180 -0
  46. package/src/types/FrameSyncStyle.tsx +51 -0
  47. package/src/types/Image.tsx +51 -0
  48. package/src/types/Include.tsx +394 -0
  49. package/src/types/Map.tsx +252 -0
  50. package/src/types/Rhythm.tsx +58 -0
  51. package/src/types/Scene.tsx +42 -0
  52. package/src/types/Subtitle.tsx +218 -0
  53. package/src/types/Video.tsx +70 -0
  54. package/src/types/keyframes.ts +454 -0
  55. package/src/utils/__tests__/vtt.test.ts +129 -0
  56. package/src/utils/index.ts +168 -0
  57. package/src/utils/tween.ts +118 -0
  58. package/src/vision/cli.mjs +1187 -0
  59. package/src/vision/vision_prompts.md +67 -0
  60. package/tests/dsl.test.ts +317 -0
  61. package/tests/fixtures/audio.json +25 -0
  62. package/tests/fixtures/basic.json +27 -0
  63. package/tests/fixtures/component-all.json +38 -0
  64. package/tests/fixtures/components.json +38 -0
  65. package/tests/fixtures/effects.json +64 -0
  66. package/tests/fixtures/full.json +51 -0
  67. package/tests/fixtures/map.json +23 -0
  68. package/tests/fixtures/md/all-nodes.md +28 -0
  69. package/tests/fixtures/md/basic.md +6 -0
  70. package/tests/fixtures/md/component-imports.md +20 -0
  71. package/tests/fixtures/md/edge-cases.md +33 -0
  72. package/tests/fixtures/md/effects.md +17 -0
  73. package/tests/fixtures/md/frontmatter.md +20 -0
  74. package/tests/fixtures/md/full-feature.md +58 -0
  75. package/tests/fixtures/md/imports-block.md +19 -0
  76. package/tests/fixtures/md/include-main.md +11 -0
  77. package/tests/fixtures/md/include-sub.md +25 -0
  78. package/tests/fixtures/md/jsx-code-fence.md +21 -0
  79. package/tests/fixtures/md/map.md +11 -0
  80. package/tests/fixtures/md/nested-scenes.md +25 -0
  81. package/tests/fixtures/md/rhythm.md +17 -0
  82. package/tests/fixtures/md/scenes.md +16 -0
  83. package/tests/fixtures/md/tween.md +11 -0
  84. package/tests/fixtures/md/vars-test.md +6 -0
  85. package/tests/fixtures/scenes.json +40 -0
  86. package/tests/fixtures/subtitle.json +59 -0
  87. package/tests/fixtures/subvideo.json +59 -0
  88. package/tests/fixtures/templates/courseware.md +351 -0
  89. package/tests/fixtures/tween-visual.json +28 -0
  90. package/tests/fixtures/video-series.json +54 -0
  91. package/tests/md-descriptive.test.ts +742 -0
  92. package/tests/render.test.ts +985 -0
  93. package/tests/schema.test.ts +68 -0
  94. package/tests/server.test.ts +308 -0
  95. package/tests/utils.ts +391 -0
  96. package/tests/vitest.config.ts +18 -0
  97. package/tests/vitest.integration.config.ts +16 -0
  98. package/tsconfig.json +20 -0
@@ -0,0 +1,985 @@
1
+ /**
2
+ * Integration tests for the Remotion Engine.
3
+ *
4
+ * Tests verify that rendering produces valid MP4 output with correct:
5
+ * - Video dimensions, duration, and FPS
6
+ * - Key frame visual content (non-blank, correct composition)
7
+ * - Audio track presence and content (via STT)
8
+ *
9
+ * Each test exercises one or more feature(s) of the stream tree schema.
10
+ */
11
+ import { describe, it, expect, beforeAll } from "vitest";
12
+ import {
13
+ renderFixture,
14
+ getVideoInfo,
15
+ extractFrame,
16
+ extractAudio,
17
+ transcribeAudio,
18
+ isFrameNonBlank,
19
+ getFrameFileSize,
20
+ OUT_DIR,
21
+ FIXTURES_DIR,
22
+ } from "./utils";
23
+ import { existsSync, rmSync, mkdirSync } from "node:fs";
24
+ import { resolve } from "node:path";
25
+
26
+ // Increase timeout for integration tests (rendering + STT can take a while)
27
+ const RENDER_TIMEOUT = 300_000;
28
+
29
+ beforeAll(() => {
30
+ mkdirSync(OUT_DIR, { recursive: true });
31
+ });
32
+
33
+ // ── Helpers ─────────────────────────────────────────────────────────────────
34
+
35
+ function fixturePath(name: string): string {
36
+ return resolve(FIXTURES_DIR, name);
37
+ }
38
+
39
+ function outPath(name: string): string {
40
+ return resolve(OUT_DIR, name);
41
+ }
42
+
43
+ // ───────────────────────────────────────────────────────────────────────────
44
+ // 1. Basic Render Tests
45
+ // ───────────────────────────────────────────────────────────────────────────
46
+
47
+ describe("Basic Rendering", () => {
48
+ it("renders a minimal empty composition", async () => {
49
+ const fixture = {
50
+ root: {
51
+ id: "root",
52
+ type: "root",
53
+ width: 640,
54
+ height: 480,
55
+ fps: 30,
56
+ isSeries: false,
57
+ children: [],
58
+ },
59
+ };
60
+
61
+ // Write temp fixture
62
+ const tmpFixture = outPath("_minimal.json");
63
+ const { writeFileSync } = await import("node:fs");
64
+ writeFileSync(tmpFixture, JSON.stringify(fixture));
65
+
66
+ const output = renderFixture(tmpFixture, {
67
+ outputName: "minimal.mp4",
68
+ timeout: RENDER_TIMEOUT,
69
+ });
70
+
71
+ const info = getVideoInfo(output);
72
+ expect(info.width).toBe(640);
73
+ expect(info.height).toBe(480);
74
+ expect(info.fps).toBe(30);
75
+ expect(info.durationSec).toBeCloseTo(1, 0); // default 1s
76
+ expect(info.fileSizeBytes).toBeGreaterThan(1000);
77
+
78
+ // Cleanup
79
+ try { rmSync(tmpFixture); } catch {}
80
+ });
81
+
82
+ it("renders with correct dimensions from fixture", async () => {
83
+ const output = renderFixture(fixturePath("basic.json"), {
84
+ outputName: "basic.mp4",
85
+ timeout: RENDER_TIMEOUT,
86
+ });
87
+
88
+ const info = getVideoInfo(output);
89
+ expect(info.width).toBe(640);
90
+ expect(info.height).toBe(480);
91
+ expect(info.fps).toBe(30);
92
+ // basic.json has one scene with max action end=2 → ~2s expected
93
+ expect(info.durationSec).toBeGreaterThanOrEqual(1);
94
+ expect(info.durationSec).toBeLessThanOrEqual(3);
95
+ });
96
+ });
97
+
98
+ // ───────────────────────────────────────────────────────────────────────────
99
+ // 2. Image + Root Subtitle Overlay Rendering
100
+ // ───────────────────────────────────────────────────────────────────────────
101
+
102
+ describe("Image + Root Subtitle Overlay Rendering", () => {
103
+ it("renders an image scene correctly", async () => {
104
+ const output = renderFixture(fixturePath("basic.json"), {
105
+ outputName: "basic-image.mp4",
106
+ timeout: RENDER_TIMEOUT,
107
+ });
108
+
109
+ const info = getVideoInfo(output);
110
+ expect(info.width).toBe(640);
111
+ expect(info.height).toBe(480);
112
+
113
+ // Extract a frame at 0.5s — should have visual content
114
+ const frame1 = outPath("frames/basic-0.5s.png");
115
+ extractFrame(output, 0.5, frame1);
116
+ expect(existsSync(frame1)).toBe(true);
117
+
118
+ // Verify frame is not blank
119
+ expect(isFrameNonBlank(frame1)).toBe(true);
120
+
121
+ // Check frame has substantial content (non-blank PNG should be > 5KB)
122
+ const frameSize = getFrameFileSize(frame1);
123
+ expect(frameSize).toBeGreaterThan(5000);
124
+ });
125
+
126
+ it("renders root-level subtitle overlay from inline VTT", async () => {
127
+ const output = renderFixture(fixturePath("subtitle.json"), {
128
+ outputName: "subtitle-overlay.mp4",
129
+ timeout: RENDER_TIMEOUT,
130
+ });
131
+
132
+ const info = getVideoInfo(output);
133
+ expect(info.width).toBe(640);
134
+
135
+ // Extract frame at 1s — subtitle cue active (0.5–3.5s)
136
+ const frameWithSub = outPath("frames/subtitle-1s.png");
137
+ extractFrame(output, 1, frameWithSub);
138
+ expect(existsSync(frameWithSub)).toBe(true);
139
+ expect(isFrameNonBlank(frameWithSub)).toBe(true);
140
+
141
+ // Extract frame at 5s (between cues in scene-2)
142
+ const frameSilent = outPath("frames/subtitle-5s.png");
143
+ extractFrame(output, 5, frameSilent);
144
+ expect(existsSync(frameSilent)).toBe(true);
145
+ expect(isFrameNonBlank(frameSilent)).toBe(true);
146
+ });
147
+
148
+ it("renders image scene with no subtitle node (basic fixture)", async () => {
149
+ const output = renderFixture(fixturePath("basic.json"), {
150
+ outputName: "basic-no-subtitle.mp4",
151
+ timeout: RENDER_TIMEOUT,
152
+ });
153
+
154
+ const info = getVideoInfo(output);
155
+ expect(info.width).toBe(640);
156
+ expect(info.height).toBe(480);
157
+ expect(info.fileSizeBytes).toBeGreaterThan(5000);
158
+ });
159
+ });
160
+
161
+ // ───────────────────────────────────────────────────────────────────────────
162
+ // 3. Built-in Components
163
+ // ───────────────────────────────────────────────────────────────────────────
164
+
165
+ describe("Built-in Components", () => {
166
+ it("renders AnimatedHeadline component", async () => {
167
+ const output = renderFixture(fixturePath("components.json"), {
168
+ outputName: "components.mp4",
169
+ timeout: RENDER_TIMEOUT,
170
+ });
171
+
172
+ const info = getVideoInfo(output);
173
+ expect(info.width).toBe(640);
174
+
175
+ // Check frame at 1s (scene 1, headline visible)
176
+ const frame1 = outPath("frames/components-1s.png");
177
+ extractFrame(output, 1, frame1);
178
+ expect(getFrameFileSize(frame1)).toBeGreaterThan(5000);
179
+
180
+ // Check frame at 4s (scene 2, stats visible)
181
+ const frame2 = outPath("frames/components-4s.png");
182
+ extractFrame(output, 4, frame2);
183
+ expect(getFrameFileSize(frame2)).toBeGreaterThan(5000);
184
+ });
185
+
186
+ it("renders StatCounter component", async () => {
187
+ const output = renderFixture(fixturePath("components.json"), {
188
+ outputName: "components-statcounter.mp4",
189
+ timeout: RENDER_TIMEOUT,
190
+ });
191
+
192
+ const info = getVideoInfo(output);
193
+ expect(info.fps).toBe(30);
194
+ expect(info.durationSec).toBeGreaterThanOrEqual(4);
195
+ });
196
+ });
197
+
198
+ // ───────────────────────────────────────────────────────────────────────────
199
+ // 4. Effects (CSS Keyframe Animations)
200
+ // ───────────────────────────────────────────────────────────────────────────
201
+
202
+ describe("Effects (CSS Keyframes)", () => {
203
+ it("renders fadeIn effect", async () => {
204
+ const output = renderFixture(fixturePath("effects.json"), {
205
+ outputName: "effects.mp4",
206
+ timeout: RENDER_TIMEOUT,
207
+ });
208
+
209
+ const info = getVideoInfo(output);
210
+ expect(info.width).toBe(640);
211
+
212
+ // FadeIn scene is first (0-2s), extract at 1s
213
+ const frameFade = outPath("frames/effects-fade-1s.png");
214
+ extractFrame(output, 1, frameFade);
215
+ expect(isFrameNonBlank(frameFade)).toBe(true);
216
+
217
+ // Bounce scene is second (2-4s), extract at 3s
218
+ const frameBounce = outPath("frames/effects-bounce-3s.png");
219
+ extractFrame(output, 3, frameBounce);
220
+ expect(isFrameNonBlank(frameBounce)).toBe(true);
221
+
222
+ // Custom keyframes scene is third (4-6s), extract at 5s
223
+ const frameCustom = outPath("frames/effects-custom-5s.png");
224
+ extractFrame(output, 5, frameCustom);
225
+ expect(isFrameNonBlank(frameCustom)).toBe(true);
226
+ });
227
+
228
+ it("renders animation with custom keyframes", async () => {
229
+ const output = renderFixture(fixturePath("effects.json"), {
230
+ outputName: "effects-custom.mp4",
231
+ timeout: RENDER_TIMEOUT,
232
+ });
233
+
234
+ const info = getVideoInfo(output);
235
+ expect(info.durationSec).toBeGreaterThanOrEqual(5);
236
+ });
237
+ });
238
+
239
+ // ───────────────────────────────────────────────────────────────────────────
240
+ // 5. Map Rendering (Canvas Route)
241
+ // ───────────────────────────────────────────────────────────────────────────
242
+
243
+ describe("Map Rendering", () => {
244
+ it("renders a map route on canvas", async () => {
245
+ const output = renderFixture(fixturePath("map.json"), {
246
+ outputName: "map.mp4",
247
+ timeout: RENDER_TIMEOUT,
248
+ });
249
+
250
+ const info = getVideoInfo(output);
251
+ expect(info.width).toBe(640);
252
+ expect(info.durationSec).toBeGreaterThanOrEqual(2);
253
+
254
+ // Extract frame at mid-point
255
+ const frame = outPath("frames/map-1.5s.png");
256
+ extractFrame(output, 1.5, frame);
257
+ // Map may render blank if Google Maps tiles aren't available (offline/headless).
258
+ // Check file size as proxy for visual content.
259
+ expect(getFrameFileSize(frame)).toBeGreaterThan(1000);
260
+ });
261
+ });
262
+
263
+ // ───────────────────────────────────────────────────────────────────────────
264
+ // 6. Audio Rendering
265
+ // ───────────────────────────────────────────────────────────────────────────
266
+
267
+ describe("Audio Rendering", () => {
268
+ it("renders video with audio track", async () => {
269
+ const output = renderFixture(fixturePath("audio.json"), {
270
+ outputName: "audio.mp4",
271
+ timeout: RENDER_TIMEOUT,
272
+ });
273
+
274
+ const info = getVideoInfo(output);
275
+ expect(info.width).toBe(640);
276
+ // Should have an audio stream
277
+ expect(info.hasAudio).toBe(true);
278
+ });
279
+
280
+ it("extracts audio and verifies non-silent", async () => {
281
+ const output = renderFixture(fixturePath("audio.json"), {
282
+ outputName: "audio-extract.mp4",
283
+ timeout: RENDER_TIMEOUT,
284
+ });
285
+
286
+ const audioPath = outPath("audio/audio-extract.wav");
287
+ extractAudio(output, audioPath);
288
+ expect(existsSync(audioPath)).toBe(true);
289
+
290
+ // Check audio has non-silent content (file should be > 1KB)
291
+ const { statSync } = await import("node:fs");
292
+ const audioSize = statSync(audioPath).size;
293
+ expect(audioSize).toBeGreaterThan(1000);
294
+ });
295
+
296
+ it("renders audio in full feature test", async () => {
297
+ const output = renderFixture(fixturePath("full.json"), {
298
+ outputName: "audio-full.mp4",
299
+ timeout: RENDER_TIMEOUT,
300
+ });
301
+
302
+ const info = getVideoInfo(output);
303
+ // full.json has background audio
304
+ expect(info.hasAudio).toBe(true);
305
+ });
306
+ });
307
+
308
+ // ───────────────────────────────────────────────────────────────────────────
309
+ // 7. Include (External Video Composition)
310
+ // ───────────────────────────────────────────────────────────────────────────
311
+
312
+ describe("Include Rendering", () => {
313
+ it("renders an external video via include", async () => {
314
+ const output = renderFixture(fixturePath("subvideo.json"), {
315
+ outputName: "include.mp4",
316
+ timeout: RENDER_TIMEOUT,
317
+ });
318
+
319
+ const info = getVideoInfo(output);
320
+ expect(info.width).toBe(640);
321
+ expect(info.height).toBe(480);
322
+ expect(info.durationSec).toBeGreaterThanOrEqual(7);
323
+
324
+ // Extract frame at 1s (main video, intro)
325
+ const frameMain = outPath("frames/include-main-1s.png");
326
+ extractFrame(output, 1, frameMain);
327
+ expect(isFrameNonBlank(frameMain)).toBe(true);
328
+
329
+ // Extract frame at 3s (included video playing)
330
+ const frameSub = outPath("frames/include-sub-3s.png");
331
+ extractFrame(output, 3, frameSub);
332
+ expect(isFrameNonBlank(frameSub)).toBe(true);
333
+
334
+ // Extract frame at 5s (included video scene 2)
335
+ const frameSub2 = outPath("frames/include-sub-5s.png");
336
+ extractFrame(output, 5, frameSub2);
337
+ expect(isFrameNonBlank(frameSub2)).toBe(true);
338
+
339
+ // Extract frame at 7s (outro)
340
+ const frameOutro = outPath("frames/include-outro-7s.png");
341
+ extractFrame(output, 7, frameOutro);
342
+ expect(isFrameNonBlank(frameOutro)).toBe(true);
343
+ });
344
+ });
345
+
346
+ // ───────────────────────────────────────────────────────────────────────────
347
+ // 8. Full Feature Combination
348
+ // ───────────────────────────────────────────────────────────────────────────
349
+
350
+ describe("Full Feature Combination", () => {
351
+ it("renders a complete video with multiple features", async () => {
352
+ const output = renderFixture(fixturePath("full.json"), {
353
+ outputName: "full.mp4",
354
+ timeout: RENDER_TIMEOUT,
355
+ });
356
+
357
+ const info = getVideoInfo(output);
358
+ expect(info.width).toBe(640);
359
+ expect(info.height).toBe(480);
360
+ expect(info.fps).toBe(30);
361
+ // Parallel layout: max(5, 5, 4.5, 4.5) = 5s from audio
362
+ expect(info.durationSec).toBeGreaterThanOrEqual(4);
363
+
364
+ // Verify frames at key points (all scenes play in parallel)
365
+ const frame1 = outPath("frames/full-0.5s.png"); // headline + stats + subtitle
366
+ extractFrame(output, 0.5, frame1);
367
+ expect(isFrameNonBlank(frame1)).toBe(true);
368
+
369
+ const frame2 = outPath("frames/full-2.5s.png"); // mid-point
370
+ extractFrame(output, 2.5, frame2);
371
+ expect(isFrameNonBlank(frame2)).toBe(true);
372
+
373
+ const frame3 = outPath("frames/full-4s.png"); // near end
374
+ extractFrame(output, 4, frame3);
375
+ expect(isFrameNonBlank(frame3)).toBe(true);
376
+
377
+ // Full file should be substantial
378
+ expect(info.fileSizeBytes).toBeGreaterThan(20000);
379
+ });
380
+
381
+ it("renders all fixtures without errors", async () => {
382
+ const fixtures = [
383
+ "basic.json",
384
+ "components.json",
385
+ "effects.json",
386
+ "subtitle.json",
387
+ "map.json",
388
+ "audio.json",
389
+ "subvideo.json",
390
+ "full.json",
391
+ ];
392
+
393
+ for (const f of fixtures) {
394
+ const output = renderFixture(fixturePath(f), {
395
+ outputName: `batch-${f.replace(/\.json$/, ".mp4")}`,
396
+ timeout: RENDER_TIMEOUT,
397
+ });
398
+ const info = getVideoInfo(output);
399
+ expect(info.fileSizeBytes).toBeGreaterThan(1000);
400
+ expect(info.width).toBe(640);
401
+ expect(info.height).toBe(480);
402
+ }
403
+ });
404
+ });
405
+
406
+ // ───────────────────────────────────────────────────────────────────────────
407
+ // 9. Scene Node Rendering (stream tree scene type)
408
+ // ───────────────────────────────────────────────────────────────────────────
409
+
410
+ describe("Scene Node Rendering", () => {
411
+ it("renders scenes via stream tree scene nodes", async () => {
412
+ const output = renderFixture(fixturePath("scenes.json"), {
413
+ outputName: "scenes.mp4",
414
+ timeout: RENDER_TIMEOUT,
415
+ });
416
+
417
+ const info = getVideoInfo(output);
418
+ expect(info.width).toBe(640);
419
+ expect(info.height).toBe(480);
420
+ // 2 scenes: 3s + 2s - 0.3s transition = ~4.7s
421
+ expect(info.durationSec).toBeGreaterThanOrEqual(4);
422
+
423
+ // Extract frame at 1s (scene 1) — verify visual content
424
+ const frame = outPath("frames/scenes-1s.png");
425
+ extractFrame(output, 1, frame);
426
+ expect(getFrameFileSize(frame)).toBeGreaterThan(5000);
427
+
428
+ // Extract frame at 4s (scene 2) — verify file has content
429
+ const frame2 = outPath("frames/scenes-4s.png");
430
+ extractFrame(output, 4, frame2);
431
+ expect(getFrameFileSize(frame2)).toBeGreaterThan(5000);
432
+ });
433
+ });
434
+
435
+ // ───────────────────────────────────────────────────────────────────────────
436
+ // 10. Audio STT Verification (if whisper available)
437
+ // ───────────────────────────────────────────────────────────────────────────
438
+
439
+ describe("Audio STT Verification", () => {
440
+ it("transcribes audio from rendered video", async () => {
441
+ // Use a fixture that has audio
442
+ const output = renderFixture(fixturePath("audio.json"), {
443
+ outputName: "stt-audio.mp4",
444
+ timeout: RENDER_TIMEOUT,
445
+ });
446
+
447
+ // Extract audio
448
+ const audioPath = outPath("audio/stt-audio.wav");
449
+ extractAudio(output, audioPath);
450
+ expect(existsSync(audioPath)).toBe(true);
451
+
452
+ // Run STT
453
+ const text = transcribeAudio(audioPath);
454
+ // The audio is ambient music, so STT might return empty or garbage
455
+ // This test just checks the pipeline works without errors
456
+ expect(text).toBeDefined();
457
+ console.log(` STT result (ambient audio): "${text}"`);
458
+ });
459
+
460
+ it("transcribes audio from full feature video", async () => {
461
+ const output = renderFixture(fixturePath("full.json"), {
462
+ outputName: "stt-full.mp4",
463
+ timeout: RENDER_TIMEOUT,
464
+ });
465
+
466
+ const audioPath = outPath("audio/stt-full.wav");
467
+ extractAudio(output, audioPath);
468
+
469
+ const text = transcribeAudio(audioPath);
470
+ expect(text).toBeDefined();
471
+ console.log(` STT result (full video audio): "${text}"`);
472
+ });
473
+ });
474
+
475
+ // ───────────────────────────────────────────────────────────────────────────
476
+ // 11. Multiple Aspect Ratios
477
+ // ───────────────────────────────────────────────────────────────────────────
478
+
479
+ describe("Multiple Aspect Ratios", () => {
480
+ it("renders same fixture at 16x9, 9x16, and 1x1", async () => {
481
+ const fixture = {
482
+ root: {
483
+ id: "root",
484
+ type: "root",
485
+ width: 1920,
486
+ height: 1080,
487
+ fps: 30,
488
+ isSeries: false,
489
+ children: [
490
+ {
491
+ id: "bg",
492
+ type: "image",
493
+ src: "https://picsum.photos/seed/aspect-test/1920/1080",
494
+ fit: "cover",
495
+ actions: [{ start: 0, end: 2 }],
496
+ },
497
+ {
498
+ id: "title",
499
+ type: "image",
500
+ src: "https://picsum.photos/seed/aspect-title/1920/1080",
501
+ fit: "cover",
502
+ actions: [{ start: 0.3, end: 1.7 }],
503
+ },
504
+ ],
505
+ },
506
+ };
507
+
508
+ const { writeFileSync } = await import("node:fs");
509
+ const tmpFixture = outPath("_aspects.json");
510
+ writeFileSync(tmpFixture, JSON.stringify(fixture));
511
+
512
+ // Render with different aspect ratios via --props adaptation
513
+ // Note: Aspect adaptation happens in the CLI, so we test with the default
514
+ const output = renderFixture(tmpFixture, {
515
+ outputName: "aspect-default.mp4",
516
+ timeout: RENDER_TIMEOUT,
517
+ });
518
+
519
+ const info = getVideoInfo(output);
520
+ // Default aspect is whatever the fixture says (1920x1080)
521
+ expect(info.width).toBe(1920);
522
+ expect(info.height).toBe(1080);
523
+
524
+ try { rmSync(tmpFixture); } catch {}
525
+ });
526
+ });
527
+
528
+ // ───────────────────────────────────────────────────────────────────────────
529
+ // 12. Cross-Stream Type Compatibility
530
+ // ───────────────────────────────────────────────────────────────────────────
531
+
532
+ describe("Cross-Stream Type Compatibility", () => {
533
+ it("renders audio + image + subtitle in parallel", async () => {
534
+ // All three types playing simultaneously in the same folder
535
+ const fixture = {
536
+ root: {
537
+ id: "root",
538
+ type: "root",
539
+ width: 640,
540
+ height: 480,
541
+ fps: 30,
542
+ isSeries: false,
543
+ children: [
544
+ {
545
+ id: "bgm-test",
546
+ type: "audio",
547
+ src: "assets/bgm/ambient-loop.mp3",
548
+ volume: 0.2,
549
+ isBackground: true,
550
+ actions: [{ start: 0, end: 3 }],
551
+ },
552
+ {
553
+ id: "img-test",
554
+ type: "image",
555
+ src: "https://picsum.photos/seed/cross1/640/480",
556
+ fit: "cover",
557
+ actions: [{ start: 0, end: 3 }],
558
+ },
559
+ {
560
+ id: "sub-test",
561
+ type: "image",
562
+ src: "https://picsum.photos/seed/cross-sub/640/480",
563
+ fit: "cover",
564
+ actions: [{ start: 0.5, end: 2.5 }],
565
+ },
566
+ ],
567
+ },
568
+ };
569
+
570
+ const { writeFileSync } = await import("node:fs");
571
+ const tmpFixture = outPath("_cross-types.json");
572
+ writeFileSync(tmpFixture, JSON.stringify(fixture));
573
+
574
+ const output = renderFixture(tmpFixture, {
575
+ outputName: "cross-types.mp4",
576
+ timeout: RENDER_TIMEOUT,
577
+ });
578
+
579
+ const info = getVideoInfo(output);
580
+ expect(info.width).toBe(640);
581
+ expect(info.hasAudio).toBe(true);
582
+
583
+ const frame = outPath("frames/cross-types-1s.png");
584
+ extractFrame(output, 1, frame);
585
+ expect(isFrameNonBlank(frame)).toBe(true);
586
+
587
+ try { rmSync(tmpFixture); } catch {}
588
+ });
589
+
590
+ it("renders effect-wrapped children", async () => {
591
+ const fixture = {
592
+ root: {
593
+ id: "root",
594
+ type: "root",
595
+ width: 640,
596
+ height: 480,
597
+ fps: 30,
598
+ isSeries: true,
599
+ transition: "fade",
600
+ transitionTime: 0.3,
601
+ children: [
602
+ {
603
+ id: "effect-scene",
604
+ type: "effect",
605
+ animation: "slideInUp",
606
+ children: [
607
+ {
608
+ id: "effect-img",
609
+ type: "image",
610
+ src: "https://picsum.photos/seed/eff-child/640/480",
611
+ fit: "cover",
612
+ actions: [{ start: 0, end: 2 }],
613
+ },
614
+ ],
615
+ actions: [{ start: 0, end: 2 }],
616
+ },
617
+ ],
618
+ },
619
+ };
620
+
621
+ const { writeFileSync } = await import("node:fs");
622
+ const tmpFixture = outPath("_effect-children.json");
623
+ writeFileSync(tmpFixture, JSON.stringify(fixture));
624
+
625
+ const output = renderFixture(tmpFixture, {
626
+ outputName: "effect-children.mp4",
627
+ timeout: RENDER_TIMEOUT,
628
+ });
629
+
630
+ const info = getVideoInfo(output);
631
+ expect(info.width).toBe(640);
632
+
633
+ const frame = outPath("frames/effect-children-1s.png");
634
+ extractFrame(output, 1, frame);
635
+ expect(isFrameNonBlank(frame)).toBe(true);
636
+
637
+ try { rmSync(tmpFixture); } catch {}
638
+ });
639
+ });
640
+
641
+ // ───────────────────────────────────────────────────────────────────────────
642
+ // 13. Frame-Accurate Verification
643
+ // ───────────────────────────────────────────────────────────────────────────
644
+
645
+ describe("Frame-Accurate Verification", () => {
646
+ it("verifies consistent frames across the timeline", async () => {
647
+ const output = renderFixture(fixturePath("basic.json"), {
648
+ outputName: "frame-accuracy.mp4",
649
+ timeout: RENDER_TIMEOUT,
650
+ });
651
+
652
+ const info = getVideoInfo(output);
653
+ expect(info.durationSec).toBeGreaterThan(0);
654
+
655
+ // Extract frames at regular intervals
656
+ const intervals = [0.1, 0.5, 1.0, 1.5];
657
+ for (const t of intervals) {
658
+ if (t >= info.durationSec) continue;
659
+ const frame = outPath(`frames/accuracy-${t}s.png`);
660
+ extractFrame(output, t, frame);
661
+ expect(existsSync(frame)).toBe(true);
662
+ expect(isFrameNonBlank(frame)).toBe(true);
663
+ }
664
+ });
665
+
666
+ it("verifies video has expected number of frames", async () => {
667
+ const output = renderFixture(fixturePath("basic.json"), {
668
+ outputName: "frame-count.mp4",
669
+ timeout: RENDER_TIMEOUT,
670
+ });
671
+
672
+ const info = getVideoInfo(output);
673
+ const expectedFrames = Math.round(info.durationSec * info.fps);
674
+ expect(expectedFrames).toBeGreaterThan(30); // at least 30 frames for 1s @ 30fps
675
+ });
676
+ });
677
+
678
+ // ───────────────────────────────────────────────────────────────────────────
679
+ // 14. Description Field (label/storyboard metadata on any stream type)
680
+ // ───────────────────────────────────────────────────────────────────────────
681
+
682
+ describe("Instruction Field", () => {
683
+ it("validates instruction on image nodes via schema", async () => {
684
+ const { image } = await import("../src/schema/index");
685
+
686
+ const withInst = image.parse({
687
+ id: "test-img",
688
+ type: "image",
689
+ src: "https://picsum.photos/seed/desc/640/480",
690
+ instruction: "A beautiful sunset",
691
+ actions: [{ start: 0, end: 3 }],
692
+ });
693
+ expect(withInst.instruction).toBe("A beautiful sunset");
694
+
695
+ const withoutInst = image.parse({
696
+ id: "test-img-2",
697
+ type: "image",
698
+ src: "https://picsum.photos/seed/no-desc/640/480",
699
+ actions: [{ start: 0, end: 3 }],
700
+ });
701
+ expect(withoutInst.instruction).toBeUndefined();
702
+ });
703
+
704
+ it("validates instruction on video/component nodes via schema", async () => {
705
+ const { video, component } = await import("../src/schema/index");
706
+
707
+ const v = video.parse({
708
+ id: "test-vid",
709
+ type: "video",
710
+ src: "test.mp4",
711
+ instruction: "label for video clip",
712
+ actions: [{ start: 0, end: 5 }],
713
+ });
714
+ expect(v.instruction).toBe("label for video clip");
715
+
716
+ const c = component.parse({
717
+ id: "test-comp",
718
+ type: "component",
719
+ jsx: "<StatCounter value={100} />",
720
+ instruction: "stats component label",
721
+ actions: [{ start: 0, end: 3 }],
722
+ });
723
+ expect(c.instruction).toBe("stats component label");
724
+ });
725
+
726
+ it("preserves instruction through render pipeline", async () => {
727
+ // Render a fixture where image has an instruction — the engine should not strip it
728
+ const fixture = {
729
+ root: {
730
+ id: "root",
731
+ type: "root",
732
+ width: 640,
733
+ height: 480,
734
+ fps: 30,
735
+ isSeries: false,
736
+ children: [
737
+ {
738
+ id: "desc-img",
739
+ type: "image",
740
+ src: "https://picsum.photos/seed/desc-render/640/480",
741
+ instruction: "Preserved instruction",
742
+ fit: "cover",
743
+ actions: [{ start: 0, end: 2 }],
744
+ },
745
+ ],
746
+ },
747
+ };
748
+
749
+ const { writeFileSync } = await import("node:fs");
750
+ const tmpFixture = outPath("_instruction.json");
751
+ writeFileSync(tmpFixture, JSON.stringify(fixture));
752
+
753
+ const output = renderFixture(tmpFixture, {
754
+ outputName: "instruction.mp4",
755
+ timeout: RENDER_TIMEOUT,
756
+ });
757
+
758
+ const info = getVideoInfo(output);
759
+ expect(info.width).toBe(640);
760
+ expect(info.fileSizeBytes).toBeGreaterThan(1000);
761
+
762
+ try { rmSync(tmpFixture); } catch {}
763
+ });
764
+ });
765
+
766
+ // ───────────────────────────────────────────────────────────────────────────
767
+ // 15. Scene as Folder Alias
768
+ // ───────────────────────────────────────────────────────────────────────────
769
+
770
+ describe("Scene as Folder Alias", () => {
771
+ it("treats scene type nodes identically to folder type nodes", async () => {
772
+ // Both scene and folder should render the same structure.
773
+ // First render with folders, then with scenes, compare structure.
774
+ const fixtureScenes = {
775
+ root: {
776
+ id: "root",
777
+ type: "root",
778
+ width: 640,
779
+ height: 480,
780
+ fps: 30,
781
+ isSeries: true,
782
+ transition: "fade",
783
+ transitionTime: 0.3,
784
+ children: [
785
+ {
786
+ id: "scene-1",
787
+ type: "scene",
788
+ name: "Intro",
789
+ description: "Opening sequence",
790
+ children: [
791
+ {
792
+ id: "s1-img",
793
+ type: "image",
794
+ src: "https://picsum.photos/seed/scene-alias-1/640/480",
795
+ fit: "cover",
796
+ actions: [{ start: 0, end: 2 }],
797
+ },
798
+ ],
799
+ },
800
+ {
801
+ id: "scene-2",
802
+ type: "scene",
803
+ name: "Outro",
804
+ description: "Closing message",
805
+ children: [
806
+ {
807
+ id: "s2-img",
808
+ type: "image",
809
+ src: "https://picsum.photos/seed/scene-alias-2/640/480",
810
+ fit: "cover",
811
+ actions: [{ start: 0, end: 2 }],
812
+ },
813
+ ],
814
+ },
815
+ ],
816
+ },
817
+ };
818
+
819
+ const { writeFileSync } = await import("node:fs");
820
+ const tmpFixture = outPath("_scene-as-folder.json");
821
+ writeFileSync(tmpFixture, JSON.stringify(fixtureScenes));
822
+
823
+ const output = renderFixture(tmpFixture, {
824
+ outputName: "scene-as-folder.mp4",
825
+ timeout: RENDER_TIMEOUT,
826
+ });
827
+
828
+ const info = getVideoInfo(output);
829
+ expect(info.width).toBe(640);
830
+ // 2 scenes × 2s − 0.3s transition = ~3.7s
831
+ expect(info.durationSec).toBeGreaterThanOrEqual(3);
832
+
833
+ // Verify visual content at both scenes
834
+ const frame1 = outPath("frames/scene-alias-0.5s.png");
835
+ extractFrame(output, 0.5, frame1);
836
+ expect(isFrameNonBlank(frame1)).toBe(true);
837
+
838
+ const frame2 = outPath("frames/scene-alias-2.5s.png");
839
+ extractFrame(output, 2.5, frame2);
840
+ expect(isFrameNonBlank(frame2)).toBe(true);
841
+
842
+ try { rmSync(tmpFixture); } catch {}
843
+ });
844
+
845
+ it("mixes scene and folder types in same tree", async () => {
846
+ // A root using series with a mix of scene and folder children
847
+ const fixture = {
848
+ root: {
849
+ id: "root",
850
+ type: "root",
851
+ width: 640,
852
+ height: 480,
853
+ fps: 30,
854
+ isSeries: true,
855
+ transition: "fade",
856
+ transitionTime: 0.3,
857
+ children: [
858
+ {
859
+ id: "scene-mixed-1",
860
+ type: "scene",
861
+ name: "Scene-First",
862
+ children: [
863
+ {
864
+ id: "mix1-img",
865
+ type: "image",
866
+ src: "https://picsum.photos/seed/mix-scene/640/480",
867
+ fit: "cover",
868
+ actions: [{ start: 0, end: 2 }],
869
+ },
870
+ ],
871
+ },
872
+ {
873
+ id: "folder-mixed-2",
874
+ type: "folder",
875
+ name: "Folder-Second",
876
+ children: [
877
+ {
878
+ id: "mix2-img",
879
+ type: "image",
880
+ src: "https://picsum.photos/seed/mix-folder/640/480",
881
+ fit: "cover",
882
+ actions: [{ start: 0, end: 2 }],
883
+ },
884
+ ],
885
+ },
886
+ ],
887
+ },
888
+ };
889
+
890
+ const { writeFileSync } = await import("node:fs");
891
+ const tmpFixture = outPath("_mixed-scene-folder.json");
892
+ writeFileSync(tmpFixture, JSON.stringify(fixture));
893
+
894
+ const output = renderFixture(tmpFixture, {
895
+ outputName: "mixed-scene-folder.mp4",
896
+ timeout: RENDER_TIMEOUT,
897
+ });
898
+
899
+ const info = getVideoInfo(output);
900
+ expect(info.width).toBe(640);
901
+ expect(info.durationSec).toBeGreaterThanOrEqual(3);
902
+
903
+ try { rmSync(tmpFixture); } catch {}
904
+ });
905
+ });
906
+
907
+ // ───────────────────────────────────────────────────────────────────────────
908
+ // 16. Video startFrom/endAt Trimming + Photo in Series
909
+ // ───────────────────────────────────────────────────────────────────────────
910
+
911
+ describe("Video startFrom/endAt Trimming", () => {
912
+ it("renders a video trimmed with startFrom/endAt followed by a photo in series", async () => {
913
+ const output = renderFixture(fixturePath("video-series.json"), {
914
+ outputName: "video-series.mp4",
915
+ timeout: RENDER_TIMEOUT,
916
+ });
917
+
918
+ const info = getVideoInfo(output);
919
+ expect(info.width).toBe(640);
920
+ expect(info.height).toBe(480);
921
+ expect(info.fps).toBe(30);
922
+
923
+ // video-scene: 6s, photo-scene: 3s, transition: 0.3s → ~8.7s
924
+ expect(info.durationSec).toBeGreaterThanOrEqual(8);
925
+ expect(info.durationSec).toBeLessThanOrEqual(10);
926
+
927
+ // Extract frame at 3s — should be in video-scene (trimmed clip01.mp4, 2s-8s region)
928
+ const frameVideo = outPath("frames/video-series-3s.png");
929
+ extractFrame(output, 3, frameVideo);
930
+ expect(existsSync(frameVideo)).toBe(true);
931
+ expect(isFrameNonBlank(frameVideo)).toBe(true);
932
+
933
+ // Extract frame at 7s — should be in photo-scene (image card)
934
+ const framePhoto = outPath("frames/video-series-7s.png");
935
+ extractFrame(output, 7, framePhoto);
936
+ expect(existsSync(framePhoto)).toBe(true);
937
+ expect(isFrameNonBlank(framePhoto)).toBe(true);
938
+
939
+ // Verify both frames have meaningful content
940
+ expect(getFrameFileSize(frameVideo)).toBeGreaterThan(5000);
941
+ expect(getFrameFileSize(framePhoto)).toBeGreaterThan(5000);
942
+ });
943
+ });
944
+ // ───────────────────────────────────────────────────────────────────────────
945
+ // 18. Component Rendering (JSX + Tween)
946
+ // ───────────────────────────────────────────────────────────────────────────
947
+
948
+ describe("Component Rendering", () => {
949
+ it("renders HTML JSX and SVG tween components", async () => {
950
+ const output = renderFixture(fixturePath("component-all.json"), {
951
+ outputName: "component-all.mp4",
952
+ timeout: RENDER_TIMEOUT,
953
+ });
954
+ const info = getVideoInfo(output);
955
+ expect(info.width).toBe(640);
956
+ expect(info.height).toBe(480);
957
+ expect(info.fps).toBe(30);
958
+ expect(info.durationSec).toBeGreaterThanOrEqual(4.5);
959
+ expect(info.fileSizeBytes).toBeGreaterThan(1000);
960
+ });
961
+
962
+ it("schema parses component-all fixture correctly", async () => {
963
+ const { root: rootSchema } = await import("../src/schema/index");
964
+ const { readFileSync } = await import("node:fs");
965
+ const raw = JSON.parse(readFileSync(fixturePath("component-all.json"), "utf-8"));
966
+ const data = raw.root ?? raw;
967
+ const parsed = rootSchema.parse(data);
968
+ expect(parsed.type).toBe("root");
969
+ expect(parsed.children).toHaveLength(2);
970
+
971
+ // Scene 1: HTML JSX
972
+ const scene1 = parsed.children[0];
973
+ expect(scene1.type).toBe("scene");
974
+ const comp1 = scene1.children[0] as any;
975
+ expect(comp1.type).toBe("component");
976
+ expect(comp1.jsx).toContain("Hello World");
977
+
978
+ // Scene 2: Tween animation
979
+ const scene2 = parsed.children[1];
980
+ expect(scene2.type).toBe("scene");
981
+ const comp2 = scene2.children[0] as any;
982
+ expect(comp2.type).toBe("component");
983
+ expect(comp2.jsx).toContain("tween");
984
+ });
985
+ });