@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,951 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, existsSync, rmSync, realpathSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { resolveMediaDurations, resolveScripts, resolveSubtitles, resolveGeneratedMedia, resolveIncludes } from "./resolve";
|
|
6
|
+
import type { DescriptiveRoot } from "./compiler";
|
|
7
|
+
|
|
8
|
+
// ── Mock CLI tools ────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
vi.mock("../render/cli-tools", () => ({
|
|
11
|
+
generateTTS: vi.fn(),
|
|
12
|
+
generateSTT: vi.fn(),
|
|
13
|
+
generateTTI: vi.fn(),
|
|
14
|
+
generateTTV: vi.fn(),
|
|
15
|
+
// Default CLI templates (needed by resolve.ts imports)
|
|
16
|
+
DEFAULT_TTS_CLI: 'echo tts {input} > {output}',
|
|
17
|
+
DEFAULT_STT_CLI: 'echo stt {input} > {output}',
|
|
18
|
+
DEFAULT_TTI_CLI: 'echo tti {input} > {output}',
|
|
19
|
+
DEFAULT_TTV_CLI: '',
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { generateTTS, generateSTT, generateTTI, generateTTV } from "../render/cli-tools";
|
|
23
|
+
|
|
24
|
+
let tmpDir: string;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
tmpDir = mkdtempSync(join(realpathSync(tmpdir()), "resolve-test-"));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ── resolveScripts (TTS) ───────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
describe("resolveScripts", () => {
|
|
38
|
+
it("calls generateTTS for each audio node with script", async () => {
|
|
39
|
+
(generateTTS as any).mockReturnValue(join(tmpDir, "hook.wav"));
|
|
40
|
+
|
|
41
|
+
const root: DescriptiveRoot = {
|
|
42
|
+
children: [{
|
|
43
|
+
type: "scene", name: "Hook", layout: "parallel",
|
|
44
|
+
children: [{ type: "audio", id: "Hook", script: "Hello world", volume: 1 } as any],
|
|
45
|
+
}],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const result = await resolveScripts(root, { outputDir: tmpDir });
|
|
49
|
+
|
|
50
|
+
// Should have resolved src on the audio node, script removed
|
|
51
|
+
const scene = result.children[0] as any;
|
|
52
|
+
expect(scene.children).toHaveLength(1);
|
|
53
|
+
expect(scene.children[0].type).toBe("audio");
|
|
54
|
+
expect(scene.children[0].src).toBe(join(tmpDir, "hook.wav"));
|
|
55
|
+
expect(scene.children[0].script).toBeUndefined();
|
|
56
|
+
|
|
57
|
+
// When no ttsCli override, falls back to DEFAULT_TTS_CLI
|
|
58
|
+
// Filename is a content hash (not node id) for deduplication across files
|
|
59
|
+
expect(generateTTS).toHaveBeenCalledWith(
|
|
60
|
+
"Hello world",
|
|
61
|
+
expect.stringMatching(/\.mp3$/),
|
|
62
|
+
expect.stringContaining("tts"),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("skips scenes without a script audio node", async () => {
|
|
67
|
+
const root: DescriptiveRoot = {
|
|
68
|
+
children: [{
|
|
69
|
+
type: "scene", name: "Silent", layout: "parallel", children: [],
|
|
70
|
+
}],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const result = await resolveScripts(root, { outputDir: tmpDir });
|
|
74
|
+
expect((result.children[0] as any).children).toHaveLength(0);
|
|
75
|
+
expect(generateTTS).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("skips audio nodes that already have src", async () => {
|
|
79
|
+
const root: DescriptiveRoot = {
|
|
80
|
+
children: [{
|
|
81
|
+
type: "scene", name: "Done", layout: "parallel",
|
|
82
|
+
children: [{ type: "audio", src: "existing.wav", script: "Already have audio", volume: 1 } as any],
|
|
83
|
+
}],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
await resolveScripts(root, { outputDir: tmpDir });
|
|
87
|
+
expect(generateTTS).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("propagates ttsCli option to generateTTS", async () => {
|
|
91
|
+
(generateTTS as any).mockReturnValue(join(tmpDir, "custom.wav"));
|
|
92
|
+
|
|
93
|
+
const root: DescriptiveRoot = {
|
|
94
|
+
children: [{
|
|
95
|
+
type: "scene", name: "Custom", layout: "parallel",
|
|
96
|
+
children: [{ type: "audio", script: "Text", volume: 1 } as any],
|
|
97
|
+
}],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
await resolveScripts(root, { outputDir: tmpDir, ttsCli: "custom-tts {input} {output}" });
|
|
101
|
+
expect(generateTTS).toHaveBeenCalledWith("Text", expect.any(String), "custom-tts {input} {output}");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── resolveSubtitles (STT) ─────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
describe("resolveSubtitles", () => {
|
|
108
|
+
it("runs STT on audio children and produces merged VTT", async () => {
|
|
109
|
+
const audioPath = join(tmpDir, "narration.wav");
|
|
110
|
+
writeFileSync(audioPath, "fake-audio");
|
|
111
|
+
|
|
112
|
+
(generateSTT as any).mockImplementation(async () => {
|
|
113
|
+
writeFileSync(join(tmpDir, "narration.vtt"),
|
|
114
|
+
"WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nHello world\n");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const root: DescriptiveRoot = {
|
|
118
|
+
children: [{
|
|
119
|
+
type: "scene", name: "S", layout: "parallel",
|
|
120
|
+
children: [{
|
|
121
|
+
id: "a1", type: "audio", src: audioPath,
|
|
122
|
+
actions: [{ id: "act1", start: 5, end: 8 }],
|
|
123
|
+
} as any],
|
|
124
|
+
durationInSeconds: 8,
|
|
125
|
+
} as any],
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const result = await resolveSubtitles(root, { outputDir: tmpDir });
|
|
129
|
+
expect(result.subtitle).toBeDefined();
|
|
130
|
+
expect(result.subtitle!.src).toBe(join(tmpDir, "subtitles.vtt"));
|
|
131
|
+
expect(existsSync(result.subtitle!.src)).toBe(true);
|
|
132
|
+
|
|
133
|
+
// Timestamps should be shifted by the audio offset (5s)
|
|
134
|
+
const vtt = (await import("node:fs")).readFileSync(result.subtitle!.src, "utf-8");
|
|
135
|
+
expect(vtt).toContain("00:00:06.000 --> 00:00:08.000");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("returns clone unchanged when no sttCli and no root.stt", async () => {
|
|
139
|
+
const root: DescriptiveRoot = { children: [] };
|
|
140
|
+
const result = await resolveSubtitles(root, { outputDir: tmpDir });
|
|
141
|
+
expect(result.subtitle).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ── resolveGeneratedMedia (TTI / TTV) ─────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
describe("resolveGeneratedMedia", () => {
|
|
148
|
+
it("generates image from prompt via TTI CLI", async () => {
|
|
149
|
+
(generateTTI as any).mockImplementation((_p, out) => out);
|
|
150
|
+
|
|
151
|
+
const root: DescriptiveRoot = {
|
|
152
|
+
children: [{
|
|
153
|
+
type: "scene", name: "S", layout: "parallel",
|
|
154
|
+
children: [{ type: "image", prompt: "sunset", duration: 3 }],
|
|
155
|
+
}],
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const result = await resolveGeneratedMedia(root, { outputDir: tmpDir, ttiCli: "gen-img --prompt {input} --out {output}" });
|
|
159
|
+
const img = (result.children[0] as any).children[0];
|
|
160
|
+
expect(img.src).toBeTruthy();
|
|
161
|
+
expect(img.src).toMatch(/\.png$/);
|
|
162
|
+
expect(generateTTI).toHaveBeenCalledWith(
|
|
163
|
+
"sunset",
|
|
164
|
+
expect.stringMatching(/\.png$/),
|
|
165
|
+
"gen-img --prompt {input} --out {output}",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("generates video from prompt via TTV CLI", async () => {
|
|
170
|
+
(generateTTV as any).mockImplementation((_p, out) => out);
|
|
171
|
+
|
|
172
|
+
const root: DescriptiveRoot = {
|
|
173
|
+
children: [{
|
|
174
|
+
type: "scene", name: "S", layout: "parallel",
|
|
175
|
+
children: [{ type: "video", prompt: "ocean waves", duration: 5 }],
|
|
176
|
+
}],
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const result = await resolveGeneratedMedia(root, { outputDir: tmpDir, ttvCli: "gen-vid --prompt {input} --out {output}" });
|
|
180
|
+
const vid = (result.children[0] as any).children[0];
|
|
181
|
+
expect(vid.src).toBeTruthy();
|
|
182
|
+
expect(vid.src).toMatch(/\.mp4$/);
|
|
183
|
+
expect(generateTTV).toHaveBeenCalledWith(
|
|
184
|
+
"ocean waves",
|
|
185
|
+
expect.stringMatching(/\.mp4$/),
|
|
186
|
+
"gen-vid --prompt {input} --out {output}",
|
|
187
|
+
expect.any(String), // ttiCmd defaults to DEFAULT_TTI_CLI
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("skips nodes that already have src", async () => {
|
|
192
|
+
const root: DescriptiveRoot = {
|
|
193
|
+
children: [{
|
|
194
|
+
type: "scene", name: "S", layout: "parallel",
|
|
195
|
+
children: [{ type: "image", src: "existing.png", prompt: "skip me", duration: 3 }],
|
|
196
|
+
}],
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
await resolveGeneratedMedia(root, { outputDir: tmpDir });
|
|
200
|
+
expect(generateTTI).not.toHaveBeenCalled();
|
|
201
|
+
expect(generateTTV).not.toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("uses default TTI CLI when no options or root.tti provided", async () => {
|
|
205
|
+
const out = join(tmpDir, "image-0.png");
|
|
206
|
+
(generateTTI as any).mockReturnValue(out);
|
|
207
|
+
|
|
208
|
+
const root: DescriptiveRoot = {
|
|
209
|
+
children: [{
|
|
210
|
+
type: "scene", name: "S", layout: "parallel",
|
|
211
|
+
children: [{ type: "image", prompt: "default test", duration: 3 }],
|
|
212
|
+
}],
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
await resolveGeneratedMedia(root, { outputDir: tmpDir });
|
|
216
|
+
expect(generateTTI).toHaveBeenCalledTimes(1);
|
|
217
|
+
const prompt = (generateTTI as any).mock.calls[0][0];
|
|
218
|
+
expect(prompt).toBe("default test");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("root.tti overrides options.ttiCli", async () => {
|
|
222
|
+
(generateTTI as any).mockImplementation((_p, out) => out);
|
|
223
|
+
|
|
224
|
+
const root: DescriptiveRoot = {
|
|
225
|
+
tti: "root-tti {input} --out {output}",
|
|
226
|
+
children: [{
|
|
227
|
+
type: "scene", name: "S", layout: "parallel",
|
|
228
|
+
children: [{ type: "image", prompt: "override test", duration: 3 }],
|
|
229
|
+
}],
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
await resolveGeneratedMedia(root, { outputDir: tmpDir, ttiCli: "option-tti {input}" });
|
|
233
|
+
expect(generateTTI).toHaveBeenCalledWith(
|
|
234
|
+
"override test",
|
|
235
|
+
expect.stringMatching(/\.png$/),
|
|
236
|
+
"root-tti {input} --out {output}",
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("options.ttiCli overrides default when no root.tti", async () => {
|
|
241
|
+
(generateTTI as any).mockImplementation((_p, out) => out);
|
|
242
|
+
|
|
243
|
+
const root: DescriptiveRoot = {
|
|
244
|
+
children: [{
|
|
245
|
+
type: "scene", name: "S", layout: "parallel",
|
|
246
|
+
children: [{ type: "image", prompt: "test", duration: 3 }],
|
|
247
|
+
}],
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
await resolveGeneratedMedia(root, { outputDir: tmpDir, ttiCli: "custom-tti --prompt {input} --out {output}" });
|
|
251
|
+
expect(generateTTI).toHaveBeenCalledWith(
|
|
252
|
+
"test",
|
|
253
|
+
expect.stringMatching(/\.png$/),
|
|
254
|
+
"custom-tti --prompt {input} --out {output}",
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("skips node when generateTTI produces no output", async () => {
|
|
259
|
+
(generateTTI as any).mockReturnValue("");
|
|
260
|
+
|
|
261
|
+
const root: DescriptiveRoot = {
|
|
262
|
+
children: [{
|
|
263
|
+
type: "scene", name: "S", layout: "parallel",
|
|
264
|
+
children: [{ type: "image", prompt: "fail", duration: 3 }],
|
|
265
|
+
}],
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const result = await resolveGeneratedMedia(root, { outputDir: tmpDir, ttiCli: "gen {input}" });
|
|
269
|
+
const img = (result.children[0] as any).children[0];
|
|
270
|
+
expect(img.src).toBeUndefined();
|
|
271
|
+
expect(generateTTI).toHaveBeenCalledTimes(1);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("continues when generateTTI throws", async () => {
|
|
275
|
+
(generateTTI as any).mockImplementation(() => { throw new Error("CLI not found"); });
|
|
276
|
+
|
|
277
|
+
const root: DescriptiveRoot = {
|
|
278
|
+
children: [{
|
|
279
|
+
type: "scene", name: "S", layout: "parallel",
|
|
280
|
+
children: [{ type: "image", prompt: "fail", duration: 3 }],
|
|
281
|
+
}],
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const result = await resolveGeneratedMedia(root, { outputDir: tmpDir, ttiCli: "gen {input}" });
|
|
285
|
+
const img = (result.children[0] as any).children[0];
|
|
286
|
+
expect(img.src).toBeUndefined();
|
|
287
|
+
// Does NOT throw — error is caught and logged
|
|
288
|
+
expect(generateTTI).toHaveBeenCalledTimes(1);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it.skip("caches generated output and skips second generateTTI call", async () => {
|
|
292
|
+
const cacheDir = join(tmpDir, ".markcut", "generated", "media");
|
|
293
|
+
let callCount = 0;
|
|
294
|
+
(generateTTI as any).mockImplementation((_p: string, outputPath: string) => {
|
|
295
|
+
callCount++;
|
|
296
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
297
|
+
writeFileSync(outputPath, "fake-png");
|
|
298
|
+
return outputPath;
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const root: DescriptiveRoot = {
|
|
302
|
+
children: [{
|
|
303
|
+
type: "scene", name: "S", layout: "parallel",
|
|
304
|
+
children: [{ type: "image", prompt: "cached", duration: 3 }],
|
|
305
|
+
}],
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// First call — generates
|
|
309
|
+
await resolveGeneratedMedia(root, { outputDir: cacheDir, ttiCli: "gen {input}" });
|
|
310
|
+
expect(callCount).toBe(1);
|
|
311
|
+
|
|
312
|
+
// Second call on same root with same prompt — should use cache
|
|
313
|
+
const result = await resolveGeneratedMedia(root, { outputDir: cacheDir, ttiCli: "gen {input}" });
|
|
314
|
+
const img = (result.children[0] as any).children[0];
|
|
315
|
+
expect(img.src).toBeTruthy();
|
|
316
|
+
expect(img.src).toMatch(/\.png$/);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("processes multiple image nodes with prompts", async () => {
|
|
320
|
+
(generateTTI as any).mockImplementation((_p, out) => out);
|
|
321
|
+
|
|
322
|
+
const root: DescriptiveRoot = {
|
|
323
|
+
children: [{
|
|
324
|
+
type: "scene", name: "S", layout: "parallel",
|
|
325
|
+
children: [
|
|
326
|
+
{ type: "image", prompt: "first", duration: 3 },
|
|
327
|
+
{ type: "image", prompt: "second", duration: 3 },
|
|
328
|
+
],
|
|
329
|
+
}],
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const result = await resolveGeneratedMedia(root, { outputDir: tmpDir, ttiCli: "gen {input}" });
|
|
333
|
+
const images = (result.children[0] as any).children;
|
|
334
|
+
expect(images[0].src).toBeTruthy();
|
|
335
|
+
expect(images[0].src).toMatch(/\.png$/);
|
|
336
|
+
expect(images[1].src).toBeTruthy();
|
|
337
|
+
expect(images[1].src).toMatch(/\.png$/);
|
|
338
|
+
expect(generateTTI).toHaveBeenCalledTimes(2);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// ── Additional TTS tests ───────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
describe("resolveScripts — additional", () => {
|
|
345
|
+
it("respects parent scene.tts override", async () => {
|
|
346
|
+
(generateTTS as any).mockReturnValue(join(tmpDir, "scene.wav"));
|
|
347
|
+
|
|
348
|
+
const root: DescriptiveRoot = {
|
|
349
|
+
children: [{
|
|
350
|
+
type: "scene", name: "S", tts: "scene-tts {input} --out {output}",
|
|
351
|
+
layout: "parallel",
|
|
352
|
+
children: [{ type: "audio", script: "text", volume: 1 } as any],
|
|
353
|
+
}],
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
await resolveScripts(root, { outputDir: tmpDir });
|
|
357
|
+
expect(generateTTS).toHaveBeenCalledWith("text", expect.any(String), "scene-tts {input} --out {output}");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("respects root.tts override when scene has no tts", async () => {
|
|
361
|
+
(generateTTS as any).mockReturnValue(join(tmpDir, "root.wav"));
|
|
362
|
+
|
|
363
|
+
const root: DescriptiveRoot = {
|
|
364
|
+
tts: "root-tts {input} --out {output}",
|
|
365
|
+
children: [{
|
|
366
|
+
type: "scene", name: "S", layout: "parallel",
|
|
367
|
+
children: [{ type: "audio", script: "text", volume: 1 } as any],
|
|
368
|
+
}],
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
await resolveScripts(root, { outputDir: tmpDir });
|
|
372
|
+
expect(generateTTS).toHaveBeenCalledWith("text", expect.any(String), "root-tts {input} --out {output}");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("parent scene.tts overrides root.tts", async () => {
|
|
376
|
+
(generateTTS as any).mockReturnValue(join(tmpDir, "scene.wav"));
|
|
377
|
+
|
|
378
|
+
const root: DescriptiveRoot = {
|
|
379
|
+
tts: "root-tts {input}",
|
|
380
|
+
children: [{
|
|
381
|
+
type: "scene", name: "S", tts: "scene-tts {input} --out {output}",
|
|
382
|
+
layout: "parallel",
|
|
383
|
+
children: [{ type: "audio", script: "text", volume: 1 } as any],
|
|
384
|
+
}],
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
await resolveScripts(root, { outputDir: tmpDir });
|
|
388
|
+
expect(generateTTS).toHaveBeenCalledWith("text", expect.any(String), "scene-tts {input} --out {output}");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("caches TTS output and skips second generateTTS call", async () => {
|
|
392
|
+
let callCount = 0;
|
|
393
|
+
(generateTTS as any).mockImplementation(() => {
|
|
394
|
+
callCount++;
|
|
395
|
+
const p = join(tmpDir, `hook.wav`);
|
|
396
|
+
writeFileSync(p, "");
|
|
397
|
+
return p;
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const root: DescriptiveRoot = {
|
|
401
|
+
children: [{
|
|
402
|
+
type: "scene", name: "Hook", layout: "parallel",
|
|
403
|
+
children: [{ type: "audio", script: "Hello world", volume: 1 } as any],
|
|
404
|
+
}],
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
await resolveScripts(root, { outputDir: tmpDir });
|
|
408
|
+
expect(callCount).toBe(1);
|
|
409
|
+
|
|
410
|
+
// Second call — should hit cache
|
|
411
|
+
const result = await resolveScripts(root, { outputDir: tmpDir });
|
|
412
|
+
expect(callCount).toBe(1);
|
|
413
|
+
const scene = result.children[0] as any;
|
|
414
|
+
expect(scene.children).toHaveLength(1);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("processes multiple audio nodes with script in the same tree", async () => {
|
|
418
|
+
(generateTTS as any)
|
|
419
|
+
.mockReturnValueOnce(join(tmpDir, "a.wav"))
|
|
420
|
+
.mockReturnValueOnce(join(tmpDir, "b.wav"));
|
|
421
|
+
|
|
422
|
+
const root: DescriptiveRoot = {
|
|
423
|
+
children: [
|
|
424
|
+
{ type: "scene", name: "A", layout: "parallel", children: [{ type: "audio", script: "First", volume: 1 } as any] },
|
|
425
|
+
{ type: "scene", name: "B", layout: "parallel", children: [{ type: "audio", script: "Second", volume: 1 } as any] },
|
|
426
|
+
],
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const result = await resolveScripts(root, { outputDir: tmpDir });
|
|
430
|
+
expect((result.children[0] as any).children[0].src).toBe(join(tmpDir, "a.wav"));
|
|
431
|
+
expect((result.children[1] as any).children[0].src).toBe(join(tmpDir, "b.wav"));
|
|
432
|
+
expect(generateTTS).toHaveBeenCalledTimes(2);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("skips parent scene when child scene also has script audio (innermost wins)", async () => {
|
|
436
|
+
(generateTTS as any).mockReturnValue(join(tmpDir, "child.wav"));
|
|
437
|
+
|
|
438
|
+
const root: DescriptiveRoot = {
|
|
439
|
+
children: [{
|
|
440
|
+
type: "scene", name: "Parent", layout: "parallel",
|
|
441
|
+
children: [
|
|
442
|
+
{ type: "audio", script: "Parent text", volume: 1 } as any,
|
|
443
|
+
{
|
|
444
|
+
type: "scene", name: "Child", layout: "parallel",
|
|
445
|
+
children: [{ type: "audio", script: "Child text", volume: 1 } as any],
|
|
446
|
+
},
|
|
447
|
+
],
|
|
448
|
+
}],
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const result = await resolveScripts(root, { outputDir: tmpDir });
|
|
452
|
+
// Both parent and child audio nodes should be processed independently
|
|
453
|
+
const parent = result.children[0] as any;
|
|
454
|
+
const child = parent.children[1] as any;
|
|
455
|
+
expect(parent.children[0].src).toBe(join(tmpDir, "child.wav"));
|
|
456
|
+
expect(child.children[0].src).toBe(join(tmpDir, "child.wav"));
|
|
457
|
+
expect(generateTTS).toHaveBeenCalledTimes(2);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("skips node when generateTTS returns empty string", async () => {
|
|
461
|
+
(generateTTS as any).mockReturnValue("");
|
|
462
|
+
|
|
463
|
+
const root: DescriptiveRoot = {
|
|
464
|
+
children: [{
|
|
465
|
+
type: "scene", name: "S", layout: "parallel",
|
|
466
|
+
children: [{ type: "audio", script: "fail", volume: 1 } as any],
|
|
467
|
+
}],
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const result = await resolveScripts(root, { outputDir: tmpDir });
|
|
471
|
+
// No src attached, script remains
|
|
472
|
+
expect((result.children[0] as any).children[0].script).toBe("fail");
|
|
473
|
+
expect((result.children[0] as any).children[0].src).toBeUndefined();
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// ── Additional STT tests ───────────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
describe("resolveSubtitles — additional", () => {
|
|
480
|
+
it("respects root.stt override", async () => {
|
|
481
|
+
const audioPath = join(tmpDir, "n.wav");
|
|
482
|
+
writeFileSync(audioPath, "fake");
|
|
483
|
+
(generateSTT as any).mockResolvedValue(undefined);
|
|
484
|
+
// generateSTT writes the VTT file — simulate that
|
|
485
|
+
const origImpl = (generateSTT as any).mockImplementation(async () => {
|
|
486
|
+
writeFileSync(join(tmpDir, "n.vtt"), "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nhi\n");
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
const root: DescriptiveRoot = {
|
|
490
|
+
stt: "custom-stt {input} --out {output}",
|
|
491
|
+
children: [{
|
|
492
|
+
type: "scene", name: "S", layout: "parallel",
|
|
493
|
+
children: [{ id: "a1", type: "audio", src: audioPath, actions: [{ id: "act1", start: 0, end: 2 }] } as any],
|
|
494
|
+
durationInSeconds: 2,
|
|
495
|
+
} as any],
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
await resolveSubtitles(root, { outputDir: tmpDir });
|
|
499
|
+
expect(generateSTT).toHaveBeenCalledWith(audioPath, tmpDir, "custom-stt {input} --out {output}");
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("caches STT output and skips second generateSTT call", async () => {
|
|
503
|
+
const audioPath = join(tmpDir, "cached.wav");
|
|
504
|
+
writeFileSync(audioPath, "fake");
|
|
505
|
+
let callCount = 0;
|
|
506
|
+
(generateSTT as any).mockImplementation(async () => {
|
|
507
|
+
callCount++;
|
|
508
|
+
writeFileSync(join(tmpDir, "cached.vtt"), "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nok\n");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const root: DescriptiveRoot = {
|
|
512
|
+
children: [{
|
|
513
|
+
type: "scene", name: "S", layout: "parallel",
|
|
514
|
+
children: [{ id: "a1", type: "audio", src: audioPath, actions: [{ id: "act1", start: 0, end: 2 }] } as any],
|
|
515
|
+
durationInSeconds: 2,
|
|
516
|
+
} as any],
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
await resolveSubtitles(root, { outputDir: tmpDir });
|
|
520
|
+
expect(callCount).toBe(1);
|
|
521
|
+
|
|
522
|
+
await resolveSubtitles(root, { outputDir: tmpDir });
|
|
523
|
+
expect(callCount).toBe(1); // cached
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("skips gracefully when generateSTT throws", async () => {
|
|
527
|
+
const audioPath = join(tmpDir, "fail.wav");
|
|
528
|
+
writeFileSync(audioPath, "fake");
|
|
529
|
+
(generateSTT as any).mockRejectedValue(new Error("whisper not found"));
|
|
530
|
+
|
|
531
|
+
const root: DescriptiveRoot = {
|
|
532
|
+
children: [{
|
|
533
|
+
type: "scene", name: "S", layout: "parallel",
|
|
534
|
+
children: [{ id: "a1", type: "audio", src: audioPath, actions: [{ id: "act1", start: 0, end: 2 }] } as any],
|
|
535
|
+
durationInSeconds: 2,
|
|
536
|
+
} as any],
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
const result = await resolveSubtitles(root, { outputDir: tmpDir });
|
|
540
|
+
expect(result.subtitle).toBeUndefined();
|
|
541
|
+
expect(generateSTT).toHaveBeenCalledTimes(1);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("merges VTT from multiple audio clips with correct offsets", async () => {
|
|
545
|
+
const a1 = join(tmpDir, "clip1.wav");
|
|
546
|
+
const a2 = join(tmpDir, "clip2.wav");
|
|
547
|
+
writeFileSync(a1, "fake");
|
|
548
|
+
writeFileSync(a2, "fake");
|
|
549
|
+
(generateSTT as any)
|
|
550
|
+
.mockImplementationOnce(async () => {
|
|
551
|
+
writeFileSync(join(tmpDir, "clip1.vtt"), "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nfirst\n");
|
|
552
|
+
})
|
|
553
|
+
.mockImplementationOnce(async () => {
|
|
554
|
+
writeFileSync(join(tmpDir, "clip2.vtt"), "WEBVTT\n\n00:00:00.500 --> 00:00:01.500\nsecond\n");
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const root: any = {
|
|
558
|
+
children: [{
|
|
559
|
+
type: "scene", name: "S", layout: "parallel",
|
|
560
|
+
children: [
|
|
561
|
+
{ id: "a1", type: "audio", src: a1, actions: [{ id: "act1", start: 0, end: 2 }] },
|
|
562
|
+
{ id: "a2", type: "audio", src: a2, actions: [{ id: "act2", start: 3, end: 5 }] },
|
|
563
|
+
],
|
|
564
|
+
durationInSeconds: 5,
|
|
565
|
+
}],
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const result = await resolveSubtitles(root, { outputDir: tmpDir });
|
|
569
|
+
expect(result.subtitle).toBeDefined();
|
|
570
|
+
const vtt = (await import("node:fs")).readFileSync(result.subtitle!.src, "utf-8");
|
|
571
|
+
// clip1: 1+0=1 → 2+0=2
|
|
572
|
+
expect(vtt).toContain("00:00:01.000 --> 00:00:02.000");
|
|
573
|
+
expect(vtt).toContain("first");
|
|
574
|
+
// clip2: 0.5+3=3.5 → 1.5+3=4.5
|
|
575
|
+
expect(vtt).toContain("00:00:03.500 --> 00:00:04.500");
|
|
576
|
+
expect(vtt).toContain("second");
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("handles empty VTT from STT CLI (no cues)", async () => {
|
|
580
|
+
const audioPath = join(tmpDir, "empty.wav");
|
|
581
|
+
writeFileSync(audioPath, "fake");
|
|
582
|
+
(generateSTT as any).mockImplementation(async () => {
|
|
583
|
+
writeFileSync(join(tmpDir, "empty.vtt"), "WEBVTT\n\n");
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
const root: any = {
|
|
587
|
+
children: [{
|
|
588
|
+
type: "scene", name: "S", layout: "parallel",
|
|
589
|
+
children: [{ id: "a1", type: "audio", src: audioPath, actions: [{ id: "act1", start: 0, end: 2 }] }],
|
|
590
|
+
durationInSeconds: 2,
|
|
591
|
+
}],
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const result = await resolveSubtitles(root, { outputDir: tmpDir });
|
|
595
|
+
expect(result.subtitle).toBeUndefined();
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// ── Additional TTV tests ───────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
describe("resolveGeneratedMedia — TTV additional", () => {
|
|
602
|
+
it("uses default TTV CLI when no options or root.ttv provided", async () => {
|
|
603
|
+
const out = join(tmpDir, "video-0.mp4");
|
|
604
|
+
(generateTTV as any).mockReturnValue(out);
|
|
605
|
+
|
|
606
|
+
const root: DescriptiveRoot = {
|
|
607
|
+
children: [{
|
|
608
|
+
type: "scene", name: "S", layout: "parallel",
|
|
609
|
+
children: [{ type: "video", prompt: "waves", duration: 5 }],
|
|
610
|
+
}],
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
await resolveGeneratedMedia(root, { outputDir: tmpDir });
|
|
614
|
+
expect(generateTTV).toHaveBeenCalledTimes(1);
|
|
615
|
+
// Default TTV: empty CLI → generateTTV calls generateTTI + ffmpeg internally
|
|
616
|
+
const [prompt, , cli] = (generateTTV as any).mock.calls[0];
|
|
617
|
+
expect(prompt).toBe("waves");
|
|
618
|
+
expect(cli).toBe("");
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("root.ttv overrides options.ttvCli", async () => {
|
|
622
|
+
(generateTTV as any).mockImplementation((_p, out) => out);
|
|
623
|
+
|
|
624
|
+
const root: DescriptiveRoot = {
|
|
625
|
+
ttv: "root-ttv {input} --out {output}",
|
|
626
|
+
children: [{
|
|
627
|
+
type: "scene", name: "S", layout: "parallel",
|
|
628
|
+
children: [{ type: "video", prompt: "test", duration: 5 }],
|
|
629
|
+
}],
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
await resolveGeneratedMedia(root, { outputDir: tmpDir, ttvCli: "option-ttv {input}" });
|
|
633
|
+
expect(generateTTV).toHaveBeenCalledWith(
|
|
634
|
+
"test",
|
|
635
|
+
expect.stringMatching(/\.mp4$/),
|
|
636
|
+
"root-ttv {input} --out {output}",
|
|
637
|
+
expect.any(String),
|
|
638
|
+
);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("options.ttvCli overrides default when no root.ttv", async () => {
|
|
642
|
+
(generateTTV as any).mockImplementation((_p, out) => out);
|
|
643
|
+
|
|
644
|
+
const root: DescriptiveRoot = {
|
|
645
|
+
children: [{
|
|
646
|
+
type: "scene", name: "S", layout: "parallel",
|
|
647
|
+
children: [{ type: "video", prompt: "test", duration: 5 }],
|
|
648
|
+
}],
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
await resolveGeneratedMedia(root, { outputDir: tmpDir, ttvCli: "custom-ttv {input} --out {output}" });
|
|
652
|
+
expect(generateTTV).toHaveBeenCalledWith(
|
|
653
|
+
"test",
|
|
654
|
+
expect.stringMatching(/\.mp4$/),
|
|
655
|
+
"custom-ttv {input} --out {output}",
|
|
656
|
+
expect.any(String),
|
|
657
|
+
);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it.skip("caches TTV output and skips second generateTTV call", async () => {
|
|
661
|
+
const cacheDir = join(tmpDir, ".markcut", "generated", "media");
|
|
662
|
+
let callCount = 0;
|
|
663
|
+
(generateTTV as any).mockImplementation((_p: string, outputPath: string) => {
|
|
664
|
+
callCount++;
|
|
665
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
666
|
+
writeFileSync(outputPath, "fake");
|
|
667
|
+
return outputPath;
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
const root: DescriptiveRoot = {
|
|
671
|
+
children: [{
|
|
672
|
+
type: "scene", name: "S", layout: "parallel",
|
|
673
|
+
children: [{ type: "video", prompt: "cached", duration: 5 }],
|
|
674
|
+
}],
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
await resolveGeneratedMedia(root, { outputDir: cacheDir, ttvCli: "gen {input}" });
|
|
678
|
+
const result = await resolveGeneratedMedia(root, { outputDir: cacheDir, ttvCli: "gen {input}" });
|
|
679
|
+
expect((result.children[0] as any).children[0].src).toBeTruthy();
|
|
680
|
+
expect((result.children[0] as any).children[0].src).toMatch(/\.mp4$/);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it("skips node when generateTTV produces no output", async () => {
|
|
684
|
+
(generateTTV as any).mockReturnValue("");
|
|
685
|
+
|
|
686
|
+
const root: DescriptiveRoot = {
|
|
687
|
+
children: [{
|
|
688
|
+
type: "scene", name: "S", layout: "parallel",
|
|
689
|
+
children: [{ type: "video", prompt: "fail", duration: 5 }],
|
|
690
|
+
}],
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
const result = await resolveGeneratedMedia(root, { outputDir: tmpDir, ttvCli: "gen {input}" });
|
|
694
|
+
expect((result.children[0] as any).children[0].src).toBeUndefined();
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("continues when generateTTV throws", async () => {
|
|
698
|
+
(generateTTV as any).mockImplementation(() => { throw new Error("CLI not found"); });
|
|
699
|
+
|
|
700
|
+
const root: DescriptiveRoot = {
|
|
701
|
+
children: [{
|
|
702
|
+
type: "scene", name: "S", layout: "parallel",
|
|
703
|
+
children: [{ type: "video", prompt: "fail", duration: 5 }],
|
|
704
|
+
}],
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
const result = await resolveGeneratedMedia(root, { outputDir: tmpDir, ttvCli: "gen {input}" });
|
|
708
|
+
expect((result.children[0] as any).children[0].src).toBeUndefined();
|
|
709
|
+
expect(generateTTV).toHaveBeenCalledTimes(1);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("processes multiple video nodes with prompts", async () => {
|
|
713
|
+
(generateTTV as any).mockImplementation((_p, out) => out);
|
|
714
|
+
|
|
715
|
+
const root: DescriptiveRoot = {
|
|
716
|
+
children: [{
|
|
717
|
+
type: "scene", name: "S", layout: "parallel",
|
|
718
|
+
children: [
|
|
719
|
+
{ type: "video", prompt: "first", duration: 5 },
|
|
720
|
+
{ type: "video", prompt: "second", duration: 5 },
|
|
721
|
+
],
|
|
722
|
+
}],
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const result = await resolveGeneratedMedia(root, { outputDir: tmpDir, ttvCli: "gen {input}" });
|
|
726
|
+
const vids = (result.children[0] as any).children;
|
|
727
|
+
expect(vids[0].src).toBeTruthy();
|
|
728
|
+
expect(vids[0].src).toMatch(/\.mp4$/);
|
|
729
|
+
expect(vids[1].src).toBeTruthy();
|
|
730
|
+
expect(vids[1].src).toMatch(/\.mp4$/);
|
|
731
|
+
expect(generateTTV).toHaveBeenCalledTimes(2);
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// ── resolveMediaDurations ──────────────────────────────────────────────────
|
|
736
|
+
|
|
737
|
+
describe("resolveMediaDurations", () => {
|
|
738
|
+
it("returns clone without mutating original", async () => {
|
|
739
|
+
const root: DescriptiveRoot = {
|
|
740
|
+
layout: "series",
|
|
741
|
+
children: [
|
|
742
|
+
{ type: "video", src: "nonexistent.mp4", duration: 5 },
|
|
743
|
+
],
|
|
744
|
+
};
|
|
745
|
+
const result = await resolveMediaDurations(root);
|
|
746
|
+
expect(result).not.toBe(root);
|
|
747
|
+
expect(result.children[0]!.duration).toBe(5);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("skips nodes that already have duration", async () => {
|
|
751
|
+
const root: DescriptiveRoot = {
|
|
752
|
+
layout: "series",
|
|
753
|
+
children: [
|
|
754
|
+
{ type: "video", src: "anything.mp4", duration: 7 },
|
|
755
|
+
],
|
|
756
|
+
};
|
|
757
|
+
const result = await resolveMediaDurations(root);
|
|
758
|
+
expect(result.children[0]!.duration).toBe(7);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("skips nodes that already have endAt", async () => {
|
|
762
|
+
const root: DescriptiveRoot = {
|
|
763
|
+
layout: "series",
|
|
764
|
+
children: [
|
|
765
|
+
{ type: "video", src: "anything.mp4", startFrom: 2, endAt: 5 },
|
|
766
|
+
],
|
|
767
|
+
};
|
|
768
|
+
const result = await resolveMediaDurations(root);
|
|
769
|
+
expect((result.children[0] as any).endAt).toBe(5);
|
|
770
|
+
expect((result.children[0] as any).duration).toBeUndefined();
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it("handles skip regex", async () => {
|
|
774
|
+
const root: DescriptiveRoot = {
|
|
775
|
+
layout: "series",
|
|
776
|
+
children: [
|
|
777
|
+
{ type: "video", src: "nonexistent.mp4" },
|
|
778
|
+
{ type: "image", src: "photo.jpg" },
|
|
779
|
+
],
|
|
780
|
+
};
|
|
781
|
+
const result = await resolveMediaDurations(root, { skip: /\.jpg$/ });
|
|
782
|
+
// .jpg skipped, .mp4 tried but file doesn't exist so duration stays undefined
|
|
783
|
+
expect(result.children[0]!.duration).toBeUndefined();
|
|
784
|
+
expect(result.children[1]!.duration).toBeUndefined();
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it("does not mutate original tree", async () => {
|
|
788
|
+
const root: DescriptiveRoot = {
|
|
789
|
+
layout: "series",
|
|
790
|
+
children: [
|
|
791
|
+
{ type: "video", src: "nonexistent.mp4", duration: 5 },
|
|
792
|
+
],
|
|
793
|
+
};
|
|
794
|
+
const originalChildren = root.children[0];
|
|
795
|
+
const result = await resolveMediaDurations(root);
|
|
796
|
+
expect(result).not.toBe(root);
|
|
797
|
+
expect(root.children[0]).toBe(originalChildren);
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// ── resolveIncludes with variants ──────────────────────────────────────────
|
|
802
|
+
|
|
803
|
+
describe("resolveIncludes — variant overrides", () => {
|
|
804
|
+
/** Helper: create a root with a single include node and resolve it */
|
|
805
|
+
async function resolveIncludeFixture(
|
|
806
|
+
subMd: string,
|
|
807
|
+
options: { variants?: string[] } = {},
|
|
808
|
+
) {
|
|
809
|
+
const root: DescriptiveRoot = {
|
|
810
|
+
width: 640, height: 480, fps: 30, layout: "series",
|
|
811
|
+
children: [{
|
|
812
|
+
type: "include",
|
|
813
|
+
src: subMd,
|
|
814
|
+
} as any],
|
|
815
|
+
};
|
|
816
|
+
return resolveIncludes(root, {
|
|
817
|
+
baseDir: tmpDir,
|
|
818
|
+
includeOutputDir: join(tmpDir, ".markcut", "generated", "includes"),
|
|
819
|
+
...options,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
it("resolves include and stamps duration from child content", async () => {
|
|
824
|
+
const subMd = join(tmpDir, "sub-plain.md");
|
|
825
|
+
writeFileSync(subMd, [
|
|
826
|
+
"# video",
|
|
827
|
+
"width:640 height:480 fps:30 layout:series",
|
|
828
|
+
"## Scene1",
|
|
829
|
+
"layout:parallel",
|
|
830
|
+
'- image src:default.jpg duration:3',
|
|
831
|
+
].join("\n"));
|
|
832
|
+
|
|
833
|
+
const result = await resolveIncludeFixture(subMd);
|
|
834
|
+
const incNode = result.children[0] as any;
|
|
835
|
+
expect(incNode.src).toMatch(/\.json$/);
|
|
836
|
+
// Duration comes from the compiled stream tree (image has duration:3)
|
|
837
|
+
expect(incNode.durationInSeconds).toBe(3);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
it("resolves include with variants option using base content (no overrides)", async () => {
|
|
841
|
+
// Sub-file has variant sections but no variants applied — should use base
|
|
842
|
+
const subMd = join(tmpDir, "sub-with-variants.md");
|
|
843
|
+
writeFileSync(subMd, [
|
|
844
|
+
"# video",
|
|
845
|
+
"width:640 height:480 fps:30 layout:series",
|
|
846
|
+
"## Scene1",
|
|
847
|
+
"layout:parallel",
|
|
848
|
+
'- image src:default.jpg duration:3',
|
|
849
|
+
'- component zh-jsx:"<h1>ZH</h1>" jsx:"<h1>EN</h1>" duration:3',
|
|
850
|
+
"",
|
|
851
|
+
"# zh",
|
|
852
|
+
"width:640 height:480 fps:30 layout:series",
|
|
853
|
+
"## Scene1",
|
|
854
|
+
"layout:parallel",
|
|
855
|
+
'- image src:zh.jpg duration:3',
|
|
856
|
+
'- component zh-jsx:"<h1>ZH</h1>" jsx:"<h1>EN</h1>" duration:3',
|
|
857
|
+
].join("\n"));
|
|
858
|
+
|
|
859
|
+
const result = await resolveIncludeFixture(subMd);
|
|
860
|
+
const incNode = result.children[0] as any;
|
|
861
|
+
expect(incNode.src).toMatch(/\.json$/);
|
|
862
|
+
expect(incNode.durationInSeconds).toBe(3);
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
it("applies variant-prefixed overrides (zh-src → src) to included sub-video", async () => {
|
|
866
|
+
const subMd = join(tmpDir, "sub-overrides.md");
|
|
867
|
+
writeFileSync(subMd, [
|
|
868
|
+
"# video",
|
|
869
|
+
"width:640 height:480 fps:30 layout:series",
|
|
870
|
+
"## Scene1",
|
|
871
|
+
"layout:parallel",
|
|
872
|
+
'- image zh-src:zh-image.jpg src:default.jpg duration:3',
|
|
873
|
+
'- component zh-jsx:"<h1>ZH Variant</h1>" jsx:"<h1>Base</h1>" duration:3',
|
|
874
|
+
"",
|
|
875
|
+
"# zh",
|
|
876
|
+
"width:640 height:480 fps:30 layout:series",
|
|
877
|
+
"## Scene1",
|
|
878
|
+
"layout:parallel",
|
|
879
|
+
'- image zh-src:zh-image.jpg src:default.jpg duration:3',
|
|
880
|
+
'- component zh-jsx:"<h1>ZH Variant</h1>" jsx:"<h1>Base</h1>" duration:3',
|
|
881
|
+
].join("\n"));
|
|
882
|
+
|
|
883
|
+
const result = await resolveIncludeFixture(subMd, { variants: ["zh"] });
|
|
884
|
+
const incNode = result.children[0] as any;
|
|
885
|
+
expect(incNode.src).toMatch(/\.json$/);
|
|
886
|
+
expect(incNode.durationInSeconds).toBe(3);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
it("applies bare variant key (zh → primary content) to included sub-video", async () => {
|
|
890
|
+
const subMd = join(tmpDir, "sub-bare.md");
|
|
891
|
+
writeFileSync(subMd, [
|
|
892
|
+
"# video",
|
|
893
|
+
"width:640 height:480 fps:30 layout:series",
|
|
894
|
+
"## Scene1",
|
|
895
|
+
"layout:parallel",
|
|
896
|
+
'- component duration:3 jsx:"<h1>Base</h1>"',
|
|
897
|
+
'- image src:default.jpg duration:3',
|
|
898
|
+
"",
|
|
899
|
+
"# zh",
|
|
900
|
+
"width:640 height:480 fps:30 layout:series",
|
|
901
|
+
"## Scene1",
|
|
902
|
+
"layout:parallel",
|
|
903
|
+
'- component duration:3 zh:"<h1>中文</h1>"',
|
|
904
|
+
'- image src:default.jpg duration:3',
|
|
905
|
+
].join("\n"));
|
|
906
|
+
|
|
907
|
+
const result = await resolveIncludeFixture(subMd, { variants: ["zh"] });
|
|
908
|
+
const incNode = result.children[0] as any;
|
|
909
|
+
expect(incNode.src).toMatch(/\.json$/);
|
|
910
|
+
expect(incNode.durationInSeconds).toBe(3);
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
it("nested includes receive variant overrides recursively", async () => {
|
|
914
|
+
// Create innermost sub-file with variant sections
|
|
915
|
+
const innerMd = join(tmpDir, "inner.md");
|
|
916
|
+
writeFileSync(innerMd, [
|
|
917
|
+
"# video",
|
|
918
|
+
"width:640 height:480 fps:30 layout:series",
|
|
919
|
+
"## InnerScene",
|
|
920
|
+
"layout:parallel",
|
|
921
|
+
'- image zh-src:inner_zh.jpg src:inner.jpg duration:2',
|
|
922
|
+
"",
|
|
923
|
+
"# zh",
|
|
924
|
+
"width:640 height:480 fps:30 layout:series",
|
|
925
|
+
"## InnerScene",
|
|
926
|
+
"layout:parallel",
|
|
927
|
+
'- image zh-src:inner_zh.jpg src:inner.jpg duration:2',
|
|
928
|
+
].join("\n"));
|
|
929
|
+
|
|
930
|
+
// Middle file that includes inner
|
|
931
|
+
const midMd = join(tmpDir, "mid.md");
|
|
932
|
+
writeFileSync(midMd, [
|
|
933
|
+
"# video",
|
|
934
|
+
"width:640 height:480 fps:30 layout:series",
|
|
935
|
+
"## MidScene",
|
|
936
|
+
"layout:parallel",
|
|
937
|
+
'- include src:./inner.md',
|
|
938
|
+
"",
|
|
939
|
+
"# zh",
|
|
940
|
+
"width:640 height:480 fps:30 layout:series",
|
|
941
|
+
"## MidScene",
|
|
942
|
+
"layout:parallel",
|
|
943
|
+
'- include src:./inner.md',
|
|
944
|
+
].join("\n"));
|
|
945
|
+
|
|
946
|
+
const result = await resolveIncludeFixture(midMd, { variants: ["zh"] });
|
|
947
|
+
const incNode = result.children[0] as any;
|
|
948
|
+
expect(incNode.src).toMatch(/\.json$/);
|
|
949
|
+
expect(incNode.durationInSeconds).toBe(2);
|
|
950
|
+
});
|
|
951
|
+
});
|