@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.
- package/.env.example +27 -0
- package/.github/user-steer.md +9 -0
- package/.vscode/settings.json +3 -0
- package/AGENTS.md +271 -0
- package/README.md +219 -0
- package/SKILL.md +209 -0
- package/docs/dynamic-components.md +191 -0
- package/docs/edit-mode.md +220 -0
- package/docs/json-descriptive.md +539 -0
- package/docs/label-mode.md +110 -0
- package/docs/markdown-descriptive.md +751 -0
- package/docs/templates.md +52 -0
- package/package.json +64 -0
- package/remotion.config.ts +5 -0
- package/scripts/artlist-dl.mjs +190 -0
- package/scripts/build-pipeline.sh +19 -0
- package/scripts/build-player.sh +20 -0
- package/src/Root.tsx +55 -0
- package/src/config.mjs +88 -0
- package/src/context/EventContext.tsx +168 -0
- package/src/context/index.tsx +42 -0
- package/src/descriptive/compiler.test.ts +1135 -0
- package/src/descriptive/compiler.ts +1230 -0
- package/src/descriptive/dsl.ts +455 -0
- package/src/descriptive/markdown.test.ts +866 -0
- package/src/descriptive/markdown.ts +674 -0
- package/src/descriptive/resolve.test.ts +951 -0
- package/src/descriptive/resolve.ts +891 -0
- package/src/entry.tsx +163 -0
- package/src/index.ts +4 -0
- package/src/player/browser.tsx +356 -0
- package/src/player/bundle/player.js +60259 -0
- package/src/player/bundler.mjs +269 -0
- package/src/player/label-server.mjs +599 -0
- package/src/player/pipeline.mjs +11123 -0
- package/src/player/pipeline.ts +117 -0
- package/src/player/server-shared.mjs +144 -0
- package/src/player/server.mjs +1006 -0
- package/src/render/cli-tools.ts +177 -0
- package/src/render/cli.mjs +628 -0
- package/src/schema/index.ts +259 -0
- package/src/types/Audio.tsx +56 -0
- package/src/types/Component.tsx +135 -0
- package/src/types/Effect.tsx +88 -0
- package/src/types/Folder.tsx +180 -0
- package/src/types/FrameSyncStyle.tsx +51 -0
- package/src/types/Image.tsx +51 -0
- package/src/types/Include.tsx +394 -0
- package/src/types/Map.tsx +252 -0
- package/src/types/Rhythm.tsx +58 -0
- package/src/types/Scene.tsx +42 -0
- package/src/types/Subtitle.tsx +218 -0
- package/src/types/Video.tsx +70 -0
- package/src/types/keyframes.ts +454 -0
- package/src/utils/__tests__/vtt.test.ts +129 -0
- package/src/utils/index.ts +168 -0
- package/src/utils/tween.ts +118 -0
- package/src/vision/cli.mjs +1187 -0
- package/src/vision/vision_prompts.md +67 -0
- package/tests/dsl.test.ts +317 -0
- package/tests/fixtures/audio.json +25 -0
- package/tests/fixtures/basic.json +27 -0
- package/tests/fixtures/component-all.json +38 -0
- package/tests/fixtures/components.json +38 -0
- package/tests/fixtures/effects.json +64 -0
- package/tests/fixtures/full.json +51 -0
- package/tests/fixtures/map.json +23 -0
- package/tests/fixtures/md/all-nodes.md +28 -0
- package/tests/fixtures/md/basic.md +6 -0
- package/tests/fixtures/md/component-imports.md +20 -0
- package/tests/fixtures/md/edge-cases.md +33 -0
- package/tests/fixtures/md/effects.md +17 -0
- package/tests/fixtures/md/frontmatter.md +20 -0
- package/tests/fixtures/md/full-feature.md +58 -0
- package/tests/fixtures/md/imports-block.md +19 -0
- package/tests/fixtures/md/include-main.md +11 -0
- package/tests/fixtures/md/include-sub.md +25 -0
- package/tests/fixtures/md/jsx-code-fence.md +21 -0
- package/tests/fixtures/md/map.md +11 -0
- package/tests/fixtures/md/nested-scenes.md +25 -0
- package/tests/fixtures/md/rhythm.md +17 -0
- package/tests/fixtures/md/scenes.md +16 -0
- package/tests/fixtures/md/tween.md +11 -0
- package/tests/fixtures/md/vars-test.md +6 -0
- package/tests/fixtures/scenes.json +40 -0
- package/tests/fixtures/subtitle.json +59 -0
- package/tests/fixtures/subvideo.json +59 -0
- package/tests/fixtures/templates/courseware.md +351 -0
- package/tests/fixtures/tween-visual.json +28 -0
- package/tests/fixtures/video-series.json +54 -0
- package/tests/md-descriptive.test.ts +742 -0
- package/tests/render.test.ts +985 -0
- package/tests/schema.test.ts +68 -0
- package/tests/server.test.ts +308 -0
- package/tests/utils.ts +391 -0
- package/tests/vitest.config.ts +18 -0
- package/tests/vitest.integration.config.ts +16 -0
- 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
|
+
});
|