@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
package/tests/utils.ts
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test utilities for the Remotion Engine integration tests.
|
|
3
|
+
*
|
|
4
|
+
* Provides helper functions for rendering stream trees, extracting frames,
|
|
5
|
+
* and running STT (speech-to-text) on rendered video output.
|
|
6
|
+
*/
|
|
7
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, statSync } from "node:fs";
|
|
9
|
+
import { resolve, dirname, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { config } from "dotenv";
|
|
12
|
+
import { DEFAULT_STT_CLI } from "../src/config.mjs";
|
|
13
|
+
|
|
14
|
+
// Load .env from project root so env vars (GOOGLE_MAPS_API_KEY, etc.) are available
|
|
15
|
+
config({ path: resolve(fileURLToPath(import.meta.url), "../../.env") });
|
|
16
|
+
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
+
const __dirname = dirname(__filename);
|
|
19
|
+
export const ROOT = resolve(__dirname, "..");
|
|
20
|
+
export const OUT_DIR = resolve(__dirname, "out");
|
|
21
|
+
export const FIXTURES_DIR = resolve(__dirname, "fixtures");
|
|
22
|
+
|
|
23
|
+
// Ensure output directory exists
|
|
24
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
25
|
+
|
|
26
|
+
// ── Configuration ──────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** Default render timeout (5 min) */
|
|
29
|
+
export const RENDER_TIMEOUT = 300_000;
|
|
30
|
+
|
|
31
|
+
/** Default STT timeout (2 min) */
|
|
32
|
+
export const STT_TIMEOUT = 120_000;
|
|
33
|
+
|
|
34
|
+
// ── Render Helpers ─────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export interface RenderOptions {
|
|
37
|
+
/** Composition ID (default: "Root") */
|
|
38
|
+
composition?: string;
|
|
39
|
+
/** Output filename (relative to tests/out/) */
|
|
40
|
+
outputName?: string;
|
|
41
|
+
/** Timeout in ms */
|
|
42
|
+
timeout?: number;
|
|
43
|
+
/** Whether to show verbose output */
|
|
44
|
+
verbose?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Render a stream tree JSON fixture to an MP4 video.
|
|
49
|
+
*
|
|
50
|
+
* @param fixturePath - Path to the JSON fixture file (absolute or relative to ROOT)
|
|
51
|
+
* @param options - Render options
|
|
52
|
+
* @returns The absolute path to the rendered MP4 file
|
|
53
|
+
*/
|
|
54
|
+
export function renderFixture(
|
|
55
|
+
fixturePath: string,
|
|
56
|
+
options: RenderOptions = {},
|
|
57
|
+
): string {
|
|
58
|
+
const {
|
|
59
|
+
composition = "Root",
|
|
60
|
+
outputName = `${basename(fixturePath).replace(/\.json$/, "")}.mp4`,
|
|
61
|
+
timeout = RENDER_TIMEOUT,
|
|
62
|
+
verbose = false,
|
|
63
|
+
} = options;
|
|
64
|
+
|
|
65
|
+
const absFixturePath = fixturePath.startsWith("/")
|
|
66
|
+
? fixturePath
|
|
67
|
+
: resolve(ROOT, fixturePath);
|
|
68
|
+
|
|
69
|
+
if (!existsSync(absFixturePath)) {
|
|
70
|
+
throw new Error(`Fixture not found: ${absFixturePath}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const raw = JSON.parse(readFileSync(absFixturePath, "utf-8"));
|
|
74
|
+
const streamTree = raw.root ?? raw;
|
|
75
|
+
|
|
76
|
+
const tmpProps = resolve(OUT_DIR, `_props-${outputName}.json`);
|
|
77
|
+
writeFileSync(tmpProps, JSON.stringify({ root: streamTree }));
|
|
78
|
+
|
|
79
|
+
const outPath = resolve(OUT_DIR, outputName);
|
|
80
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
81
|
+
|
|
82
|
+
const cmd = `npx remotion render ${composition} "${outPath}" --props="${tmpProps}" --config=remotion.config.ts --log=error`;
|
|
83
|
+
|
|
84
|
+
if (verbose) {
|
|
85
|
+
console.log(`\n▶ Rendering: ${cmd}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
execSync(cmd, {
|
|
90
|
+
cwd: ROOT,
|
|
91
|
+
stdio: verbose ? "inherit" : "pipe",
|
|
92
|
+
timeout,
|
|
93
|
+
env: { ...process.env, CI: "true" },
|
|
94
|
+
});
|
|
95
|
+
} catch (err: any) {
|
|
96
|
+
const stderr = err.stderr?.toString() || "";
|
|
97
|
+
const stdout = err.stdout?.toString() || "";
|
|
98
|
+
if (verbose) {
|
|
99
|
+
console.error("RENDER ERROR:", stderr);
|
|
100
|
+
}
|
|
101
|
+
// Check if the error is just "still processing" or a real error
|
|
102
|
+
if (!existsSync(outPath)) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Render failed for ${fixturePath}: ${err.message}\n${stderr.slice(0, 500)}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!existsSync(outPath)) {
|
|
110
|
+
throw new Error(`Output file not created: ${outPath}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Clean up temp props
|
|
114
|
+
try { rmSync(tmpProps); } catch {}
|
|
115
|
+
|
|
116
|
+
return outPath;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Render a scene-based video.json fixture to an MP4 video.
|
|
121
|
+
* Uses the Main16x9 composition.
|
|
122
|
+
*/
|
|
123
|
+
export function renderScenes(
|
|
124
|
+
fixturePath: string,
|
|
125
|
+
options: RenderOptions = {},
|
|
126
|
+
): string {
|
|
127
|
+
const {
|
|
128
|
+
composition = "Main16x9",
|
|
129
|
+
outputName = `scenes-${basename(fixturePath).replace(/\.json$/, "")}.mp4`,
|
|
130
|
+
timeout = RENDER_TIMEOUT,
|
|
131
|
+
verbose = false,
|
|
132
|
+
} = options;
|
|
133
|
+
|
|
134
|
+
const absFixturePath = fixturePath.startsWith("/")
|
|
135
|
+
? fixturePath
|
|
136
|
+
: resolve(ROOT, fixturePath);
|
|
137
|
+
|
|
138
|
+
if (!existsSync(absFixturePath)) {
|
|
139
|
+
throw new Error(`Fixture not found: ${absFixturePath}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const props = readFileSync(absFixturePath, "utf-8");
|
|
143
|
+
|
|
144
|
+
const tmpProps = resolve(OUT_DIR, `_props-${outputName}.json`);
|
|
145
|
+
writeFileSync(tmpProps, props);
|
|
146
|
+
|
|
147
|
+
const outPath = resolve(OUT_DIR, outputName);
|
|
148
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
149
|
+
|
|
150
|
+
const cmd = `npx remotion render ${composition} "${outPath}" --props="${tmpProps}" --config=remotion.config.ts --log=error`;
|
|
151
|
+
|
|
152
|
+
if (verbose) {
|
|
153
|
+
console.log(`\n▶ Rendering scenes: ${cmd}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
execSync(cmd, {
|
|
158
|
+
cwd: ROOT,
|
|
159
|
+
stdio: verbose ? "inherit" : "pipe",
|
|
160
|
+
timeout,
|
|
161
|
+
env: { ...process.env, CI: "true" },
|
|
162
|
+
});
|
|
163
|
+
} catch (err: any) {
|
|
164
|
+
const stderr = err.stderr?.toString() || "";
|
|
165
|
+
if (!existsSync(outPath)) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Scene render failed for ${fixturePath}: ${err.message}\n${stderr.slice(0, 500)}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!existsSync(outPath)) {
|
|
173
|
+
throw new Error(`Output file not created: ${outPath}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try { rmSync(tmpProps); } catch {}
|
|
177
|
+
|
|
178
|
+
return outPath;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Video Analysis ─────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export interface VideoInfo {
|
|
184
|
+
path: string;
|
|
185
|
+
durationSec: number;
|
|
186
|
+
width: number;
|
|
187
|
+
height: number;
|
|
188
|
+
fps: number;
|
|
189
|
+
hasAudio: boolean;
|
|
190
|
+
fileSizeBytes: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get video metadata using ffprobe.
|
|
195
|
+
*/
|
|
196
|
+
export function getVideoInfo(videoPath: string): VideoInfo {
|
|
197
|
+
if (!existsSync(videoPath)) {
|
|
198
|
+
throw new Error(`Video not found: ${videoPath}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const ffprobe = `ffprobe -v quiet -print_format json -show_format -show_streams "${videoPath}"`;
|
|
202
|
+
const output = execSync(ffprobe, { encoding: "utf-8" });
|
|
203
|
+
const data = JSON.parse(output);
|
|
204
|
+
|
|
205
|
+
const videoStream = data.streams?.find((s: any) => s.codec_type === "video");
|
|
206
|
+
const audioStream = data.streams?.find((s: any) => s.codec_type === "audio");
|
|
207
|
+
|
|
208
|
+
const durationSec = parseFloat(data.format?.duration || "0");
|
|
209
|
+
const frameRate = videoStream?.avg_frame_rate || videoStream?.r_frame_rate || "30/1";
|
|
210
|
+
const [num, den] = frameRate.split("/").map(Number);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
path: videoPath,
|
|
214
|
+
durationSec,
|
|
215
|
+
width: videoStream?.width || 0,
|
|
216
|
+
height: videoStream?.height || 0,
|
|
217
|
+
fps: den ? Math.round(num / den) : 30,
|
|
218
|
+
hasAudio: !!audioStream,
|
|
219
|
+
fileSizeBytes: parseInt(data.format?.size || "0", 10),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Extract a specific frame from a video as PNG.
|
|
225
|
+
* @param videoPath - Path to the video
|
|
226
|
+
* @param timeSeconds - Time offset in seconds
|
|
227
|
+
* @param outputPath - Where to save the PNG
|
|
228
|
+
* @returns The output path
|
|
229
|
+
*/
|
|
230
|
+
export function extractFrame(
|
|
231
|
+
videoPath: string,
|
|
232
|
+
timeSeconds: number,
|
|
233
|
+
outputPath: string,
|
|
234
|
+
): string {
|
|
235
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
236
|
+
const cmd = `ffmpeg -y -ss ${timeSeconds} -i "${videoPath}" -vframes 1 -f image2 "${outputPath}" 2>/dev/null`;
|
|
237
|
+
execSync(cmd, { stdio: "pipe" });
|
|
238
|
+
if (!existsSync(outputPath)) {
|
|
239
|
+
throw new Error(`Frame extraction failed at ${timeSeconds}s for ${videoPath}`);
|
|
240
|
+
}
|
|
241
|
+
return outputPath;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Extract audio from a video as WAV.
|
|
246
|
+
* @param videoPath - Path to the video
|
|
247
|
+
* @param outputPath - Where to save the WAV
|
|
248
|
+
* @returns The output path
|
|
249
|
+
*/
|
|
250
|
+
export function extractAudio(
|
|
251
|
+
videoPath: string,
|
|
252
|
+
outputPath: string,
|
|
253
|
+
): string {
|
|
254
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
255
|
+
const cmd = `ffmpeg -y -i "${videoPath}" -vn -acodec pcm_s16le -ar 16000 -ac 1 "${outputPath}" 2>/dev/null`;
|
|
256
|
+
execSync(cmd, { stdio: "pipe" });
|
|
257
|
+
if (!existsSync(outputPath)) {
|
|
258
|
+
throw new Error(`Audio extraction failed for ${videoPath}`);
|
|
259
|
+
}
|
|
260
|
+
return outputPath;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Run STT on an audio file using the configured STT CLI (DEFAULT_STT_CLI).
|
|
265
|
+
* @returns The transcribed text
|
|
266
|
+
*/
|
|
267
|
+
export function transcribeAudio(audioPath: string, sttCli?: string): string {
|
|
268
|
+
const cli = sttCli || DEFAULT_STT_CLI;
|
|
269
|
+
if (!cli) return "[stt not configured]";
|
|
270
|
+
|
|
271
|
+
const outputDir = dirname(audioPath);
|
|
272
|
+
const cmd = cli.replace(/\{input\}/g, audioPath).replace(/\{output\}/g, outputDir);
|
|
273
|
+
try {
|
|
274
|
+
execSync(cmd, { stdio: "pipe", timeout: STT_TIMEOUT, cwd: dirname(audioPath) });
|
|
275
|
+
// Whisper STT outputs a .vtt file next to the audio file
|
|
276
|
+
const baseName = audioPath.replace(/\.\w+$/, "");
|
|
277
|
+
const vttPath = baseName + ".vtt";
|
|
278
|
+
if (existsSync(vttPath)) {
|
|
279
|
+
const vtt = readFileSync(vttPath, "utf-8").trim();
|
|
280
|
+
try { rmSync(vttPath); } catch {}
|
|
281
|
+
// Extract plain text from VTT (strip timestamps and headers)
|
|
282
|
+
return vtt
|
|
283
|
+
.replace(/^WEBVTT.*\n?/m, "")
|
|
284
|
+
.replace(/\d{2}:\d{2}:\d{2}\.\d{3}.*/g, "")
|
|
285
|
+
.replace(/-->.*/g, "")
|
|
286
|
+
.replace(/\n{2,}/g, "\n")
|
|
287
|
+
.trim();
|
|
288
|
+
}
|
|
289
|
+
return "";
|
|
290
|
+
} catch (err: any) {
|
|
291
|
+
console.warn(`STT failed for ${audioPath}: ${err.message}`);
|
|
292
|
+
return `[stt error: ${err.message}]`;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Get the pixel data of a specific frame (first row center pixel) to verify
|
|
298
|
+
* the video is not blank/black.
|
|
299
|
+
*/
|
|
300
|
+
export function getFramePixelInfo(
|
|
301
|
+
framePath: string,
|
|
302
|
+
x = -1,
|
|
303
|
+
y = -1,
|
|
304
|
+
): { r: number; g: number; b: number } | null {
|
|
305
|
+
if (!existsSync(framePath)) return null;
|
|
306
|
+
|
|
307
|
+
// Use ffmpeg to get pixel info
|
|
308
|
+
const fx = x >= 0 ? x : 320; // center-ish for 640 width
|
|
309
|
+
const fy = y >= 0 ? y : 240; // center-ish for 480 height
|
|
310
|
+
const cmd = `ffmpeg -y -i "${framePath}" -vf "crop=1:1:${fx}:${fy}" -vframes 1 -f rawvideo -pix_fmt rgb24 pipe: 2>/dev/null`;
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
const output = execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] });
|
|
314
|
+
if (output.length >= 3) {
|
|
315
|
+
return { r: output[0], g: output[1], b: output[2] };
|
|
316
|
+
}
|
|
317
|
+
} catch {}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Verify a frame is not entirely black (has visual content).
|
|
323
|
+
*/
|
|
324
|
+
export function isFrameNonBlank(framePath: string): boolean {
|
|
325
|
+
const pixel = getFramePixelInfo(framePath);
|
|
326
|
+
if (!pixel) return false;
|
|
327
|
+
return pixel.r > 10 || pixel.g > 10 || pixel.b > 10;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Get the file size of a frame in bytes.
|
|
332
|
+
* A non-blank frame will be significantly larger than a blank one.
|
|
333
|
+
*/
|
|
334
|
+
export function getFrameFileSize(framePath: string): number {
|
|
335
|
+
if (!existsSync(framePath)) return 0;
|
|
336
|
+
try {
|
|
337
|
+
return statSync(framePath).size;
|
|
338
|
+
} catch {
|
|
339
|
+
return 0;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── Duration Calculation Test Helpers ──────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Get the expected duration of a stream tree fixture in seconds.
|
|
347
|
+
*/
|
|
348
|
+
export function getExpectedDuration(fixtureData: any): number {
|
|
349
|
+
if (fixtureData.durationInSeconds) return fixtureData.durationInSeconds;
|
|
350
|
+
|
|
351
|
+
// For root with series children, sum the durations
|
|
352
|
+
if (fixtureData.type === "root" || fixtureData.type === "folder") {
|
|
353
|
+
if (fixtureData.isSeries && Array.isArray(fixtureData.children)) {
|
|
354
|
+
let total = 0;
|
|
355
|
+
for (const child of fixtureData.children) {
|
|
356
|
+
const dur = getExpectedDuration(child);
|
|
357
|
+
total += dur;
|
|
358
|
+
}
|
|
359
|
+
// Subtract transition overlaps
|
|
360
|
+
const transitionTime = fixtureData.transitionTime ?? 0.5;
|
|
361
|
+
const transitions = fixtureData.children.length - 1;
|
|
362
|
+
if (transitions > 0 && fixtureData.transition) {
|
|
363
|
+
total -= transitions * transitionTime;
|
|
364
|
+
}
|
|
365
|
+
return Math.max(1, total);
|
|
366
|
+
}
|
|
367
|
+
if (Array.isArray(fixtureData.children)) {
|
|
368
|
+
let maxDur = 0;
|
|
369
|
+
for (const child of fixtureData.children) {
|
|
370
|
+
const dur = getExpectedDuration(child);
|
|
371
|
+
if (!child.isBackground) {
|
|
372
|
+
maxDur = Math.max(maxDur, dur);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return Math.max(1, maxDur);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Leaf action duration
|
|
380
|
+
if (fixtureData.actions?.length) {
|
|
381
|
+
return Math.max(...fixtureData.actions.map((a: any) => a.end ?? 1));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return 1;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
function basename(path: string): string {
|
|
390
|
+
return path.split("/").pop() || path.split("\\").pop() || path;
|
|
391
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
// Integration tests can be slow — 5 min timeout per test
|
|
6
|
+
testTimeout: 600_000,
|
|
7
|
+
hookTimeout: 600_000,
|
|
8
|
+
// Don't run tests in parallel — rendering is resource-intensive
|
|
9
|
+
pool: "forks",
|
|
10
|
+
fileParallelism: false,
|
|
11
|
+
// Print detailed output
|
|
12
|
+
reporters: ["verbose"],
|
|
13
|
+
// Pass through environment
|
|
14
|
+
env: {
|
|
15
|
+
CI: "true",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
// Integration tests render videos — long timeout
|
|
6
|
+
testTimeout: 600_000,
|
|
7
|
+
hookTimeout: 600_000,
|
|
8
|
+
// Only run the integration test file
|
|
9
|
+
include: ["tests/render.test.ts"],
|
|
10
|
+
// Don't run in parallel — rendering is resource-intensive
|
|
11
|
+
pool: "forks",
|
|
12
|
+
fileParallelism: false,
|
|
13
|
+
reporters: ["verbose"],
|
|
14
|
+
env: { CI: "true" },
|
|
15
|
+
},
|
|
16
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"allowImportingTsExtensions": false,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"isolatedModules": true,
|
|
16
|
+
"forceConsistentCasingInFileNames": true,
|
|
17
|
+
"types": ["node"]
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*"]
|
|
20
|
+
}
|