@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,891 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async pre-pass resolvers for the descriptive compiler.
|
|
3
|
+
*
|
|
4
|
+
* - resolveMediaDurations: probe actual video/audio duration via ffprobe
|
|
5
|
+
* - resolveScripts: TTS each `script` to audio, STT to VTT, attach as children
|
|
6
|
+
* - resolveIncludes: recursively resolve included descriptive markdown files
|
|
7
|
+
*
|
|
8
|
+
* These run BEFORE compileDescriptiveRoot() so the synchronous compiler
|
|
9
|
+
* has complete duration and renderable children.
|
|
10
|
+
*/
|
|
11
|
+
import { execSync } from "node:child_process";
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { join, dirname, resolve as resolvePath, relative } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
generateTTS,
|
|
17
|
+
generateSTT,
|
|
18
|
+
generateTTI,
|
|
19
|
+
generateTTV,
|
|
20
|
+
DEFAULT_TTS_CLI,
|
|
21
|
+
DEFAULT_STT_CLI,
|
|
22
|
+
DEFAULT_TTI_CLI,
|
|
23
|
+
DEFAULT_TTV_CLI,
|
|
24
|
+
} from "../render/cli-tools";
|
|
25
|
+
import { walkDown } from "../utils";
|
|
26
|
+
import type { DescriptiveNode, DescriptiveRoot } from "./compiler";
|
|
27
|
+
import { compileDescriptiveRoot, parseImportsBlock, extractDependencySpecs, resolveTemplateVars, resolveVariantOverrides } from "./compiler";
|
|
28
|
+
import { parseMarkdownDescriptive, parseMarkdownVariants } from "./markdown";
|
|
29
|
+
|
|
30
|
+
// ── Content-hash cache for expensive operations (TTS, STT) ────────────────
|
|
31
|
+
// Skips regeneration when input hasn't changed. Cache key is a hash of all
|
|
32
|
+
// inputs that affect the output (script + cli template).
|
|
33
|
+
|
|
34
|
+
interface CacheEntry {
|
|
35
|
+
hash: string;
|
|
36
|
+
output: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function computeCacheKey(parts: Record<string, unknown>): string {
|
|
40
|
+
const json = JSON.stringify(parts);
|
|
41
|
+
return createHash("sha1").update(json).digest("hex").slice(0, 12);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readCacheManifest(outputDir: string): Record<string, CacheEntry> {
|
|
45
|
+
const manifestPath = join(outputDir, ".cache.json");
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
48
|
+
} catch {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeCacheManifest(outputDir: string, manifest: Record<string, CacheEntry>): void {
|
|
54
|
+
const manifestPath = join(outputDir, ".cache.json");
|
|
55
|
+
try {
|
|
56
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
|
|
57
|
+
} catch {
|
|
58
|
+
// best-effort
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Returns cached output path if inputs unchanged AND output file still exists.
|
|
64
|
+
* Otherwise returns null (caller should regenerate).
|
|
65
|
+
*/
|
|
66
|
+
function checkCache(
|
|
67
|
+
manifest: Record<string, CacheEntry>,
|
|
68
|
+
key: string,
|
|
69
|
+
cacheKey: string,
|
|
70
|
+
): string | null {
|
|
71
|
+
const entry = manifest[key];
|
|
72
|
+
if (entry?.hash === cacheKey && entry.output && existsSync(entry.output)) {
|
|
73
|
+
return entry.output;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function updateCache(
|
|
79
|
+
manifest: Record<string, CacheEntry>,
|
|
80
|
+
key: string,
|
|
81
|
+
cacheKey: string,
|
|
82
|
+
output: string,
|
|
83
|
+
): void {
|
|
84
|
+
manifest[key] = { hash: cacheKey, output };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Media Duration ─────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/** First N words of a string, safe for log labels. */
|
|
90
|
+
function firstWords(text: string, n: number): string {
|
|
91
|
+
if (!text) return "";
|
|
92
|
+
return text.split(/\s+/).slice(0, n).join(" ").replace(/[^a-zA-Z0-9\u4e00-\u9fff\s-]/g, "").slice(0, 60) || text.slice(0, 60);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ResolveMediaOptions {
|
|
96
|
+
/** Base directory for resolving relative src paths (default: cwd) */
|
|
97
|
+
baseDir?: string;
|
|
98
|
+
/** Skip nodes whose src matches this regex */
|
|
99
|
+
skip?: RegExp;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Probe actual media duration via ffprobe.
|
|
104
|
+
* Returns duration in seconds, or null if probe fails.
|
|
105
|
+
*/
|
|
106
|
+
function probeDuration(src: string, baseDir?: string): number | null {
|
|
107
|
+
const absPath = resolveSrc(src, baseDir);
|
|
108
|
+
try {
|
|
109
|
+
const out = execSync(
|
|
110
|
+
`ffprobe -v error -show_entries format=duration -of csv=p=0 "${absPath}"`,
|
|
111
|
+
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 10_000 },
|
|
112
|
+
).trim();
|
|
113
|
+
const d = parseFloat(out);
|
|
114
|
+
return Number.isFinite(d) && d > 0 ? d : null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveSrc(src: string, baseDir?: string): string {
|
|
121
|
+
if (/^(https?:|file:|\/)/.test(src)) return src;
|
|
122
|
+
return resolvePath(baseDir ?? process.cwd(), src);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Common display resolutions for best-match fallback, ordered by popularity.
|
|
127
|
+
* Used when a pattern-based src (e.g. photo_${width}x${height}.jpg) doesn't
|
|
128
|
+
* have an exact file match — the resolver tries nearby resolutions and picks
|
|
129
|
+
* the one with the closest aspect ratio.
|
|
130
|
+
*/
|
|
131
|
+
const COMMON_RESOLUTIONS: Array<{ width: number; height: number }> = [
|
|
132
|
+
{ width: 1920, height: 1080 }, // 16:9
|
|
133
|
+
{ width: 1080, height: 1920 }, // 9:16
|
|
134
|
+
{ width: 1080, height: 1080 }, // 1:1
|
|
135
|
+
{ width: 3840, height: 2160 }, // 4K 16:9
|
|
136
|
+
{ width: 2560, height: 1440 }, // 2K 16:9
|
|
137
|
+
{ width: 1280, height: 720 }, // HD 16:9
|
|
138
|
+
{ width: 640, height: 480 }, // 4:3
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolve a potentially pattern-based media src to the best matching file.
|
|
143
|
+
*
|
|
144
|
+
* If `src` doesn't contain pattern placeholders, returns `resolveSrc(src, baseDir)`.
|
|
145
|
+
*
|
|
146
|
+
* If `src` contains placeholders (resolved via `width`/`height`), it:
|
|
147
|
+
* 1. Generates the exact-dimension path
|
|
148
|
+
* 2. If the exact file doesn't exist, tries COMMON_RESOLUTIONS sorted by
|
|
149
|
+
* closest aspect-ratio match
|
|
150
|
+
* 3. Falls back to the exact-dimension path if nothing matches
|
|
151
|
+
*
|
|
152
|
+
* This allows authors to write:
|
|
153
|
+
* image src:photo_${width}x${height}.jpg
|
|
154
|
+
* And have the resolver pick photo_1920x1080.jpg for landscape or
|
|
155
|
+
* photo_1080x1920.jpg for portrait, with fallback to nearby resolutions.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveMediaSrc(
|
|
158
|
+
src: string,
|
|
159
|
+
targetWidth: number,
|
|
160
|
+
targetHeight: number,
|
|
161
|
+
baseDir?: string,
|
|
162
|
+
): string {
|
|
163
|
+
// If no pattern, direct resolve
|
|
164
|
+
if (!src.includes("${")) {
|
|
165
|
+
return resolveSrc(src, baseDir);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Build template context from targets
|
|
169
|
+
const ctx = { width: targetWidth, height: targetHeight, fps: 30, variant: "video" };
|
|
170
|
+
|
|
171
|
+
// Exact match: resolve placeholders to exact target dimensions
|
|
172
|
+
const exactPath = resolveTemplateVars(src, ctx);
|
|
173
|
+
const exactAbs = resolveSrc(exactPath, baseDir);
|
|
174
|
+
if (existsSync(exactAbs)) {
|
|
175
|
+
return exactAbs;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Best-match fallback: try common resolutions sorted by closest aspect ratio
|
|
179
|
+
const targetRatio = targetWidth / targetHeight;
|
|
180
|
+
const scored = COMMON_RESOLUTIONS.map((r) => {
|
|
181
|
+
const ratio = r.width / r.height;
|
|
182
|
+
const score = Math.abs(ratio - targetRatio);
|
|
183
|
+
return { ...r, score };
|
|
184
|
+
}).sort((a, b) => a.score - b.score);
|
|
185
|
+
|
|
186
|
+
for (const res of scored) {
|
|
187
|
+
const candidate = resolveTemplateVars(src, { width: res.width, height: res.height, fps: 30, variant: "video" });
|
|
188
|
+
const candidateAbs = resolveSrc(candidate, baseDir);
|
|
189
|
+
if (existsSync(candidateAbs)) {
|
|
190
|
+
console.log(` 📐 Media src matched: ${candidate} (${res.width}x${res.height}) for target ${targetWidth}x${targetHeight}`);
|
|
191
|
+
return candidateAbs;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// No fallback matched — return the exact path (will 404 gracefully)
|
|
196
|
+
console.warn(` ⚠ No matching media file for "${src}" at ${targetWidth}x${targetHeight}`);
|
|
197
|
+
return exactAbs;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Walk the descriptive tree and resolve pattern-based src fields using
|
|
202
|
+
* best-match resolution. Should be called after resolveAllTemplateVars
|
|
203
|
+
* so that `${width}`/`${height}` are replaced with actual values first.
|
|
204
|
+
*/
|
|
205
|
+
export async function resolveMediaSrcs(
|
|
206
|
+
root: DescriptiveRoot,
|
|
207
|
+
options: ResolveMediaOptions = {},
|
|
208
|
+
): Promise<DescriptiveRoot> {
|
|
209
|
+
const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
|
|
210
|
+
const baseDir = options.baseDir;
|
|
211
|
+
const targetWidth = clone.width ?? 1080;
|
|
212
|
+
const targetHeight = clone.height ?? 1920;
|
|
213
|
+
|
|
214
|
+
walkDown(clone as any, (node) => {
|
|
215
|
+
const n = node as any;
|
|
216
|
+
if (n.src && typeof n.src === "string" && n.src.includes("${")) {
|
|
217
|
+
n.src = resolveMediaSrc(n.src, targetWidth, targetHeight, baseDir);
|
|
218
|
+
}
|
|
219
|
+
if (n.prompt && typeof n.prompt === "string" && n.prompt.includes("${")) {
|
|
220
|
+
n.prompt = resolveTemplateVars(n.prompt, {
|
|
221
|
+
width: targetWidth,
|
|
222
|
+
height: targetHeight,
|
|
223
|
+
fps: clone.fps ?? 30,
|
|
224
|
+
variant: "video",
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
return clone;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Walk the descriptive tree and fill `duration` on video/audio nodes
|
|
234
|
+
* that lack it, by probing actual media files.
|
|
235
|
+
*
|
|
236
|
+
* For video/audio without startFrom/endAt, also sets startFrom=0 and endAt=duration.
|
|
237
|
+
*/
|
|
238
|
+
export async function resolveMediaDurations(
|
|
239
|
+
root: DescriptiveRoot,
|
|
240
|
+
options: ResolveMediaOptions = {},
|
|
241
|
+
): Promise<DescriptiveRoot> {
|
|
242
|
+
const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
|
|
243
|
+
const baseDir = options.baseDir;
|
|
244
|
+
|
|
245
|
+
walkDown(clone as any, (node) => {
|
|
246
|
+
const n = node as DescriptiveNode;
|
|
247
|
+
if (n.type !== "video" && n.type !== "audio") return;
|
|
248
|
+
if (typeof n.duration === "number" && n.duration > 0) return;
|
|
249
|
+
if (typeof n.endAt === "number") return; // trim already specified
|
|
250
|
+
if (!n.src) return;
|
|
251
|
+
if (options.skip?.test(n.src)) return;
|
|
252
|
+
|
|
253
|
+
const probed = probeDuration(n.src, baseDir);
|
|
254
|
+
if (probed != null) {
|
|
255
|
+
n.duration = probed;
|
|
256
|
+
if (n.startFrom == null) (n as any).startFrom = 0;
|
|
257
|
+
if (n.endAt == null) (n as any).endAt = probed;
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return clone;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Script → TTS → STT ─────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
export interface ResolveScriptOptions {
|
|
267
|
+
/** Output directory for generated audio files */
|
|
268
|
+
outputDir: string;
|
|
269
|
+
/** CLI template override (default: edge-tts) */
|
|
270
|
+
ttsCli?: string;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Walk the descriptive tree and for each audio node with a `script` field:
|
|
275
|
+
* 1. Generate TTS audio from script text → set as `src`
|
|
276
|
+
* 2. Remove the `script` field from the node
|
|
277
|
+
*
|
|
278
|
+
* At this point in the pipeline, the parser has already converted all
|
|
279
|
+
* container-level `script` attributes into audio children. This function
|
|
280
|
+
* only needs to find those audio nodes and generate TTS for them.
|
|
281
|
+
*
|
|
282
|
+
* Subtitles are handled separately as a post-compile step that merges
|
|
283
|
+
* per-clip VTTs (with absolute timing) into root.subtitle.src.
|
|
284
|
+
*/
|
|
285
|
+
export async function resolveScripts(
|
|
286
|
+
root: DescriptiveRoot,
|
|
287
|
+
options: ResolveScriptOptions,
|
|
288
|
+
): Promise<DescriptiveRoot> {
|
|
289
|
+
const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
|
|
290
|
+
mkdirSync(options.outputDir, { recursive: true });
|
|
291
|
+
const cache = readCacheManifest(options.outputDir);
|
|
292
|
+
let cacheDirty = false;
|
|
293
|
+
|
|
294
|
+
// Collect all audio nodes that have script text but no src yet
|
|
295
|
+
// Also store their parent scene for per-scene tts resolution
|
|
296
|
+
const allScriptNodes: Array<{ node: any; id: string; parent: any }> = [];
|
|
297
|
+
walkDown(clone as any, (node, parent) => {
|
|
298
|
+
if (node.type !== "audio") return;
|
|
299
|
+
if (!node.script || typeof node.script !== "string") return;
|
|
300
|
+
if (node.src) return; // already has real source
|
|
301
|
+
const id = node.id ?? `audio-${allScriptNodes.length}`;
|
|
302
|
+
allScriptNodes.push({ node, id, parent });
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
if (allScriptNodes.length > 0) {
|
|
306
|
+
console.log(` 🔊 TTS: generating ${allScriptNodes.length} script${allScriptNodes.length > 1 ? "s" : ""}...`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
for (const { node, id, parent } of allScriptNodes) {
|
|
310
|
+
// Resolve TTS config: parent scene tts > root tts > options ttsCli > default
|
|
311
|
+
const ttsCli = parent?.tts ?? clone.tts ?? options.ttsCli ?? DEFAULT_TTS_CLI;
|
|
312
|
+
|
|
313
|
+
// Content-addressed filename: hash of script + CLI, so identical scripts
|
|
314
|
+
// across different source files share the same audio file.
|
|
315
|
+
const cacheKey = computeCacheKey({ script: node.script, cli: ttsCli });
|
|
316
|
+
const audioPath = join(options.outputDir, `${cacheKey}.mp3`);
|
|
317
|
+
|
|
318
|
+
// Check cache — skip TTS if script + config unchanged AND audio file exists
|
|
319
|
+
const cached = checkCache(cache, `tts:${cacheKey}`, cacheKey);
|
|
320
|
+
let generated: string;
|
|
321
|
+
const label = firstWords(node.script, 8);
|
|
322
|
+
if (cached) {
|
|
323
|
+
generated = cached;
|
|
324
|
+
console.log(` ✓ TTS: ${label} (cached)`);
|
|
325
|
+
} else {
|
|
326
|
+
generated = generateTTS(node.script, audioPath, ttsCli);
|
|
327
|
+
if (generated) {
|
|
328
|
+
updateCache(cache, `tts:${cacheKey}`, cacheKey, generated);
|
|
329
|
+
cacheDirty = true;
|
|
330
|
+
console.log(` ✓ TTS: ${label}`);
|
|
331
|
+
} else {
|
|
332
|
+
console.warn(` ⚠ TTS produced no audio for "${label}". Audio will have no source. Check root.tts config.`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (!generated) continue;
|
|
336
|
+
|
|
337
|
+
// Set resolved src (normalize to absolute for reliable probing later)
|
|
338
|
+
node.src = resolvePath(generated);
|
|
339
|
+
delete node.script;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (cacheDirty) writeCacheManifest(options.outputDir, cache);
|
|
343
|
+
if (allScriptNodes.length > 0) {
|
|
344
|
+
try { writeFileSync(join(options.outputDir, ".cache.json"), JSON.stringify(cache, null, 2), "utf-8"); } catch {}
|
|
345
|
+
const unique = new Set(allScriptNodes.filter(s => s.node.src).map(s => s.node.src)).size;
|
|
346
|
+
console.log(` ✅ TTS: ${unique} unique audio file${unique > 1 ? "s" : ""} (${allScriptNodes.length} node${allScriptNodes.length > 1 ? "s" : ""})`);
|
|
347
|
+
}
|
|
348
|
+
return clone;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Post-compile: walk the compiled stream tree, find all audio nodes whose
|
|
353
|
+
* source was TTS-generated (relative path inside outputDir), run STT on each,
|
|
354
|
+
* and merge the resulting per-clip VTTs into a single root.subtitle VTT with
|
|
355
|
+
* absolute timestamps derived from the audio node's action start time.
|
|
356
|
+
*
|
|
357
|
+
* Sets root.subtitle.src on the returned root.
|
|
358
|
+
*/
|
|
359
|
+
export async function resolveSubtitles(
|
|
360
|
+
root: DescriptiveRoot,
|
|
361
|
+
options: {
|
|
362
|
+
outputDir: string;
|
|
363
|
+
sttCli?: string;
|
|
364
|
+
/** Compiled stream tree with action start times, used for correct offset calculation. */
|
|
365
|
+
compiled?: { children?: any[] };
|
|
366
|
+
/** Separate output directory for the merged subtitles.vtt.
|
|
367
|
+
* When set, per-clip VTTs stay in outputDir (shared cache) while
|
|
368
|
+
* the merged VTT goes here (e.g., per-variant). Defaults to outputDir. */
|
|
369
|
+
mergedOutputDir?: string;
|
|
370
|
+
},
|
|
371
|
+
): Promise<DescriptiveRoot> {
|
|
372
|
+
const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
|
|
373
|
+
const sttCli = clone.stt ?? options.sttCli ?? DEFAULT_STT_CLI;
|
|
374
|
+
if (!sttCli) return clone;
|
|
375
|
+
|
|
376
|
+
mkdirSync(options.outputDir, { recursive: true });
|
|
377
|
+
const cache = readCacheManifest(options.outputDir);
|
|
378
|
+
let cacheDirty = false;
|
|
379
|
+
|
|
380
|
+
// Collect { audioSrc, absoluteOffset } from the compiled tree
|
|
381
|
+
const clips: Array<{ audioSrc: string; offset: number }> = [];
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Walk an array of sibling nodes, tracking cumulative offset for series layouts.
|
|
385
|
+
*
|
|
386
|
+
* In the compiled stream tree, container nodes (Folder/Scene) don't carry `actions`
|
|
387
|
+
* — their position on the timeline is implicit in the `<Series>`/`<TransitionSeries>`
|
|
388
|
+
* renderer which plays each child sequentially based on `durationInSeconds`.
|
|
389
|
+
* This function replicates that logic to compute absolute audio offsets.
|
|
390
|
+
*/
|
|
391
|
+
function walkSiblings(
|
|
392
|
+
nodes: any[],
|
|
393
|
+
parentOffset: number,
|
|
394
|
+
parentIsSeries: boolean,
|
|
395
|
+
parentTransition?: string,
|
|
396
|
+
parentTransitionTime?: number,
|
|
397
|
+
): void {
|
|
398
|
+
let seriesOffset = parentOffset;
|
|
399
|
+
for (const node of nodes) {
|
|
400
|
+
// Skip background children — they are rendered outside the series as
|
|
401
|
+
// parallel overlays (see FolderLeaf), so they don't advance the offset.
|
|
402
|
+
if (node.isBackground) continue;
|
|
403
|
+
|
|
404
|
+
// In a series, each child starts after all previous siblings' durations.
|
|
405
|
+
// In parallel, all children share the parent offset.
|
|
406
|
+
const nodeStart = parentIsSeries ? seriesOffset : parentOffset;
|
|
407
|
+
// Leaf nodes (video/audio/image/component) carry an action start offset
|
|
408
|
+
// relative to the container start (e.g., parallel layout with staggered start).
|
|
409
|
+
const actionStart = node.actions?.[0]?.start ?? 0;
|
|
410
|
+
const effectiveOffset = nodeStart + actionStart;
|
|
411
|
+
|
|
412
|
+
if (node.type === "audio" && node.src) {
|
|
413
|
+
clips.push({ audioSrc: node.src, offset: effectiveOffset });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Recurse into children — determine their layout from the node type
|
|
417
|
+
if (node.children && node.children.length > 0) {
|
|
418
|
+
let childIsSeries = false;
|
|
419
|
+
let childTransition = undefined as string | undefined;
|
|
420
|
+
let childTransitionTime = 0.5;
|
|
421
|
+
|
|
422
|
+
if (node.type === "folder") {
|
|
423
|
+
childIsSeries = node.isSeries ?? false;
|
|
424
|
+
childTransition = node.transition;
|
|
425
|
+
childTransitionTime = node.transitionTime ?? 0.5;
|
|
426
|
+
} else if (node.type === "scene") {
|
|
427
|
+
// A parallel scene has direct leaf children.
|
|
428
|
+
// A series/transitionSeries scene wraps its children in a single Folder child.
|
|
429
|
+
if (
|
|
430
|
+
node.children.length === 1 &&
|
|
431
|
+
node.children[0].type === "folder" &&
|
|
432
|
+
node.children[0].isSeries
|
|
433
|
+
) {
|
|
434
|
+
childIsSeries = true;
|
|
435
|
+
childTransition = node.children[0].transition;
|
|
436
|
+
childTransitionTime = node.children[0].transitionTime ?? 0.5;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
walkSiblings(node.children, effectiveOffset, childIsSeries, childTransition, childTransitionTime);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// In a series, advance the offset by this node's duration
|
|
444
|
+
// so the next sibling starts after this one finishes.
|
|
445
|
+
if (parentIsSeries && node.durationInSeconds) {
|
|
446
|
+
const overlap = parentTransition ? (parentTransitionTime ?? 0.5) : 0;
|
|
447
|
+
seriesOffset += node.durationInSeconds - overlap;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Walk the compiled tree (with actions) for correct absolute offsets;
|
|
453
|
+
// fall back to the descriptive tree if no compiled tree provided.
|
|
454
|
+
const treeToWalk = options.compiled ?? (clone as any);
|
|
455
|
+
walkSiblings(
|
|
456
|
+
(treeToWalk as any).children ?? [],
|
|
457
|
+
0,
|
|
458
|
+
(treeToWalk as any).isSeries ?? false,
|
|
459
|
+
(treeToWalk as any).transition,
|
|
460
|
+
(treeToWalk as any).transitionTime,
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
// Run STT and collect VTT cues with absolute timestamps
|
|
464
|
+
const mergedLines: string[] = ["WEBVTT", ""];
|
|
465
|
+
let cueIndex = 1;
|
|
466
|
+
|
|
467
|
+
let sttFailedCount = 0;
|
|
468
|
+
|
|
469
|
+
for (const { audioSrc, offset } of clips) {
|
|
470
|
+
// Cache key: audio hash + STT CLI string
|
|
471
|
+
const audioHash = existsSync(audioSrc)
|
|
472
|
+
? createHash("sha1").update(readFileSync(audioSrc)).digest("hex").slice(0, 12)
|
|
473
|
+
: audioSrc;
|
|
474
|
+
const sttCacheKey = computeCacheKey({ audioHash, cli: sttCli });
|
|
475
|
+
const sttKey = `stt:${audioSrc.split("/").pop()}`;
|
|
476
|
+
const clipName = audioSrc.split("/").pop()!;
|
|
477
|
+
|
|
478
|
+
let vttPath: string | null = null;
|
|
479
|
+
// Cache per-clip STT by audio hash + CLI (individual VTT files are stable)
|
|
480
|
+
const cachedVtt = checkCache(cache, sttKey, sttCacheKey);
|
|
481
|
+
if (cachedVtt) {
|
|
482
|
+
vttPath = cachedVtt;
|
|
483
|
+
} else {
|
|
484
|
+
try {
|
|
485
|
+
await generateSTT(audioSrc, options.outputDir, sttCli);
|
|
486
|
+
// Find generated VTT file
|
|
487
|
+
const base = audioSrc.replace(/\.wav$/, "").replace(/\.mp3$/, "");
|
|
488
|
+
const name = base.split("/").pop()!;
|
|
489
|
+
const candidate = join(options.outputDir, `${name}.vtt`);
|
|
490
|
+
if (existsSync(candidate)) {
|
|
491
|
+
vttPath = candidate;
|
|
492
|
+
updateCache(cache, sttKey, sttCacheKey, vttPath);
|
|
493
|
+
cacheDirty = true;
|
|
494
|
+
}
|
|
495
|
+
} catch {
|
|
496
|
+
console.warn(` ⚠ STT failed for ${clipName}. Install whisper (pip install openai-whisper) or set root.stt.`);
|
|
497
|
+
sttFailedCount++;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (!vttPath || !existsSync(vttPath)) continue;
|
|
502
|
+
const vttText = readFileSync(vttPath, "utf-8");
|
|
503
|
+
const blocks = vttText.replace(/\r\n/g, "\n").split(/\n\n+/);
|
|
504
|
+
for (const block of blocks) {
|
|
505
|
+
const lines = block.split("\n").filter(Boolean);
|
|
506
|
+
const tline = lines.find((l) => l.includes("-->"));
|
|
507
|
+
if (!tline) continue;
|
|
508
|
+
const [a, z] = tline.split("-->").map((s) => s.trim());
|
|
509
|
+
if (!a || !z) continue;
|
|
510
|
+
const toSec = (ts: string) => {
|
|
511
|
+
const parts = ts.split(":").map(Number);
|
|
512
|
+
if (parts.length === 3) return parts[0]! * 3600 + parts[1]! * 60 + parts[2]!;
|
|
513
|
+
return parts[0]! * 60 + parts[1]!;
|
|
514
|
+
};
|
|
515
|
+
const formatSec = (s: number) => {
|
|
516
|
+
const h = Math.floor(s / 3600);
|
|
517
|
+
const m = Math.floor((s % 3600) / 60);
|
|
518
|
+
const sec = (s % 60).toFixed(3).padStart(6, "0");
|
|
519
|
+
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${sec}`;
|
|
520
|
+
};
|
|
521
|
+
const text = lines.slice(lines.indexOf(tline) + 1).join("\n").trim();
|
|
522
|
+
mergedLines.push(String(cueIndex++));
|
|
523
|
+
mergedLines.push(`${formatSec(toSec(a) + offset)} --> ${formatSec(toSec(z) + offset)}`);
|
|
524
|
+
mergedLines.push(text, "");
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (clips.length > 0) {
|
|
529
|
+
const transcribed = clips.length - sttFailedCount;
|
|
530
|
+
if (transcribed > 0 || sttFailedCount > 0) {
|
|
531
|
+
console.log(` 📝 STT: ${transcribed} transcribed${sttFailedCount > 0 ? `, ${sttFailedCount} failed` : ""}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (cueIndex > 1) {
|
|
536
|
+
const mergedDir = options.mergedOutputDir ?? options.outputDir;
|
|
537
|
+
mkdirSync(mergedDir, { recursive: true });
|
|
538
|
+
const mergedPath = join(mergedDir, "subtitles.vtt");
|
|
539
|
+
writeFileSync(mergedPath, mergedLines.join("\n"), "utf-8");
|
|
540
|
+
clone.subtitle = { ...(clone.subtitle ?? {}), src: mergedPath };
|
|
541
|
+
console.log(` ✅ STT: subtitles ready (${cueIndex - 1} cues)`);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (cacheDirty) writeCacheManifest(options.outputDir, cache);
|
|
545
|
+
return clone;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ── Image & Video Generation (TTI / TTV) ──────────────────────────────────
|
|
549
|
+
|
|
550
|
+
export interface ResolveGeneratedMediaOptions {
|
|
551
|
+
/** Output directory for generated media files */
|
|
552
|
+
outputDir: string;
|
|
553
|
+
/** Default TTI CLI template (overrides root.tti) */
|
|
554
|
+
ttiCli?: string;
|
|
555
|
+
/** Default TTV CLI template (overrides root.ttv) */
|
|
556
|
+
ttvCli?: string;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Walk the descriptive tree, find image/video nodes with `prompt` but no `src`,
|
|
561
|
+
* run the configured TTI/TTV CLI to generate media, and set `src` to the output.
|
|
562
|
+
*
|
|
563
|
+
* Images: prompt → generate .png via TTI CLI
|
|
564
|
+
* Videos: prompt → generate .mp4 via TTV CLI
|
|
565
|
+
*
|
|
566
|
+
* Both support content-hash caching (same prompt + same config → reuse).
|
|
567
|
+
*/
|
|
568
|
+
export async function resolveGeneratedMedia(
|
|
569
|
+
root: DescriptiveRoot,
|
|
570
|
+
options: ResolveGeneratedMediaOptions,
|
|
571
|
+
): Promise<DescriptiveRoot> {
|
|
572
|
+
const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
|
|
573
|
+
mkdirSync(options.outputDir, { recursive: true });
|
|
574
|
+
const cache = readCacheManifest(options.outputDir);
|
|
575
|
+
let cacheDirty = false;
|
|
576
|
+
|
|
577
|
+
// Collect all image/video nodes that have prompt but no src
|
|
578
|
+
const genNodes: Array<{
|
|
579
|
+
node: any;
|
|
580
|
+
id: string;
|
|
581
|
+
type: "image" | "video";
|
|
582
|
+
prompt: string;
|
|
583
|
+
}> = [];
|
|
584
|
+
|
|
585
|
+
walkDown(clone as any, (node) => {
|
|
586
|
+
if ((node.type !== "image" && node.type !== "video")) return;
|
|
587
|
+
if (!node.prompt || typeof node.prompt !== "string") return;
|
|
588
|
+
// Skip if src is already set (prompt is just metadata)
|
|
589
|
+
if (node.src) return;
|
|
590
|
+
const id = node.id ?? `${node.type}-${genNodes.length}`;
|
|
591
|
+
genNodes.push({ node, id, type: node.type as "image" | "video", prompt: node.prompt });
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
for (const { node, id, type, prompt } of genNodes) {
|
|
595
|
+
const ext = type === "image" ? "png" : "mp4";
|
|
596
|
+
|
|
597
|
+
// Resolve TTI/TTV config: root-level config overrides CLI defaults
|
|
598
|
+
const cli = type === "image"
|
|
599
|
+
? (clone.tti ?? options.ttiCli ?? DEFAULT_TTI_CLI)
|
|
600
|
+
: (clone.ttv ?? options.ttvCli ?? DEFAULT_TTV_CLI);
|
|
601
|
+
|
|
602
|
+
// Content-addressed filename: hash of prompt + CLI + type, so identical
|
|
603
|
+
// prompts across different source files share the same generated media.
|
|
604
|
+
const cacheKey = computeCacheKey({ prompt, cli, type });
|
|
605
|
+
const outputPath = join(options.outputDir, `${cacheKey}.${ext}`);
|
|
606
|
+
|
|
607
|
+
const cached = checkCache(cache, `gen:${cacheKey}`, cacheKey);
|
|
608
|
+
const label = type === "image" ? "TTI" : "TTV";
|
|
609
|
+
const labelText = firstWords(prompt, 8);
|
|
610
|
+
|
|
611
|
+
if (cached) {
|
|
612
|
+
node.src = resolvePath(cached);
|
|
613
|
+
console.log(` ✓ ${label}: ${labelText} (cached)`);
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
console.log(` 🔊 ${label}: ${labelText}...`);
|
|
619
|
+
const ttiCmd = clone.tti ?? options.ttiCli ?? DEFAULT_TTI_CLI;
|
|
620
|
+
const result = type === "image"
|
|
621
|
+
? generateTTI(prompt, outputPath, cli)
|
|
622
|
+
: generateTTV(prompt, outputPath, cli, ttiCmd);
|
|
623
|
+
if (result) {
|
|
624
|
+
node.src = outputPath;
|
|
625
|
+
updateCache(cache, `gen:${cacheKey}`, cacheKey, outputPath);
|
|
626
|
+
cacheDirty = true;
|
|
627
|
+
console.log(` ✓ ${label}: ${labelText}`);
|
|
628
|
+
} else {
|
|
629
|
+
const hint = cli.includes("echo")
|
|
630
|
+
? `No ${label} tool installed. The default CLI just echoes a message — set root.${type === "image" ? "tti" : "ttv"} to a real generation command.`
|
|
631
|
+
: `The command ran but produced no output file. Check the CLI template or run the script manually to debug.`;
|
|
632
|
+
console.error(` ⚠ ${label}: "${labelText}" produced no output. ${hint}`);
|
|
633
|
+
}
|
|
634
|
+
} catch (err: any) {
|
|
635
|
+
const hint = (err as any)?.stderr?.toString()?.includes("not found")
|
|
636
|
+
? `${label} tool not found. Install the required CLI or configure root.${type === "image" ? "tti" : "ttv"}.`
|
|
637
|
+
: `Command failed. Try running the CLI template directly to debug: ${cli}`;
|
|
638
|
+
console.error(` ✗ ${label}: "${labelText}" failed — ${err.message}. ${hint}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (cacheDirty) writeCacheManifest(options.outputDir, cache);
|
|
643
|
+
return clone;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ── Combined Pipeline ──────────────────────────────────────────────────────
|
|
647
|
+
|
|
648
|
+
export interface ResolveAllOptions extends ResolveMediaOptions {
|
|
649
|
+
/** If set, enables script → TTS resolution */
|
|
650
|
+
scriptOutputDir?: string;
|
|
651
|
+
/** TTS CLI template override (default: edge-tts). Overrides root.tts. */
|
|
652
|
+
ttsCli?: string;
|
|
653
|
+
/** STT CLI template override (default: whisper). Overrides root.stt. */
|
|
654
|
+
sttCli?: string;
|
|
655
|
+
/** If set, enables TTI/TTV media generation from prompts */
|
|
656
|
+
mediaOutputDir?: string;
|
|
657
|
+
/** TTI CLI template override (default: pi agent). Overrides root.tti. */
|
|
658
|
+
ttiCli?: string;
|
|
659
|
+
/** TTV CLI template override (default: pi agent). Overrides root.ttv. */
|
|
660
|
+
ttvCli?: string;
|
|
661
|
+
/** If set, resolve includes. Directory where pre-compiled include JSON files are stored. */
|
|
662
|
+
includeOutputDir?: string;
|
|
663
|
+
/** Separate output dir for the merged subtitles.vtt.
|
|
664
|
+
* Per-clip VTTs stay in scriptOutputDir (shared cache) while the merged
|
|
665
|
+
* VTT goes here (e.g., per-variant). Defaults to scriptOutputDir. */
|
|
666
|
+
subtitleOutputDir?: string;
|
|
667
|
+
/** Variant chain to apply to included sub-videos (e.g. ["zh", "tiktok"]).
|
|
668
|
+
* When set, include nodes parse their target .md files with variant awareness
|
|
669
|
+
* and apply variant overrides (zh-src → src, bare `zh` key → primary content). */
|
|
670
|
+
variants?: string[];
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Recursively resolve include nodes in the descriptive tree.
|
|
675
|
+
*
|
|
676
|
+
* For each `include` node with a `.md` src:
|
|
677
|
+
* 1. Read and parse the referenced markdown file
|
|
678
|
+
* 2. Recursively resolve it (media durations, TTS, STT, nested includes)
|
|
679
|
+
* 3. Compile it to a stream tree Root
|
|
680
|
+
* 4. Write the compiled Root to `includeOutputDir/<hash>.json`
|
|
681
|
+
* 5. Write a companion `.meta.json` file with import entries for the server
|
|
682
|
+
* 6. Update the include node's `src` to the compiled JSON path
|
|
683
|
+
* 7. Stamp `durationInSeconds` with the sub-video's real duration
|
|
684
|
+
*
|
|
685
|
+
* After this step, the descriptive tree is fully resolved: all includes point
|
|
686
|
+
* to pre-compiled JSON files with accurate durations. The server can then
|
|
687
|
+
* bundle per-subvideo component imports using the companion meta files.
|
|
688
|
+
*/
|
|
689
|
+
export async function resolveIncludes(
|
|
690
|
+
root: DescriptiveRoot,
|
|
691
|
+
options: ResolveAllOptions = {},
|
|
692
|
+
): Promise<DescriptiveRoot> {
|
|
693
|
+
const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
|
|
694
|
+
const baseDir = options.baseDir ?? process.cwd();
|
|
695
|
+
const outputDir = options.includeOutputDir ?? join(baseDir, ".markcut", "generated", "includes");
|
|
696
|
+
mkdirSync(outputDir, { recursive: true });
|
|
697
|
+
|
|
698
|
+
// Extract imports block from raw markdown source (reusable helper)
|
|
699
|
+
function extractImportEntriesFromRaw(raw: string): {
|
|
700
|
+
entries: Array<{ name: string; from?: string; exports?: string }> | null;
|
|
701
|
+
extraSpecs: string[];
|
|
702
|
+
rawSource: string | null;
|
|
703
|
+
} {
|
|
704
|
+
const match = raw.match(/^(```|~~~)\s*js imports\s*\n([\s\S]*?)^\1\s*$/m);
|
|
705
|
+
if (!match) return { entries: null, extraSpecs: [], rawSource: null };
|
|
706
|
+
const src = match[2]!;
|
|
707
|
+
const entries = parseImportsBlock(src);
|
|
708
|
+
const extraSpecs = extractDependencySpecs(src);
|
|
709
|
+
return { entries, extraSpecs, rawSource: src };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function resolveOneInclude(
|
|
713
|
+
includeNode: DescriptiveNode,
|
|
714
|
+
parentBaseDir: string,
|
|
715
|
+
): Promise<void> {
|
|
716
|
+
const n = includeNode as any;
|
|
717
|
+
if (n.type !== "include") return;
|
|
718
|
+
const src = n.src;
|
|
719
|
+
if (!src || !src.endsWith(".md")) return;
|
|
720
|
+
|
|
721
|
+
// Resolve the path
|
|
722
|
+
const absPath = src.startsWith("/") ? src : resolvePath(parentBaseDir, src);
|
|
723
|
+
if (!existsSync(absPath)) {
|
|
724
|
+
console.warn(` ⚠ Include "${src}" not found at ${absPath}`);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Read and parse
|
|
729
|
+
const raw = readFileSync(absPath, "utf-8");
|
|
730
|
+
let subRoot = parseMarkdownDescriptive(raw);
|
|
731
|
+
|
|
732
|
+
// Apply variant overrides to the sub-video if a variant chain is active
|
|
733
|
+
const variantChain = options.variants;
|
|
734
|
+
if (variantChain && variantChain.length > 0) {
|
|
735
|
+
const parsed = parseMarkdownVariants(raw);
|
|
736
|
+
subRoot = parsed.base;
|
|
737
|
+
// Merge root config from the first variant's section
|
|
738
|
+
const variantRoot = parsed.variants.get(variantChain[0]!);
|
|
739
|
+
if (variantRoot) {
|
|
740
|
+
const { children: _, ...configOverrides } = variantRoot;
|
|
741
|
+
subRoot = { ...subRoot, ...configOverrides };
|
|
742
|
+
}
|
|
743
|
+
subRoot = resolveVariantOverrides(subRoot, variantChain);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Extract import info from the included file (for the server to bundle later)
|
|
747
|
+
const { entries: importEntries, extraSpecs, rawSource } = extractImportEntriesFromRaw(raw);
|
|
748
|
+
|
|
749
|
+
// Recursively resolve the sub-root (this handles nested includes too)
|
|
750
|
+
// Use the same output dir so all compiled includes are co-located.
|
|
751
|
+
// Pass the variant chain so nested includes also get variant overrides.
|
|
752
|
+
const resolved = await resolveAll(subRoot, {
|
|
753
|
+
...options,
|
|
754
|
+
includeOutputDir: outputDir,
|
|
755
|
+
baseDir: dirname(absPath),
|
|
756
|
+
variants: variantChain,
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
// Compile to stream tree
|
|
760
|
+
const compiled = compileDescriptiveRoot(resolved);
|
|
761
|
+
|
|
762
|
+
// Determine duration
|
|
763
|
+
const dur = compiled.durationInSeconds ?? compiled.children?.reduce(
|
|
764
|
+
(max: number, c: any) => Math.max(max, c.durationInSeconds ?? 0), 0
|
|
765
|
+
) ?? 3;
|
|
766
|
+
|
|
767
|
+
// Create a stable filename from the absolute path
|
|
768
|
+
const hash = createHash("sha1").update(absPath).digest("hex").slice(0, 12);
|
|
769
|
+
const compiledPath = join(outputDir, `${hash}.json`);
|
|
770
|
+
const metaPath = join(outputDir, `${hash}.meta.json`);
|
|
771
|
+
|
|
772
|
+
// Write companion meta file if there are imports
|
|
773
|
+
if (importEntries && importEntries.length > 0) {
|
|
774
|
+
const sourceName = absPath.split("/").pop().replace(/\.[^.]+$/, "");
|
|
775
|
+
const meta = { importEntries, extraSpecs, rawSource, sourceName };
|
|
776
|
+
writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf-8");
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Write compiled JSON (without imports field — the server will set it after bundling)
|
|
780
|
+
const compiledJson = {
|
|
781
|
+
root: {
|
|
782
|
+
...compiled,
|
|
783
|
+
// Include a stub field so the server knows this needs bundling
|
|
784
|
+
...(importEntries && importEntries.length > 0 ? { _pendingImports: true } : {}),
|
|
785
|
+
},
|
|
786
|
+
};
|
|
787
|
+
writeFileSync(compiledPath, JSON.stringify(compiledJson, null, 2), "utf-8");
|
|
788
|
+
|
|
789
|
+
// Update the include node
|
|
790
|
+
n.src = compiledPath;
|
|
791
|
+
n.durationInSeconds = dur;
|
|
792
|
+
n.duration = dur;
|
|
793
|
+
// Remove children if any (the sub-video is now an external reference)
|
|
794
|
+
delete n.children;
|
|
795
|
+
|
|
796
|
+
console.log(` 🔗 Include resolved: ${src} → ${relative(process.cwd(), compiledPath)} (${dur.toFixed(1)}s)`);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Walk the tree and resolve includes. We do a multi-pass approach:
|
|
800
|
+
// first collect all includes, then resolve them (to avoid mutation during iteration).
|
|
801
|
+
const includes: Array<{ node: DescriptiveNode; baseDir: string }> = [];
|
|
802
|
+
walkDown(clone as any, (node, parent) => {
|
|
803
|
+
if ((node as any).type === "include" && (node as any).src?.endsWith(".md")) {
|
|
804
|
+
includes.push({ node: node as DescriptiveNode, baseDir: baseDir });
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
for (const inc of includes) {
|
|
809
|
+
await resolveOneInclude(inc.node, inc.baseDir);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return clone;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Run all pre-pass resolvers in the correct order:
|
|
817
|
+
* 0. Resolve includes (pre-compile referenced .md files to JSON)
|
|
818
|
+
* 1. Resolve template variables (${width}, ${height}, etc. in src/prompt)
|
|
819
|
+
* 2. Best-match media src resolution (pattern-based src → closest existing file)
|
|
820
|
+
* 3. Generated media (TTI/TTV) — resolve image/video prompts to actual files
|
|
821
|
+
* 4. Media duration probing
|
|
822
|
+
* 5. Script → TTS (audio only) — uses root.tts / scene.tts / CLI options
|
|
823
|
+
* 6. Post-compile: STT → VTT (subtitle) — uses root.stt / CLI options
|
|
824
|
+
*
|
|
825
|
+
* Step 0 runs first so that sub-video durations are known before duration
|
|
826
|
+
* probing of the parent tree.
|
|
827
|
+
*/
|
|
828
|
+
export async function resolveAll(
|
|
829
|
+
root: DescriptiveRoot,
|
|
830
|
+
options: ResolveAllOptions = {},
|
|
831
|
+
): Promise<DescriptiveRoot> {
|
|
832
|
+
let result = root;
|
|
833
|
+
|
|
834
|
+
// Step 0: Resolve includes (pre-compile referenced .md files to JSON with accurate durations)
|
|
835
|
+
result = await resolveIncludes(result, options);
|
|
836
|
+
|
|
837
|
+
// Step 1: Resolve template variables (${width}, ${height}, ${fps}, ${variant}) in all string fields.
|
|
838
|
+
// Uses root's current width/height/fps. This happens before media resolution
|
|
839
|
+
// so that pattern-based src/prompt values are resolved with actual dimensions.
|
|
840
|
+
const { resolveAllTemplateVars } = await import("./compiler");
|
|
841
|
+
result = resolveAllTemplateVars(result, {
|
|
842
|
+
width: result.width ?? 1080,
|
|
843
|
+
height: result.height ?? 1920,
|
|
844
|
+
fps: result.fps ?? 30,
|
|
845
|
+
variant: options.variants?.[0] ?? "video",
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
// Step 2: Best-match media src resolution — for pattern-based src values
|
|
849
|
+
// (e.g. photo_${width}x${height}.jpg), find the closest existing file.
|
|
850
|
+
result = await resolveMediaSrcs(result, { baseDir: options.baseDir });
|
|
851
|
+
|
|
852
|
+
// Step 3: Generate images/videos from prompts before probing durations
|
|
853
|
+
if (options.mediaOutputDir) {
|
|
854
|
+
result = await resolveGeneratedMedia(result, {
|
|
855
|
+
outputDir: options.mediaOutputDir,
|
|
856
|
+
ttiCli: options.ttiCli,
|
|
857
|
+
ttvCli: options.ttvCli,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Step 4: Media duration probing
|
|
862
|
+
result = await resolveMediaDurations(result, {
|
|
863
|
+
baseDir: options.baseDir,
|
|
864
|
+
skip: options.skip,
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
// Step 5: Script → TTS
|
|
868
|
+
if (options.scriptOutputDir) {
|
|
869
|
+
result = await resolveScripts(result, {
|
|
870
|
+
outputDir: options.scriptOutputDir,
|
|
871
|
+
ttsCli: options.ttsCli,
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
// Re-probe for newly generated TTS audio durations
|
|
875
|
+
result = await resolveMediaDurations(result, {
|
|
876
|
+
baseDir: options.baseDir,
|
|
877
|
+
skip: options.skip,
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// Post-compile subtitle generation (uses root.stt, options.sttCli, or default whisper CLI)
|
|
881
|
+
const compiled = compileDescriptiveRoot(result);
|
|
882
|
+
result = await resolveSubtitles(result, {
|
|
883
|
+
outputDir: options.scriptOutputDir,
|
|
884
|
+
sttCli: options.sttCli,
|
|
885
|
+
compiled,
|
|
886
|
+
mergedOutputDir: options.subtitleOutputDir,
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
return result;
|
|
891
|
+
}
|