video-editing 1.3.1 → 3.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/README.md +53 -57
- package/dist/chunk-W4IIL2T2.js +2488 -0
- package/dist/cli.js +590 -1435
- package/dist/index.d.ts +1099 -922
- package/dist/index.js +319 -268
- package/package.json +2 -19
- package/skills/video-editing/SKILL.md +170 -235
- package/skills/video-editing/references/adversarial-review.md +3 -43
- package/skills/video-editing/references/cli-reference.md +165 -160
- package/skills/video-editing/references/editing-brief.md +3 -43
- package/skills/video-editing/references/hard-rules.md +3 -37
- package/skills/video-editing/references/scribe-collapse.md +3 -36
- package/skills/video-editing/references/timeline-v2-vocabulary.md +2 -2
- package/dist/assets/DejaVuSansMono.ttf +0 -0
- package/dist/chunk-63HIQBDT.js +0 -4321
- package/dist/chunk-CU3NBKB4.js +0 -307
- package/dist/job-OFCSLPBV.js +0 -25
- package/dist/templates/author-edl.prompt.md +0 -33
- package/dist/templates/fix.prompt.md +0 -23
- package/dist/templates/review.prompt.md +0 -30
package/dist/chunk-63HIQBDT.js
DELETED
|
@@ -1,4321 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
displayedDimensions,
|
|
4
|
-
ffprobe,
|
|
5
|
-
isoNow,
|
|
6
|
-
mediaDuration,
|
|
7
|
-
pyFixed,
|
|
8
|
-
pyFixedPad,
|
|
9
|
-
pyRound,
|
|
10
|
-
pySpacePad,
|
|
11
|
-
run,
|
|
12
|
-
transcriptPath,
|
|
13
|
-
videoStream
|
|
14
|
-
} from "./chunk-CU3NBKB4.js";
|
|
15
|
-
|
|
16
|
-
// src/config.ts
|
|
17
|
-
import { existsSync, readFileSync } from "fs";
|
|
18
|
-
import { homedir } from "os";
|
|
19
|
-
import { join } from "path";
|
|
20
|
-
function configDir() {
|
|
21
|
-
const xdg = process.env.XDG_CONFIG_HOME;
|
|
22
|
-
const base = xdg && xdg.trim() ? xdg : join(homedir(), ".config");
|
|
23
|
-
return join(base, "video-editing");
|
|
24
|
-
}
|
|
25
|
-
function configEnvPath() {
|
|
26
|
-
return join(configDir(), ".env");
|
|
27
|
-
}
|
|
28
|
-
function parseEnvFileForKey(path, key) {
|
|
29
|
-
let text;
|
|
30
|
-
try {
|
|
31
|
-
text = readFileSync(path, "utf8");
|
|
32
|
-
} catch {
|
|
33
|
-
return void 0;
|
|
34
|
-
}
|
|
35
|
-
for (const raw of text.split(/\r?\n/)) {
|
|
36
|
-
const line = raw.trim();
|
|
37
|
-
if (!line || line.startsWith("#") || !line.includes("=")) continue;
|
|
38
|
-
const eq = line.indexOf("=");
|
|
39
|
-
const k = line.slice(0, eq).trim();
|
|
40
|
-
if (k === key) {
|
|
41
|
-
let v = line.slice(eq + 1).trim();
|
|
42
|
-
v = v.replace(/^"|"$/g, "").replace(/^'|'$/g, "");
|
|
43
|
-
return v.trim();
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return void 0;
|
|
47
|
-
}
|
|
48
|
-
function loadApiKey(opts = {}) {
|
|
49
|
-
const fromEnv = process.env.ELEVENLABS_API_KEY;
|
|
50
|
-
if (fromEnv && fromEnv.trim()) return fromEnv.trim();
|
|
51
|
-
if (opts.apiKey && opts.apiKey.trim()) return opts.apiKey.trim();
|
|
52
|
-
const candidates = [];
|
|
53
|
-
if (opts.envPath) candidates.push(opts.envPath);
|
|
54
|
-
candidates.push(configEnvPath());
|
|
55
|
-
for (const path of candidates) {
|
|
56
|
-
if (existsSync(path)) {
|
|
57
|
-
const v = parseEnvFileForKey(path, "ELEVENLABS_API_KEY");
|
|
58
|
-
if (v) return v;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
throw new Error(
|
|
62
|
-
`ELEVENLABS_API_KEY not found. Set the env var, pass --api-key, or add it to ${configEnvPath()}`
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// src/master.ts
|
|
67
|
-
import { writeFileSync } from "fs";
|
|
68
|
-
import { existsSync as existsSync2 } from "fs";
|
|
69
|
-
import { join as join2 } from "path";
|
|
70
|
-
var X264_BAKE = [
|
|
71
|
-
"-c:v",
|
|
72
|
-
"libx264",
|
|
73
|
-
"-preset",
|
|
74
|
-
"medium",
|
|
75
|
-
"-crf",
|
|
76
|
-
"18",
|
|
77
|
-
"-pix_fmt",
|
|
78
|
-
"yuv420p",
|
|
79
|
-
"-c:a",
|
|
80
|
-
"aac",
|
|
81
|
-
"-b:a",
|
|
82
|
-
"192k",
|
|
83
|
-
"-movflags",
|
|
84
|
-
"+faststart"
|
|
85
|
-
];
|
|
86
|
-
async function ensureVerticalMaster(jobDir, source, opts = {}) {
|
|
87
|
-
const out = join2(jobDir, "vertical_src.mp4");
|
|
88
|
-
if (existsSync2(out) && !opts.force) return out;
|
|
89
|
-
const probe = await ffprobe(source);
|
|
90
|
-
const stream = videoStream(probe);
|
|
91
|
-
const [displayW, displayH, rotation] = displayedDimensions(stream);
|
|
92
|
-
const longEdge = Math.max(displayW, displayH);
|
|
93
|
-
let vf = displayH >= displayW && longEdge > 1920 ? "scale=-2:1920" : null;
|
|
94
|
-
if (displayW > displayH && longEdge > 1920) vf = "scale=1920:-2";
|
|
95
|
-
const cmd = ["ffmpeg", "-y", "-i", source];
|
|
96
|
-
if (vf) cmd.push("-vf", vf);
|
|
97
|
-
cmd.push(...X264_BAKE, out);
|
|
98
|
-
await run(cmd);
|
|
99
|
-
const outProbe = await ffprobe(out);
|
|
100
|
-
const outStream = videoStream(outProbe);
|
|
101
|
-
const outW = Math.trunc(Number(outStream.width ?? 0)) || 0;
|
|
102
|
-
const outH = Math.trunc(Number(outStream.height ?? 0)) || 0;
|
|
103
|
-
let retriedWith = null;
|
|
104
|
-
if (displayH > displayW && outW > outH) {
|
|
105
|
-
retriedWith = vf ? `transpose=1,${vf}` : "transpose=1";
|
|
106
|
-
await run(["ffmpeg", "-y", "-i", source, "-vf", retriedWith, ...X264_BAKE, out]);
|
|
107
|
-
}
|
|
108
|
-
writeFileSync(
|
|
109
|
-
join2(jobDir, "master.json"),
|
|
110
|
-
JSON.stringify(
|
|
111
|
-
{
|
|
112
|
-
source,
|
|
113
|
-
master: out,
|
|
114
|
-
display_width: displayW,
|
|
115
|
-
display_height: displayH,
|
|
116
|
-
rotation,
|
|
117
|
-
scale_filter: vf,
|
|
118
|
-
retry_filter: retriedWith,
|
|
119
|
-
created: isoNow()
|
|
120
|
-
},
|
|
121
|
-
null,
|
|
122
|
-
2
|
|
123
|
-
) + "\n",
|
|
124
|
-
"utf8"
|
|
125
|
-
);
|
|
126
|
-
return out;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// src/transcribe.ts
|
|
130
|
-
import { existsSync as existsSync3, mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync as writeFileSync2 } from "fs";
|
|
131
|
-
import { tmpdir } from "os";
|
|
132
|
-
import { basename, extname, join as join3 } from "path";
|
|
133
|
-
var SCRIBE_URL = "https://api.elevenlabs.io/v1/speech-to-text";
|
|
134
|
-
function stemOf(p) {
|
|
135
|
-
return basename(p, extname(p));
|
|
136
|
-
}
|
|
137
|
-
async function extractAudio(videoPath, dest) {
|
|
138
|
-
await run(
|
|
139
|
-
[
|
|
140
|
-
"ffmpeg",
|
|
141
|
-
"-y",
|
|
142
|
-
"-i",
|
|
143
|
-
videoPath,
|
|
144
|
-
"-vn",
|
|
145
|
-
"-ac",
|
|
146
|
-
"1",
|
|
147
|
-
"-ar",
|
|
148
|
-
"16000",
|
|
149
|
-
"-c:a",
|
|
150
|
-
"pcm_s16le",
|
|
151
|
-
dest
|
|
152
|
-
],
|
|
153
|
-
{ capture: true, echo: false }
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
async function extractAudioWindow(video, start, end, dest) {
|
|
157
|
-
await run(
|
|
158
|
-
[
|
|
159
|
-
"ffmpeg",
|
|
160
|
-
"-y",
|
|
161
|
-
"-ss",
|
|
162
|
-
start.toFixed(3),
|
|
163
|
-
"-i",
|
|
164
|
-
video,
|
|
165
|
-
"-t",
|
|
166
|
-
Math.max(0.05, end - start).toFixed(3),
|
|
167
|
-
"-vn",
|
|
168
|
-
"-ac",
|
|
169
|
-
"1",
|
|
170
|
-
"-ar",
|
|
171
|
-
"16000",
|
|
172
|
-
"-c:a",
|
|
173
|
-
"pcm_s16le",
|
|
174
|
-
dest
|
|
175
|
-
],
|
|
176
|
-
{ capture: true, echo: false }
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
async function callScribe(audioPath, apiKey, language, numSpeakers) {
|
|
180
|
-
const form = new FormData();
|
|
181
|
-
form.append("model_id", "scribe_v1");
|
|
182
|
-
form.append("diarize", "true");
|
|
183
|
-
form.append("tag_audio_events", "true");
|
|
184
|
-
form.append("timestamps_granularity", "word");
|
|
185
|
-
if (language) form.append("language_code", language);
|
|
186
|
-
if (numSpeakers) form.append("num_speakers", String(numSpeakers));
|
|
187
|
-
const { readFileSync: readFileSync11 } = await import("fs");
|
|
188
|
-
const buf = readFileSync11(audioPath);
|
|
189
|
-
const blob = new Blob([buf], { type: "audio/wav" });
|
|
190
|
-
form.append("file", blob, basename(audioPath));
|
|
191
|
-
const resp = await fetch(SCRIBE_URL, {
|
|
192
|
-
method: "POST",
|
|
193
|
-
headers: { "xi-api-key": apiKey },
|
|
194
|
-
body: form,
|
|
195
|
-
signal: AbortSignal.timeout(18e5)
|
|
196
|
-
});
|
|
197
|
-
if (resp.status !== 200) {
|
|
198
|
-
const body = await resp.text();
|
|
199
|
-
throw new Error(`Scribe returned ${resp.status}: ${body.slice(0, 500)}`);
|
|
200
|
-
}
|
|
201
|
-
return await resp.json();
|
|
202
|
-
}
|
|
203
|
-
var LONG_TOKEN_S = 1.2;
|
|
204
|
-
function wordDur(w) {
|
|
205
|
-
const s = w.start;
|
|
206
|
-
const e = w.end;
|
|
207
|
-
return s !== null && s !== void 0 && e !== null && e !== void 0 ? e - s : 0;
|
|
208
|
-
}
|
|
209
|
-
function realTokens(words) {
|
|
210
|
-
return words.filter(
|
|
211
|
-
(w) => (w.type === "word" || w.type === "audio_event") && w.start !== null && w.start !== void 0 && w.end !== null && w.end !== void 0
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
function attemptKey(lo, hi) {
|
|
215
|
-
return `${pyRound(lo, 2)},${pyRound(hi, 2)}`;
|
|
216
|
-
}
|
|
217
|
-
function nextRepair(words, duration, maxTokenS = LONG_TOKEN_S, gapS = 2.5, attempted = /* @__PURE__ */ new Set()) {
|
|
218
|
-
for (const w of words) {
|
|
219
|
-
if (w.type === "word" && wordDur(w) > maxTokenS) {
|
|
220
|
-
const lo = Number(w.start);
|
|
221
|
-
const hi = Number(w.end);
|
|
222
|
-
if (!attempted.has(attemptKey(lo, hi))) return { kind: "collapse", lo, hi };
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
const toks = realTokens(words);
|
|
226
|
-
if (toks.length === 0) return null;
|
|
227
|
-
for (let k = 1; k < toks.length; k++) {
|
|
228
|
-
const lo = Number(toks[k - 1].end);
|
|
229
|
-
const hi = Number(toks[k].start);
|
|
230
|
-
if (hi - lo > gapS && !attempted.has(attemptKey(lo, hi))) return { kind: "drop", lo, hi };
|
|
231
|
-
}
|
|
232
|
-
if (duration !== null && duration !== void 0 && duration - Number(toks[toks.length - 1].end) > gapS) {
|
|
233
|
-
const lo = Number(toks[toks.length - 1].end);
|
|
234
|
-
const hi = duration;
|
|
235
|
-
if (!attempted.has(attemptKey(lo, hi))) return { kind: "trailing", lo, hi };
|
|
236
|
-
}
|
|
237
|
-
return null;
|
|
238
|
-
}
|
|
239
|
-
function anchorNorm(text) {
|
|
240
|
-
return (text ?? "").toLowerCase().replace(/[^\p{L}\p{N}_-]/gu, "");
|
|
241
|
-
}
|
|
242
|
-
function goodAnchor(w) {
|
|
243
|
-
return w.type === "word" && w.start !== null && w.start !== void 0 && anchorNorm(w.text).length >= 2;
|
|
244
|
-
}
|
|
245
|
-
function findAnchors(words, lo, hi) {
|
|
246
|
-
let fa = null;
|
|
247
|
-
for (const w of words) {
|
|
248
|
-
if (goodAnchor(w) && w.end !== null && w.end !== void 0 && Number(w.end) <= lo + 1e-6) {
|
|
249
|
-
fa = w;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
let ba = null;
|
|
253
|
-
for (const w of words) {
|
|
254
|
-
if (goodAnchor(w) && Number(w.start) >= hi - 1e-6) {
|
|
255
|
-
ba = w;
|
|
256
|
-
break;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
return { fa, ba };
|
|
260
|
-
}
|
|
261
|
-
function anchorSplice(words, lo, hi, recovered, fa, ba) {
|
|
262
|
-
const locate = (anchor) => {
|
|
263
|
-
if (anchor === null) return null;
|
|
264
|
-
const target = anchorNorm(anchor.text);
|
|
265
|
-
let best = null;
|
|
266
|
-
let bestd = 1.2;
|
|
267
|
-
for (let idx = 0; idx < recovered.length; idx++) {
|
|
268
|
-
const rw = recovered[idx];
|
|
269
|
-
if (rw.type !== "word" || rw.start === null || rw.start === void 0) continue;
|
|
270
|
-
if (anchorNorm(rw.text) === target) {
|
|
271
|
-
const d = Math.abs(Number(rw.start) - Number(anchor.start));
|
|
272
|
-
if (d < bestd) {
|
|
273
|
-
best = idx;
|
|
274
|
-
bestd = d;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
if (best !== null) return best;
|
|
279
|
-
best = null;
|
|
280
|
-
bestd = 0.5;
|
|
281
|
-
for (let idx = 0; idx < recovered.length; idx++) {
|
|
282
|
-
const rw = recovered[idx];
|
|
283
|
-
if (rw.type !== "word" || rw.start === null || rw.start === void 0) continue;
|
|
284
|
-
const d = Math.abs(Number(rw.start) - Number(anchor.start));
|
|
285
|
-
if (d < bestd) {
|
|
286
|
-
best = idx;
|
|
287
|
-
bestd = d;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
return best;
|
|
291
|
-
};
|
|
292
|
-
const fi = locate(fa);
|
|
293
|
-
const bi = locate(ba);
|
|
294
|
-
const startI = fi !== null ? fi + 1 : 0;
|
|
295
|
-
let endI = bi !== null ? bi : recovered.length;
|
|
296
|
-
if (endI < startI) endI = startI;
|
|
297
|
-
const middle = recovered.slice(startI, endI).map((x) => ({ ...x }));
|
|
298
|
-
while (middle.length && middle[0].type === "spacing") middle.shift();
|
|
299
|
-
while (middle.length && middle[middle.length - 1].type === "spacing") middle.pop();
|
|
300
|
-
const low = fa !== null ? Number(fa.start) : -Infinity;
|
|
301
|
-
const high = ba !== null ? Number(ba.start) : Infinity;
|
|
302
|
-
const kept = words.filter(
|
|
303
|
-
(w) => w.start === null || w.start === void 0 || Number(w.start) <= low + 1e-6 || Number(w.start) >= high - 1e-6
|
|
304
|
-
);
|
|
305
|
-
const merged = [...kept, ...middle];
|
|
306
|
-
merged.sort((a, b) => {
|
|
307
|
-
const sa = a.start !== null && a.start !== void 0 ? Number(a.start) : 1e18;
|
|
308
|
-
const sb = b.start !== null && b.start !== void 0 ? Number(b.start) : 1e18;
|
|
309
|
-
return sa - sb;
|
|
310
|
-
});
|
|
311
|
-
return { merged, nMid: middle.length };
|
|
312
|
-
}
|
|
313
|
-
async function healCollapsedTokens(payload, video, apiKey, opts = {}) {
|
|
314
|
-
const {
|
|
315
|
-
language,
|
|
316
|
-
verbose = true,
|
|
317
|
-
maxTokenS = LONG_TOKEN_S,
|
|
318
|
-
gapS = 2.5,
|
|
319
|
-
pad = 0.25,
|
|
320
|
-
maxIters = 12
|
|
321
|
-
} = opts;
|
|
322
|
-
let words = payload.words ?? [];
|
|
323
|
-
if (!words.length) return payload;
|
|
324
|
-
let duration = null;
|
|
325
|
-
try {
|
|
326
|
-
duration = await mediaDuration(video);
|
|
327
|
-
} catch {
|
|
328
|
-
duration = null;
|
|
329
|
-
}
|
|
330
|
-
const attempted = /* @__PURE__ */ new Set();
|
|
331
|
-
let healed = false;
|
|
332
|
-
for (let i = 0; i < maxIters; i++) {
|
|
333
|
-
const region = nextRepair(words, duration, maxTokenS, gapS, attempted);
|
|
334
|
-
if (region === null) break;
|
|
335
|
-
const { lo, hi } = region;
|
|
336
|
-
attempted.add(attemptKey(lo, hi));
|
|
337
|
-
if (verbose) {
|
|
338
|
-
console.error(
|
|
339
|
-
` heal: suspicious span ${lo.toFixed(2)}-${hi.toFixed(2)} (${(hi - lo).toFixed(1)}s) \u2014 re-transcribing in isolation`
|
|
340
|
-
);
|
|
341
|
-
}
|
|
342
|
-
const { fa, ba } = findAnchors(words, lo, hi);
|
|
343
|
-
const winLo = (fa !== null ? Number(fa.start) : lo) - pad;
|
|
344
|
-
const winHi = (ba !== null ? Number(ba.end) : hi) + pad;
|
|
345
|
-
let win;
|
|
346
|
-
let ws;
|
|
347
|
-
const tmp = mkdtempSync(join3(tmpdir(), "scribe-heal-"));
|
|
348
|
-
try {
|
|
349
|
-
const wav = join3(tmp, "window.wav");
|
|
350
|
-
ws = Math.max(0, winLo);
|
|
351
|
-
const we = duration === null ? winHi : Math.min(duration, winHi);
|
|
352
|
-
await extractAudioWindow(video, ws, we, wav);
|
|
353
|
-
win = await callScribe(wav, apiKey, language, void 0);
|
|
354
|
-
} catch (e) {
|
|
355
|
-
if (verbose) console.error(` window re-transcribe failed (${String(e)}); leaving as-is`);
|
|
356
|
-
continue;
|
|
357
|
-
} finally {
|
|
358
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
359
|
-
}
|
|
360
|
-
const recovered = [];
|
|
361
|
-
for (const orig of win.words ?? []) {
|
|
362
|
-
const rw = { ...orig };
|
|
363
|
-
if (rw.start !== null && rw.start !== void 0) rw.start = rw.start + ws;
|
|
364
|
-
if (rw.end !== null && rw.end !== void 0) rw.end = rw.end + ws;
|
|
365
|
-
recovered.push(rw);
|
|
366
|
-
}
|
|
367
|
-
const beforeWords = words.filter((t) => t.type === "word").length;
|
|
368
|
-
const { merged, nMid } = anchorSplice(words, lo, hi, recovered, fa, ba);
|
|
369
|
-
if (nMid === 0) {
|
|
370
|
-
if (verbose) console.error(" (nothing recovered \u2014 treating as real silence)");
|
|
371
|
-
continue;
|
|
372
|
-
}
|
|
373
|
-
words = merged;
|
|
374
|
-
healed = true;
|
|
375
|
-
if (verbose) {
|
|
376
|
-
const net = words.filter((t) => t.type === "word").length - beforeWords;
|
|
377
|
-
const sign = net >= 0 ? `+${net}` : `${net}`;
|
|
378
|
-
console.error(` spliced ${nMid} token(s) (net ${sign} words)`);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
payload.words = words;
|
|
382
|
-
if (healed && typeof payload.text === "string") {
|
|
383
|
-
const rebuilt = words.map((w) => w.text ?? (w.type === "spacing" ? " " : "")).join("").trim();
|
|
384
|
-
payload.text = rebuilt || payload.text;
|
|
385
|
-
}
|
|
386
|
-
return payload;
|
|
387
|
-
}
|
|
388
|
-
async function transcribeOne(video, opts) {
|
|
389
|
-
const { editDir, language, numSpeakers, noHeal = false, force = false, verbose = true } = opts;
|
|
390
|
-
const apiKey = loadApiKey({ apiKey: opts.apiKey });
|
|
391
|
-
const heal = !noHeal;
|
|
392
|
-
const transcriptsDir = join3(editDir, "transcripts");
|
|
393
|
-
mkdirSync(transcriptsDir, { recursive: true });
|
|
394
|
-
const stem = stemOf(video);
|
|
395
|
-
const outPath = join3(transcriptsDir, `${stem}.json`);
|
|
396
|
-
if (existsSync3(outPath) && !force) {
|
|
397
|
-
if (verbose) console.error(`cached: ${basename(outPath)}`);
|
|
398
|
-
return outPath;
|
|
399
|
-
}
|
|
400
|
-
if (verbose) console.error(` extracting audio from ${basename(video)}`);
|
|
401
|
-
const t0 = Date.now();
|
|
402
|
-
const tmp = mkdtempSync(join3(tmpdir(), "scribe-"));
|
|
403
|
-
let payload;
|
|
404
|
-
try {
|
|
405
|
-
const audio = join3(tmp, `${stem}.wav`);
|
|
406
|
-
await extractAudio(video, audio);
|
|
407
|
-
const audioS = await mediaDuration(audio);
|
|
408
|
-
const videoS = await mediaDuration(video);
|
|
409
|
-
if (Number.isFinite(audioS) && Number.isFinite(videoS) && Math.abs(audioS - videoS) > 0.15) {
|
|
410
|
-
throw new Error(
|
|
411
|
-
`source A/V clocks diverge: decoded audio is ${audioS.toFixed(3)}s but video is ${videoS.toFixed(3)}s (\u0394 ${(audioS - videoS).toFixed(3)}s). Word timestamps would drift against the picture by up to that much. Re-export the source with a clean muxer (e.g. \`ffmpeg -i in -c:v copy -af aresample=async=1 out.mp4\`) and retry.`
|
|
412
|
-
);
|
|
413
|
-
}
|
|
414
|
-
const sizeMb = statSync(audio).size / (1024 * 1024);
|
|
415
|
-
if (verbose) console.error(` uploading ${stem}.wav (${sizeMb.toFixed(1)} MB)`);
|
|
416
|
-
payload = await callScribe(audio, apiKey, language, numSpeakers);
|
|
417
|
-
} finally {
|
|
418
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
419
|
-
}
|
|
420
|
-
if (heal) {
|
|
421
|
-
payload = await healCollapsedTokens(payload, video, apiKey, { language, verbose });
|
|
422
|
-
}
|
|
423
|
-
writeFileSync2(outPath, JSON.stringify(payload, null, 2));
|
|
424
|
-
const dt = (Date.now() - t0) / 1e3;
|
|
425
|
-
if (verbose) {
|
|
426
|
-
const kb = statSync(outPath).size / 1024;
|
|
427
|
-
console.error(` saved: ${basename(outPath)} (${kb.toFixed(1)} KB) in ${dt.toFixed(1)}s`);
|
|
428
|
-
if (payload && typeof payload === "object" && "words" in payload) {
|
|
429
|
-
console.error(` words: ${payload.words.length}`);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
return outPath;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// src/flag-takes.ts
|
|
436
|
-
import { readFileSync as readFileSync2 } from "fs";
|
|
437
|
-
import { basename as basename2 } from "path";
|
|
438
|
-
var LONG_TOKEN_S2 = 1.2;
|
|
439
|
-
var BIG_GAP_S = 1;
|
|
440
|
-
var REPEAT_WINDOW = 15;
|
|
441
|
-
function norm(t) {
|
|
442
|
-
return (t ?? "").toLowerCase().replace(/[^\p{L}\p{N}_-]/gu, "");
|
|
443
|
-
}
|
|
444
|
-
function dur(w) {
|
|
445
|
-
const s = w.start;
|
|
446
|
-
const e = w.end;
|
|
447
|
-
return s !== null && s !== void 0 && e !== null && e !== void 0 ? e - s : 0;
|
|
448
|
-
}
|
|
449
|
-
function fmtG(x) {
|
|
450
|
-
return String(x);
|
|
451
|
-
}
|
|
452
|
-
function screenTakes(transcriptPath2, opts) {
|
|
453
|
-
const offset = opts?.offset ?? 0;
|
|
454
|
-
const longTokenS = opts?.longToken ?? LONG_TOKEN_S2;
|
|
455
|
-
const gapS = opts?.gap ?? BIG_GAP_S;
|
|
456
|
-
const parsed = JSON.parse(readFileSync2(transcriptPath2, "utf-8"));
|
|
457
|
-
const words = parsed.words ?? [];
|
|
458
|
-
const real = words.filter(
|
|
459
|
-
(w) => (w.type === "word" || w.type === "audio_event") && w.start !== null && w.start !== void 0
|
|
460
|
-
);
|
|
461
|
-
const out = [];
|
|
462
|
-
const print = (line = "") => {
|
|
463
|
-
out.push(line + "\n");
|
|
464
|
-
};
|
|
465
|
-
print(`## ${basename2(transcriptPath2)} (${real.length} tokens, offset +${fmtG(offset)})`);
|
|
466
|
-
const longs = real.filter((w) => w.type === "word" && dur(w) > longTokenS);
|
|
467
|
-
print("\n[A] OVER-LONG TOKENS (likely a collapsed retake \u2014 re-transcribe this window):");
|
|
468
|
-
if (longs.length > 0) {
|
|
469
|
-
for (const w of longs) {
|
|
470
|
-
const start = w.start + offset;
|
|
471
|
-
const end = w.end + offset;
|
|
472
|
-
print(
|
|
473
|
-
` ${pySpacePad(start, 7, 2)}-${pySpacePad(end, 7, 2)} (${pySpacePad(dur(w), 4, 1)}s) '${(w.text ?? "").trim()}'`
|
|
474
|
-
);
|
|
475
|
-
}
|
|
476
|
-
} else {
|
|
477
|
-
print(" (none)");
|
|
478
|
-
}
|
|
479
|
-
const toks = [];
|
|
480
|
-
for (const w of real) {
|
|
481
|
-
if (w.type === "word") {
|
|
482
|
-
const n = norm(w.text);
|
|
483
|
-
if (n) toks.push([n, w.start + offset]);
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
print("\n[B] REPEATED PHRASES (retake fingerprint \u2014 judge vs intentional repeats):");
|
|
487
|
-
let coveredUntil = -1;
|
|
488
|
-
let anyB = false;
|
|
489
|
-
for (let i = 0; i < toks.length; i++) {
|
|
490
|
-
if (i <= coveredUntil) continue;
|
|
491
|
-
const iTime = toks[i][1];
|
|
492
|
-
let best = null;
|
|
493
|
-
for (const k of [4, 3, 2]) {
|
|
494
|
-
const gram = toks.slice(i, i + k).map((t) => t[0]);
|
|
495
|
-
if (gram.length < k) continue;
|
|
496
|
-
const jEnd = Math.min(i + k + REPEAT_WINDOW, toks.length - k + 1);
|
|
497
|
-
for (let j = i + k; j < jEnd; j++) {
|
|
498
|
-
const cand = toks.slice(j, j + k).map((t) => t[0]);
|
|
499
|
-
if (cand.length === gram.length && cand.every((v, idx) => v === gram[idx])) {
|
|
500
|
-
best = [k, toks[j][1], gram];
|
|
501
|
-
break;
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
if (best) break;
|
|
505
|
-
}
|
|
506
|
-
if (best) {
|
|
507
|
-
const [k, jTime, gram] = best;
|
|
508
|
-
anyB = true;
|
|
509
|
-
coveredUntil = i + k - 1;
|
|
510
|
-
print(` ${pySpacePad(iTime, 7, 2)}s & ${pySpacePad(jTime, 7, 2)}s : "${gram.join(" ")}"`);
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
if (!anyB) print(" (none)");
|
|
514
|
-
print("\n[C] AUDIO EVENTS / BIG GAPS:");
|
|
515
|
-
let anyC = false;
|
|
516
|
-
for (const w of real) {
|
|
517
|
-
if (w.type === "audio_event") {
|
|
518
|
-
anyC = true;
|
|
519
|
-
const start = w.start + offset;
|
|
520
|
-
print(` ${pySpacePad(start, 7, 2)}s audio-event '${(w.text ?? "").trim()}'`);
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
let prevEnd = null;
|
|
524
|
-
for (const w of real) {
|
|
525
|
-
const start = w.start;
|
|
526
|
-
if (prevEnd !== null && start - prevEnd > gapS) {
|
|
527
|
-
anyC = true;
|
|
528
|
-
print(
|
|
529
|
-
` ${pySpacePad(prevEnd + offset, 7, 2)}->${pySpacePad(start + offset, 7, 2)} GAP ${pySpacePad(start - prevEnd, 4, 1)}s (untokenized)`
|
|
530
|
-
);
|
|
531
|
-
}
|
|
532
|
-
prevEnd = "end" in w && w.end !== void 0 ? w.end : start;
|
|
533
|
-
}
|
|
534
|
-
if (!anyC) print(" (none)");
|
|
535
|
-
print();
|
|
536
|
-
return out.join("");
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// src/pack-transcripts.ts
|
|
540
|
-
import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, readdirSync } from "fs";
|
|
541
|
-
import { join as join4, basename as basename3, extname as extname2 } from "path";
|
|
542
|
-
function formatTime(seconds) {
|
|
543
|
-
return pyFixedPad(seconds, 6, 2);
|
|
544
|
-
}
|
|
545
|
-
function formatDuration(seconds) {
|
|
546
|
-
if (seconds < 60) {
|
|
547
|
-
return `${pyFixed(seconds, 1)}s`;
|
|
548
|
-
}
|
|
549
|
-
const m = Math.floor(seconds / 60);
|
|
550
|
-
const s = seconds - m * 60;
|
|
551
|
-
return `${m}m ${pyFixedPad(s, 4, 1)}s`;
|
|
552
|
-
}
|
|
553
|
-
function groupIntoPhrases(words, silenceThreshold = 0.5) {
|
|
554
|
-
const phrases = [];
|
|
555
|
-
let currentWords = [];
|
|
556
|
-
let currentStart = null;
|
|
557
|
-
let currentSpeaker = null;
|
|
558
|
-
function flush() {
|
|
559
|
-
if (currentWords.length === 0) {
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
const textParts = [];
|
|
563
|
-
for (const w of currentWords) {
|
|
564
|
-
const t = w.type ?? "word";
|
|
565
|
-
let raw = (w.text ?? "").trim();
|
|
566
|
-
if (!raw) {
|
|
567
|
-
continue;
|
|
568
|
-
}
|
|
569
|
-
if (t === "audio_event") {
|
|
570
|
-
if (!raw.startsWith("(")) {
|
|
571
|
-
raw = `(${raw})`;
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
textParts.push(raw);
|
|
575
|
-
}
|
|
576
|
-
if (textParts.length === 0) {
|
|
577
|
-
currentWords = [];
|
|
578
|
-
currentStart = null;
|
|
579
|
-
currentSpeaker = null;
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
let text = textParts.join(" ");
|
|
583
|
-
text = text.replaceAll(" ,", ",").replaceAll(" .", ".").replaceAll(" ?", "?").replaceAll(" !", "!");
|
|
584
|
-
const last = currentWords[currentWords.length - 1];
|
|
585
|
-
const endTime = "end" in last && last.end !== void 0 ? last.end : "start" in last && last.start !== void 0 ? last.start : currentStart ?? 0;
|
|
586
|
-
phrases.push({
|
|
587
|
-
start: currentStart,
|
|
588
|
-
end: endTime,
|
|
589
|
-
text,
|
|
590
|
-
speaker_id: currentSpeaker
|
|
591
|
-
});
|
|
592
|
-
currentWords = [];
|
|
593
|
-
currentStart = null;
|
|
594
|
-
currentSpeaker = null;
|
|
595
|
-
}
|
|
596
|
-
let prevEnd = null;
|
|
597
|
-
for (const w of words) {
|
|
598
|
-
const t = w.type ?? "word";
|
|
599
|
-
if (t === "spacing") {
|
|
600
|
-
const start2 = w.start;
|
|
601
|
-
const end = w.end;
|
|
602
|
-
if (start2 !== null && start2 !== void 0 && end !== null && end !== void 0) {
|
|
603
|
-
const gap = end - start2;
|
|
604
|
-
if (gap >= silenceThreshold) {
|
|
605
|
-
flush();
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
continue;
|
|
609
|
-
}
|
|
610
|
-
const start = w.start;
|
|
611
|
-
if (start === null || start === void 0) {
|
|
612
|
-
continue;
|
|
613
|
-
}
|
|
614
|
-
const speaker = w.speaker_id;
|
|
615
|
-
if (currentSpeaker !== null && currentSpeaker !== void 0 && speaker !== null && speaker !== void 0 && speaker !== currentSpeaker) {
|
|
616
|
-
flush();
|
|
617
|
-
}
|
|
618
|
-
if (prevEnd !== null && start - prevEnd >= silenceThreshold) {
|
|
619
|
-
flush();
|
|
620
|
-
}
|
|
621
|
-
if (currentStart === null) {
|
|
622
|
-
currentStart = start;
|
|
623
|
-
currentSpeaker = speaker;
|
|
624
|
-
}
|
|
625
|
-
currentWords.push(w);
|
|
626
|
-
prevEnd = "end" in w && w.end !== void 0 && w.end !== null ? w.end : start;
|
|
627
|
-
}
|
|
628
|
-
flush();
|
|
629
|
-
return phrases;
|
|
630
|
-
}
|
|
631
|
-
function packOneFile(jsonPath, silenceThreshold) {
|
|
632
|
-
const data = JSON.parse(readFileSync3(jsonPath, "utf-8"));
|
|
633
|
-
const words = data.words ?? [];
|
|
634
|
-
const phrases = groupIntoPhrases(words, silenceThreshold);
|
|
635
|
-
let duration;
|
|
636
|
-
if (phrases.length > 0) {
|
|
637
|
-
duration = (phrases[phrases.length - 1].end ?? 0) - (phrases[0].start ?? 0);
|
|
638
|
-
} else {
|
|
639
|
-
duration = 0;
|
|
640
|
-
}
|
|
641
|
-
const stem = basename3(jsonPath, extname2(jsonPath));
|
|
642
|
-
return { name: stem, duration, phrases };
|
|
643
|
-
}
|
|
644
|
-
function renderMarkdown(entries, silenceThreshold) {
|
|
645
|
-
const lines = [];
|
|
646
|
-
lines.push("# Packed transcripts");
|
|
647
|
-
lines.push("");
|
|
648
|
-
lines.push(`Phrase-level, grouped on silences \u2265 ${pyFixed(silenceThreshold, 1)}s or speaker change.`);
|
|
649
|
-
lines.push("Use `[start-end]` ranges to address cuts in the EDL.");
|
|
650
|
-
lines.push("");
|
|
651
|
-
for (const { name, duration, phrases } of entries) {
|
|
652
|
-
lines.push(`## ${name} (duration: ${formatDuration(duration)}, ${phrases.length} phrases)`);
|
|
653
|
-
if (phrases.length === 0) {
|
|
654
|
-
lines.push(" _no speech detected_");
|
|
655
|
-
lines.push("");
|
|
656
|
-
continue;
|
|
657
|
-
}
|
|
658
|
-
for (const p of phrases) {
|
|
659
|
-
const spk = p.speaker_id;
|
|
660
|
-
let spkTag;
|
|
661
|
-
if (spk !== null && spk !== void 0) {
|
|
662
|
-
let spkStr = String(spk);
|
|
663
|
-
if (spkStr.startsWith("speaker_")) {
|
|
664
|
-
spkStr = spkStr.slice("speaker_".length);
|
|
665
|
-
}
|
|
666
|
-
spkTag = ` S${spkStr}`;
|
|
667
|
-
} else {
|
|
668
|
-
spkTag = "";
|
|
669
|
-
}
|
|
670
|
-
lines.push(` [${formatTime(p.start ?? 0)}-${formatTime(p.end ?? 0)}]${spkTag} ${p.text}`);
|
|
671
|
-
}
|
|
672
|
-
lines.push("");
|
|
673
|
-
}
|
|
674
|
-
return lines.join("\n");
|
|
675
|
-
}
|
|
676
|
-
function packTranscripts(editDir, opts = {}) {
|
|
677
|
-
const silenceThreshold = opts.silenceThreshold ?? 0.5;
|
|
678
|
-
const transcriptsDir = join4(editDir, "transcripts");
|
|
679
|
-
let entries;
|
|
680
|
-
try {
|
|
681
|
-
entries = readdirSync(transcriptsDir);
|
|
682
|
-
} catch {
|
|
683
|
-
throw new Error(`no transcripts directory at ${transcriptsDir}`);
|
|
684
|
-
}
|
|
685
|
-
const jsonFiles = entries.filter((f) => f.endsWith(".json")).map((f) => join4(transcriptsDir, f)).sort();
|
|
686
|
-
if (jsonFiles.length === 0) {
|
|
687
|
-
throw new Error(`no .json files in ${transcriptsDir}`);
|
|
688
|
-
}
|
|
689
|
-
const packed = jsonFiles.map((p) => packOneFile(p, silenceThreshold));
|
|
690
|
-
const markdown = renderMarkdown(packed, silenceThreshold);
|
|
691
|
-
const outPath = opts.output ?? join4(editDir, "takes_packed.md");
|
|
692
|
-
writeFileSync3(outPath, markdown, { encoding: "utf-8" });
|
|
693
|
-
return { path: outPath, content: markdown };
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// src/grade.ts
|
|
697
|
-
import { mkdtempSync as mkdtempSync2, readFileSync as readFileSync4, rmSync as rmSync2 } from "fs";
|
|
698
|
-
import { tmpdir as tmpdir2 } from "os";
|
|
699
|
-
import { join as join5 } from "path";
|
|
700
|
-
var PRESETS = {
|
|
701
|
-
// Subtle baseline — barely perceptible cleanup. No color shift.
|
|
702
|
-
// Use when auto-analysis isn't available or when you want a safe floor.
|
|
703
|
-
subtle: "eq=contrast=1.03:saturation=0.98",
|
|
704
|
-
// Minimal corrective grade: light contrast + subtle S-curve, no color shifts.
|
|
705
|
-
neutral_punch: "eq=contrast=1.06:brightness=0.0:saturation=1.0,curves=master='0/0 0.25/0.23 0.75/0.77 1/1'",
|
|
706
|
-
// OPT-IN creative preset for retro/cinematic looks ONLY. Not a default.
|
|
707
|
-
// +12% contrast, crushed blacks, -12% sat, warm shadows + cool highs, filmic curve.
|
|
708
|
-
// Originally from HEURISTICS §6 — too aggressive for standard launch content.
|
|
709
|
-
warm_cinematic: "eq=contrast=1.12:brightness=-0.02:saturation=0.88,colorbalance=rs=0.02:gs=0.0:bs=-0.03:rm=0.04:gm=0.01:bm=-0.02:rh=0.08:gh=0.02:bh=-0.05,curves=master='0/0 0.25/0.22 0.75/0.78 1/1'",
|
|
710
|
-
// Flat — no grade. Useful as a sentinel for "skip grading this source".
|
|
711
|
-
none: ""
|
|
712
|
-
};
|
|
713
|
-
function getPreset(name) {
|
|
714
|
-
if (!(name in PRESETS)) {
|
|
715
|
-
const available = Object.keys(PRESETS).sort().join(", ");
|
|
716
|
-
throw new Error(`unknown preset '${name}'. Available: ${available}`);
|
|
717
|
-
}
|
|
718
|
-
return PRESETS[name];
|
|
719
|
-
}
|
|
720
|
-
function decideGradeFilter(stats) {
|
|
721
|
-
const { y_mean, y_range, sat_mean } = stats;
|
|
722
|
-
let contrast_adj = 1;
|
|
723
|
-
if (y_range < 0.65) {
|
|
724
|
-
const t = Math.max(0, Math.min(1, (y_range - 0.5) / 0.15));
|
|
725
|
-
contrast_adj = 1.08 - 0.05 * t;
|
|
726
|
-
} else {
|
|
727
|
-
contrast_adj = 1.03;
|
|
728
|
-
}
|
|
729
|
-
let gamma_adj = 1;
|
|
730
|
-
if (y_mean < 0.42) {
|
|
731
|
-
const t = Math.max(0, Math.min(1, (y_mean - 0.3) / 0.12));
|
|
732
|
-
gamma_adj = 1.1 - 0.08 * t;
|
|
733
|
-
} else if (y_mean > 0.6) {
|
|
734
|
-
gamma_adj = 0.97;
|
|
735
|
-
}
|
|
736
|
-
let sat_adj = 0.98;
|
|
737
|
-
if (sat_mean < 0.18) {
|
|
738
|
-
sat_adj = 1.04;
|
|
739
|
-
} else if (sat_mean > 0.38) {
|
|
740
|
-
sat_adj = 0.96;
|
|
741
|
-
}
|
|
742
|
-
contrast_adj = Math.max(0.94, Math.min(1.08, contrast_adj));
|
|
743
|
-
gamma_adj = Math.max(0.94, Math.min(1.1, gamma_adj));
|
|
744
|
-
sat_adj = Math.max(0.94, Math.min(1.06, sat_adj));
|
|
745
|
-
const eq_parts = [];
|
|
746
|
-
if (Math.abs(contrast_adj - 1) > 5e-3) {
|
|
747
|
-
eq_parts.push(`contrast=${pyFixed(contrast_adj, 3)}`);
|
|
748
|
-
}
|
|
749
|
-
if (Math.abs(gamma_adj - 1) > 5e-3) {
|
|
750
|
-
eq_parts.push(`gamma=${pyFixed(gamma_adj, 3)}`);
|
|
751
|
-
}
|
|
752
|
-
if (Math.abs(sat_adj - 1) > 5e-3) {
|
|
753
|
-
eq_parts.push(`saturation=${pyFixed(sat_adj, 3)}`);
|
|
754
|
-
}
|
|
755
|
-
if (eq_parts.length === 0) return "";
|
|
756
|
-
return "eq=" + eq_parts.join(":");
|
|
757
|
-
}
|
|
758
|
-
function parseValue(line) {
|
|
759
|
-
const idx = line.lastIndexOf("=");
|
|
760
|
-
if (idx === -1) return null;
|
|
761
|
-
const raw = line.slice(idx + 1);
|
|
762
|
-
const v = Number.parseFloat(raw);
|
|
763
|
-
if (Number.isNaN(v)) return null;
|
|
764
|
-
return v;
|
|
765
|
-
}
|
|
766
|
-
async function sampleFrameStats(video, start, duration, nSamples = 10) {
|
|
767
|
-
const fps = Math.max(0.5, Math.min(nSamples / Math.max(duration, 0.1), 10));
|
|
768
|
-
const dir = mkdtempSync2(join5(tmpdir2(), "grade-"));
|
|
769
|
-
const metadataPath = join5(dir, "signalstats.txt");
|
|
770
|
-
try {
|
|
771
|
-
const cmd = [
|
|
772
|
-
"ffmpeg",
|
|
773
|
-
"-y",
|
|
774
|
-
"-hide_banner",
|
|
775
|
-
"-nostats",
|
|
776
|
-
"-ss",
|
|
777
|
-
pyFixed(start, 3),
|
|
778
|
-
"-i",
|
|
779
|
-
video,
|
|
780
|
-
"-t",
|
|
781
|
-
pyFixed(duration, 3),
|
|
782
|
-
"-vf",
|
|
783
|
-
`fps=${fps.toFixed(2)},signalstats,metadata=print:file=${metadataPath}`,
|
|
784
|
-
"-f",
|
|
785
|
-
"null",
|
|
786
|
-
"-"
|
|
787
|
-
];
|
|
788
|
-
await run(cmd, { capture: true, check: true, echo: false });
|
|
789
|
-
const y_avgs = [];
|
|
790
|
-
const y_mins = [];
|
|
791
|
-
const y_maxs = [];
|
|
792
|
-
const sat_avgs = [];
|
|
793
|
-
let bit_depth = 8;
|
|
794
|
-
let text = "";
|
|
795
|
-
try {
|
|
796
|
-
text = readFileSync4(metadataPath, "utf8");
|
|
797
|
-
} catch {
|
|
798
|
-
text = "";
|
|
799
|
-
}
|
|
800
|
-
for (const rawLine of text.split("\n")) {
|
|
801
|
-
const line = rawLine.trim();
|
|
802
|
-
if (line.includes("lavfi.signalstats.YBITDEPTH")) {
|
|
803
|
-
const v = parseValue(line);
|
|
804
|
-
if (v !== null) bit_depth = Math.trunc(v);
|
|
805
|
-
} else if (line.includes("lavfi.signalstats.YAVG")) {
|
|
806
|
-
const v = parseValue(line);
|
|
807
|
-
if (v !== null) y_avgs.push(v);
|
|
808
|
-
} else if (line.includes("lavfi.signalstats.YMIN")) {
|
|
809
|
-
const v = parseValue(line);
|
|
810
|
-
if (v !== null) y_mins.push(v);
|
|
811
|
-
} else if (line.includes("lavfi.signalstats.YMAX")) {
|
|
812
|
-
const v = parseValue(line);
|
|
813
|
-
if (v !== null) y_maxs.push(v);
|
|
814
|
-
} else if (line.includes("lavfi.signalstats.SATAVG")) {
|
|
815
|
-
const v = parseValue(line);
|
|
816
|
-
if (v !== null) sat_avgs.push(v);
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
if (y_avgs.length === 0) {
|
|
820
|
-
return { y_mean: 0.5, y_range: 0.72, sat_mean: 0.25 };
|
|
821
|
-
}
|
|
822
|
-
const max_val = Math.pow(2, bit_depth) - 1;
|
|
823
|
-
const mean = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
824
|
-
const y_mean = mean(y_avgs) / max_val;
|
|
825
|
-
const y_range = y_maxs.length > 0 && y_mins.length > 0 ? (mean(y_maxs) - mean(y_mins)) / max_val : 0.7;
|
|
826
|
-
const sat_mean = sat_avgs.length > 0 ? mean(sat_avgs) / max_val : 0.25;
|
|
827
|
-
return { y_mean, y_range, sat_mean };
|
|
828
|
-
} finally {
|
|
829
|
-
rmSync2(dir, { recursive: true, force: true });
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
async function autoGradeForClip(input) {
|
|
833
|
-
let duration = 10;
|
|
834
|
-
try {
|
|
835
|
-
const res = await run(
|
|
836
|
-
[
|
|
837
|
-
"ffprobe",
|
|
838
|
-
"-v",
|
|
839
|
-
"error",
|
|
840
|
-
"-show_entries",
|
|
841
|
-
"format=duration",
|
|
842
|
-
"-of",
|
|
843
|
-
"default=noprint_wrappers=1:nokey=1",
|
|
844
|
-
input
|
|
845
|
-
],
|
|
846
|
-
{ capture: true, check: true, echo: false }
|
|
847
|
-
);
|
|
848
|
-
const parsed = Number.parseFloat(res.stdout.trim());
|
|
849
|
-
if (!Number.isNaN(parsed)) duration = parsed;
|
|
850
|
-
} catch {
|
|
851
|
-
duration = 10;
|
|
852
|
-
}
|
|
853
|
-
const stats = await sampleFrameStats(input, 0, duration);
|
|
854
|
-
return decideGradeFilter(stats);
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
// src/render.ts
|
|
858
|
-
import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, rmSync as rmSync3, statSync as statSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
859
|
-
import { dirname, isAbsolute, resolve } from "path";
|
|
860
|
-
var SUB_FORCE_STYLE = "FontName=Helvetica,FontSize=18,Bold=1,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BackColour=&H00000000,BorderStyle=1,Outline=2,Shadow=0,Alignment=2,MarginV=90";
|
|
861
|
-
var HDR_TRANSFERS = /* @__PURE__ */ new Set(["smpte2084", "arib-std-b67"]);
|
|
862
|
-
var TONEMAP_CHAIN = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p";
|
|
863
|
-
var LOUDNORM_I_STR = "-14.0";
|
|
864
|
-
var LOUDNORM_TP_STR = "-1.0";
|
|
865
|
-
var LOUDNORM_LRA_STR = "11.0";
|
|
866
|
-
var PUNCT_BREAK = /* @__PURE__ */ new Set([".", ",", "!", "?", ";", ":"]);
|
|
867
|
-
function f6floor(x) {
|
|
868
|
-
return (Math.floor(x * 1e6 + 1e-3) / 1e6).toFixed(6);
|
|
869
|
-
}
|
|
870
|
-
function f3(x) {
|
|
871
|
-
return pyFixed(x, 3);
|
|
872
|
-
}
|
|
873
|
-
function pyFloatStr(x) {
|
|
874
|
-
if (Number.isInteger(x)) return `${x}.0`;
|
|
875
|
-
return String(x);
|
|
876
|
-
}
|
|
877
|
-
function resolvePath(maybePath, base) {
|
|
878
|
-
if (isAbsolute(maybePath)) return maybePath;
|
|
879
|
-
return resolve(base, maybePath);
|
|
880
|
-
}
|
|
881
|
-
function resolveGradeFilter(gradeField) {
|
|
882
|
-
if (!gradeField) return "";
|
|
883
|
-
if (gradeField === "auto") return "__AUTO__";
|
|
884
|
-
if (/^[a-zA-Z0-9_-]+$/.test(gradeField)) {
|
|
885
|
-
try {
|
|
886
|
-
return getPreset(gradeField);
|
|
887
|
-
} catch {
|
|
888
|
-
process.stderr.write(`warning: unknown preset '${gradeField}', using as raw filter
|
|
889
|
-
`);
|
|
890
|
-
return gradeField;
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
return gradeField;
|
|
894
|
-
}
|
|
895
|
-
function afadeFilter(durationS) {
|
|
896
|
-
const fadeOutStart = Math.max(0, durationS - 0.03);
|
|
897
|
-
return `afade=t=in:st=0:d=0.03,afade=t=out:st=${f3(fadeOutStart)}:d=0.03`;
|
|
898
|
-
}
|
|
899
|
-
function modePresetCrf(opts) {
|
|
900
|
-
if (opts.draft) return { preset: "ultrafast", crf: "28" };
|
|
901
|
-
if (opts.preview) return { preset: "medium", crf: "22" };
|
|
902
|
-
return { preset: "fast", crf: "20" };
|
|
903
|
-
}
|
|
904
|
-
function scaleFilter(opts) {
|
|
905
|
-
if (opts.draft) {
|
|
906
|
-
return opts.portrait ? "scale=-2:1280" : "scale=1280:-2";
|
|
907
|
-
}
|
|
908
|
-
return opts.portrait ? "scale=-2:1920" : "scale=1920:-2";
|
|
909
|
-
}
|
|
910
|
-
function segmentVf(gradeFilter, opts) {
|
|
911
|
-
const parts = [];
|
|
912
|
-
if (opts.hdr) parts.push(TONEMAP_CHAIN);
|
|
913
|
-
parts.push(scaleFilter(opts));
|
|
914
|
-
if (gradeFilter) parts.push(gradeFilter);
|
|
915
|
-
return parts.join(",");
|
|
916
|
-
}
|
|
917
|
-
function segmentExtractArgs(source, segStart, duration, gradeFilter, outPath, opts = {}) {
|
|
918
|
-
const vf = segmentVf(gradeFilter, opts);
|
|
919
|
-
const af = afadeFilter(duration);
|
|
920
|
-
const { preset, crf } = modePresetCrf(opts);
|
|
921
|
-
return [
|
|
922
|
-
"ffmpeg",
|
|
923
|
-
"-y",
|
|
924
|
-
"-ss",
|
|
925
|
-
f3(segStart),
|
|
926
|
-
"-i",
|
|
927
|
-
source,
|
|
928
|
-
// -t is truncated, never rounded: a frame-grid duration like 20/30 rounds
|
|
929
|
-
// to "0.667", which lies past frame 20's timestamp, so a rounded -t leaks
|
|
930
|
-
// one extra video frame while audio stops at -t exactly — and that
|
|
931
|
-
// one-frame surplus compounds across segments into audible A/V drift.
|
|
932
|
-
// (Capping video with -frames:v instead is worse: once the video stream
|
|
933
|
-
// completes, ffmpeg stops consuming input and truncates the audio at the
|
|
934
|
-
// demuxer's interleave point, 5-20ms short.)
|
|
935
|
-
"-t",
|
|
936
|
-
f6floor(duration),
|
|
937
|
-
"-vf",
|
|
938
|
-
vf,
|
|
939
|
-
"-af",
|
|
940
|
-
af,
|
|
941
|
-
"-c:v",
|
|
942
|
-
"libx264",
|
|
943
|
-
"-preset",
|
|
944
|
-
preset,
|
|
945
|
-
"-crf",
|
|
946
|
-
crf,
|
|
947
|
-
"-pix_fmt",
|
|
948
|
-
"yuv420p",
|
|
949
|
-
"-r",
|
|
950
|
-
opts.fps ?? "24",
|
|
951
|
-
// PCM audio in the intermediates (segments are .mov): sample-exact -t cuts
|
|
952
|
-
// with no AAC frame quantization, priming, or flush loss — per-segment AAC
|
|
953
|
-
// encoding leaves each segment's audio up to one AAC frame (21ms) short,
|
|
954
|
-
// which the -c copy concat compounds into audible A/V drift. The loudnorm
|
|
955
|
-
// stage performs the single AAC encode for the whole timeline.
|
|
956
|
-
"-c:a",
|
|
957
|
-
"pcm_s16le",
|
|
958
|
-
"-ar",
|
|
959
|
-
"48000",
|
|
960
|
-
"-movflags",
|
|
961
|
-
"+faststart",
|
|
962
|
-
outPath
|
|
963
|
-
];
|
|
964
|
-
}
|
|
965
|
-
function concatListContent(segmentPaths) {
|
|
966
|
-
return segmentPaths.map((p) => `file '${p}'
|
|
967
|
-
`).join("");
|
|
968
|
-
}
|
|
969
|
-
function concatArgs(concatListPath, outPath) {
|
|
970
|
-
return [
|
|
971
|
-
"ffmpeg",
|
|
972
|
-
"-y",
|
|
973
|
-
"-f",
|
|
974
|
-
"concat",
|
|
975
|
-
"-safe",
|
|
976
|
-
"0",
|
|
977
|
-
"-i",
|
|
978
|
-
concatListPath,
|
|
979
|
-
"-c",
|
|
980
|
-
"copy",
|
|
981
|
-
"-movflags",
|
|
982
|
-
"+faststart",
|
|
983
|
-
outPath
|
|
984
|
-
];
|
|
985
|
-
}
|
|
986
|
-
function loudnormPass1Args(inputPath) {
|
|
987
|
-
const filterStr = `loudnorm=I=${LOUDNORM_I_STR}:TP=${LOUDNORM_TP_STR}:LRA=${LOUDNORM_LRA_STR}:print_format=json`;
|
|
988
|
-
return [
|
|
989
|
-
"ffmpeg",
|
|
990
|
-
"-y",
|
|
991
|
-
"-hide_banner",
|
|
992
|
-
"-nostats",
|
|
993
|
-
"-i",
|
|
994
|
-
inputPath,
|
|
995
|
-
"-af",
|
|
996
|
-
filterStr,
|
|
997
|
-
"-vn",
|
|
998
|
-
"-f",
|
|
999
|
-
"null",
|
|
1000
|
-
"-"
|
|
1001
|
-
];
|
|
1002
|
-
}
|
|
1003
|
-
function loudnormPass2Filter(measured) {
|
|
1004
|
-
return `loudnorm=I=${LOUDNORM_I_STR}:TP=${LOUDNORM_TP_STR}:LRA=${LOUDNORM_LRA_STR}:measured_I=${measured.input_i}:measured_TP=${measured.input_tp}:measured_LRA=${measured.input_lra}:measured_thresh=${measured.input_thresh}:offset=${measured.target_offset}:linear=true`;
|
|
1005
|
-
}
|
|
1006
|
-
function loudnormOnePassFilter() {
|
|
1007
|
-
return `loudnorm=I=${LOUDNORM_I_STR}:TP=${LOUDNORM_TP_STR}:LRA=${LOUDNORM_LRA_STR}`;
|
|
1008
|
-
}
|
|
1009
|
-
function loudnormApplyArgs(inputPath, outputPath, filterStr) {
|
|
1010
|
-
return [
|
|
1011
|
-
"ffmpeg",
|
|
1012
|
-
"-y",
|
|
1013
|
-
"-hide_banner",
|
|
1014
|
-
"-nostats",
|
|
1015
|
-
"-i",
|
|
1016
|
-
inputPath,
|
|
1017
|
-
"-c:v",
|
|
1018
|
-
"copy",
|
|
1019
|
-
"-af",
|
|
1020
|
-
filterStr,
|
|
1021
|
-
"-c:a",
|
|
1022
|
-
"aac",
|
|
1023
|
-
"-b:a",
|
|
1024
|
-
"192k",
|
|
1025
|
-
"-ar",
|
|
1026
|
-
"48000",
|
|
1027
|
-
"-movflags",
|
|
1028
|
-
"+faststart",
|
|
1029
|
-
outputPath
|
|
1030
|
-
];
|
|
1031
|
-
}
|
|
1032
|
-
function parseLoudnessJson(stderr) {
|
|
1033
|
-
const start = stderr.lastIndexOf("{");
|
|
1034
|
-
const end = stderr.lastIndexOf("}");
|
|
1035
|
-
if (start === -1 || end === -1 || end <= start) return null;
|
|
1036
|
-
let data;
|
|
1037
|
-
try {
|
|
1038
|
-
data = JSON.parse(stderr.slice(start, end + 1));
|
|
1039
|
-
} catch {
|
|
1040
|
-
return null;
|
|
1041
|
-
}
|
|
1042
|
-
const needed = ["input_i", "input_tp", "input_lra", "input_thresh", "target_offset"];
|
|
1043
|
-
for (const k of needed) {
|
|
1044
|
-
if (!(k in data)) return null;
|
|
1045
|
-
}
|
|
1046
|
-
return data;
|
|
1047
|
-
}
|
|
1048
|
-
function subtitleFilter(srtPath) {
|
|
1049
|
-
const subsAbs = srtPath.replace(/:/g, "\\:").replace(/'/g, "\\'");
|
|
1050
|
-
return `subtitles='${subsAbs}':force_style='${SUB_FORCE_STYLE}'`;
|
|
1051
|
-
}
|
|
1052
|
-
function srtTimestamp(seconds) {
|
|
1053
|
-
const totalMs = pyRound(seconds * 1e3, 0);
|
|
1054
|
-
let rem = totalMs;
|
|
1055
|
-
const h = Math.floor(rem / 36e5);
|
|
1056
|
-
rem = rem % 36e5;
|
|
1057
|
-
const m = Math.floor(rem / 6e4);
|
|
1058
|
-
rem = rem % 6e4;
|
|
1059
|
-
const s = Math.floor(rem / 1e3);
|
|
1060
|
-
const ms = rem % 1e3;
|
|
1061
|
-
const pad = (n, w) => String(n).padStart(w, "0");
|
|
1062
|
-
return `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)},${pad(ms, 3)}`;
|
|
1063
|
-
}
|
|
1064
|
-
function wordsInRange(transcript, tStart, tEnd) {
|
|
1065
|
-
const out = [];
|
|
1066
|
-
for (const w of transcript.words ?? []) {
|
|
1067
|
-
if (w.type !== "word") continue;
|
|
1068
|
-
const ws = w.start;
|
|
1069
|
-
const we = w.end;
|
|
1070
|
-
if (ws === null || ws === void 0 || we === null || we === void 0) continue;
|
|
1071
|
-
if (we <= tStart || ws >= tEnd) continue;
|
|
1072
|
-
out.push(w);
|
|
1073
|
-
}
|
|
1074
|
-
return out;
|
|
1075
|
-
}
|
|
1076
|
-
function buildSrt(ranges, transcripts) {
|
|
1077
|
-
const entries = [];
|
|
1078
|
-
let segOffset = 0;
|
|
1079
|
-
for (const r of ranges) {
|
|
1080
|
-
const srcName = r.source;
|
|
1081
|
-
const segStart = Number(r.start);
|
|
1082
|
-
const segEnd = Number(r.end);
|
|
1083
|
-
const segDuration = segEnd - segStart;
|
|
1084
|
-
const transcript = transcripts[srcName];
|
|
1085
|
-
if (!transcript) {
|
|
1086
|
-
segOffset += segDuration;
|
|
1087
|
-
continue;
|
|
1088
|
-
}
|
|
1089
|
-
const wordsInSeg = wordsInRange(transcript, segStart, segEnd);
|
|
1090
|
-
const chunks = [];
|
|
1091
|
-
let current = [];
|
|
1092
|
-
for (const w of wordsInSeg) {
|
|
1093
|
-
const text = (w.text ?? "").trim();
|
|
1094
|
-
if (!text) continue;
|
|
1095
|
-
current.push(w);
|
|
1096
|
-
const endsInPunct = text.length > 0 && PUNCT_BREAK.has(text[text.length - 1]);
|
|
1097
|
-
if (current.length >= 2 || endsInPunct) {
|
|
1098
|
-
chunks.push(current);
|
|
1099
|
-
current = [];
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
if (current.length > 0) chunks.push(current);
|
|
1103
|
-
for (const chunk of chunks) {
|
|
1104
|
-
const first = chunk[0];
|
|
1105
|
-
const last = chunk[chunk.length - 1];
|
|
1106
|
-
const firstStart = first.start ?? segStart;
|
|
1107
|
-
const lastEnd = last.end ?? segEnd;
|
|
1108
|
-
const localStart = Math.max(segStart, firstStart);
|
|
1109
|
-
const localEnd = Math.min(segEnd, lastEnd);
|
|
1110
|
-
const outStart = Math.max(0, localStart - segStart) + segOffset;
|
|
1111
|
-
let outEnd = Math.max(0, localEnd - segStart) + segOffset;
|
|
1112
|
-
if (outEnd <= outStart) outEnd = outStart + 0.4;
|
|
1113
|
-
let text = chunk.map((w) => (w.text ?? "").trim()).join(" ");
|
|
1114
|
-
text = text.replace(/\s+/g, " ").trim();
|
|
1115
|
-
text = text.replace(/[,;:]+$/, "");
|
|
1116
|
-
text = text.toUpperCase();
|
|
1117
|
-
entries.push([outStart, outEnd, text]);
|
|
1118
|
-
}
|
|
1119
|
-
segOffset += segDuration;
|
|
1120
|
-
}
|
|
1121
|
-
const indexed = entries.map((e, i) => ({ e, i }));
|
|
1122
|
-
indexed.sort((a, b) => a.e[0] - b.e[0] || a.i - b.i);
|
|
1123
|
-
const sorted = indexed.map((x) => x.e);
|
|
1124
|
-
const lines = [];
|
|
1125
|
-
let n = 0;
|
|
1126
|
-
for (const [a, b, t] of sorted) {
|
|
1127
|
-
n += 1;
|
|
1128
|
-
lines.push(String(n));
|
|
1129
|
-
lines.push(`${srtTimestamp(a)} --> ${srtTimestamp(b)}`);
|
|
1130
|
-
lines.push(t);
|
|
1131
|
-
lines.push("");
|
|
1132
|
-
}
|
|
1133
|
-
return lines.join("\n");
|
|
1134
|
-
}
|
|
1135
|
-
function compositeFilterComplex(overlays, subtitlesPath) {
|
|
1136
|
-
const hasOverlays = overlays.length > 0;
|
|
1137
|
-
const hasSubs = subtitlesPath !== null;
|
|
1138
|
-
const filterParts = [];
|
|
1139
|
-
for (let i = 0; i < overlays.length; i++) {
|
|
1140
|
-
const idx = i + 1;
|
|
1141
|
-
const t = Number(overlays[i].start_in_output);
|
|
1142
|
-
filterParts.push(`[${idx}:v]setpts=PTS-STARTPTS+${pyFloatStr(t)}/TB[a${idx}]`);
|
|
1143
|
-
}
|
|
1144
|
-
let current = "[0:v]";
|
|
1145
|
-
for (let i = 0; i < overlays.length; i++) {
|
|
1146
|
-
const idx = i + 1;
|
|
1147
|
-
const t = Number(overlays[i].start_in_output);
|
|
1148
|
-
const dur2 = Number(overlays[i].duration);
|
|
1149
|
-
const end = t + dur2;
|
|
1150
|
-
const nextLabel = `[v${idx}]`;
|
|
1151
|
-
filterParts.push(`${current}[a${idx}]overlay=enable='between(t,${f3(t)},${f3(end)})'${nextLabel}`);
|
|
1152
|
-
current = nextLabel;
|
|
1153
|
-
}
|
|
1154
|
-
let outLabel;
|
|
1155
|
-
if (hasSubs) {
|
|
1156
|
-
filterParts.push(`${current}${subtitleFilter(subtitlesPath)}[outv]`);
|
|
1157
|
-
outLabel = "[outv]";
|
|
1158
|
-
} else if (hasOverlays) {
|
|
1159
|
-
filterParts.push(`${current}null[outv]`);
|
|
1160
|
-
outLabel = "[outv]";
|
|
1161
|
-
} else {
|
|
1162
|
-
outLabel = "[0:v]";
|
|
1163
|
-
}
|
|
1164
|
-
return {
|
|
1165
|
-
filterComplex: filterParts.join(";"),
|
|
1166
|
-
outLabel,
|
|
1167
|
-
hasOverlays,
|
|
1168
|
-
hasSubs
|
|
1169
|
-
};
|
|
1170
|
-
}
|
|
1171
|
-
function compositeArgs(basePath, overlayPaths, filterComplex, outLabel, outPath) {
|
|
1172
|
-
const inputs = ["-i", basePath];
|
|
1173
|
-
for (const p of overlayPaths) inputs.push("-i", p);
|
|
1174
|
-
return [
|
|
1175
|
-
"ffmpeg",
|
|
1176
|
-
"-y",
|
|
1177
|
-
...inputs,
|
|
1178
|
-
"-filter_complex",
|
|
1179
|
-
filterComplex,
|
|
1180
|
-
"-map",
|
|
1181
|
-
outLabel,
|
|
1182
|
-
"-map",
|
|
1183
|
-
"0:a",
|
|
1184
|
-
"-c:v",
|
|
1185
|
-
"libx264",
|
|
1186
|
-
"-preset",
|
|
1187
|
-
"fast",
|
|
1188
|
-
"-crf",
|
|
1189
|
-
"18",
|
|
1190
|
-
"-pix_fmt",
|
|
1191
|
-
"yuv420p",
|
|
1192
|
-
"-c:a",
|
|
1193
|
-
"copy",
|
|
1194
|
-
"-movflags",
|
|
1195
|
-
"+faststart",
|
|
1196
|
-
outPath
|
|
1197
|
-
];
|
|
1198
|
-
}
|
|
1199
|
-
async function isHdrSource(video) {
|
|
1200
|
-
try {
|
|
1201
|
-
const res = await run(
|
|
1202
|
-
[
|
|
1203
|
-
"ffprobe",
|
|
1204
|
-
"-v",
|
|
1205
|
-
"error",
|
|
1206
|
-
"-select_streams",
|
|
1207
|
-
"v:0",
|
|
1208
|
-
"-show_entries",
|
|
1209
|
-
"stream=color_transfer",
|
|
1210
|
-
"-of",
|
|
1211
|
-
"default=noprint_wrappers=1:nokey=1",
|
|
1212
|
-
video
|
|
1213
|
-
],
|
|
1214
|
-
{ capture: true, check: true, echo: false }
|
|
1215
|
-
);
|
|
1216
|
-
return HDR_TRANSFERS.has(res.stdout.trim());
|
|
1217
|
-
} catch {
|
|
1218
|
-
return false;
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
async function isPortraitSource(video) {
|
|
1222
|
-
try {
|
|
1223
|
-
const res = await run(
|
|
1224
|
-
[
|
|
1225
|
-
"ffprobe",
|
|
1226
|
-
"-v",
|
|
1227
|
-
"error",
|
|
1228
|
-
"-select_streams",
|
|
1229
|
-
"v:0",
|
|
1230
|
-
"-show_entries",
|
|
1231
|
-
"stream=width,height",
|
|
1232
|
-
"-of",
|
|
1233
|
-
"csv=p=0",
|
|
1234
|
-
video
|
|
1235
|
-
],
|
|
1236
|
-
{ capture: true, check: true, echo: false }
|
|
1237
|
-
);
|
|
1238
|
-
const parts = res.stdout.trim().split(",");
|
|
1239
|
-
const w = parseInt(parts[0] ?? "", 10);
|
|
1240
|
-
const h = parseInt(parts[1] ?? "", 10);
|
|
1241
|
-
if (!Number.isFinite(w) || !Number.isFinite(h)) return false;
|
|
1242
|
-
return h > w;
|
|
1243
|
-
} catch {
|
|
1244
|
-
return false;
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
|
-
function parseFrameRate(rate) {
|
|
1248
|
-
const m = rate.match(/^(\d+)\/(\d+)$/);
|
|
1249
|
-
if (m) {
|
|
1250
|
-
const num2 = Number(m[1]);
|
|
1251
|
-
const den = Number(m[2]);
|
|
1252
|
-
return num2 > 0 && den > 0 ? num2 / den : null;
|
|
1253
|
-
}
|
|
1254
|
-
const v = Number(rate);
|
|
1255
|
-
return Number.isFinite(v) && v > 0 ? v : null;
|
|
1256
|
-
}
|
|
1257
|
-
async function sourceFrameRate(video) {
|
|
1258
|
-
try {
|
|
1259
|
-
const res = await run(
|
|
1260
|
-
[
|
|
1261
|
-
"ffprobe",
|
|
1262
|
-
"-v",
|
|
1263
|
-
"error",
|
|
1264
|
-
"-select_streams",
|
|
1265
|
-
"v:0",
|
|
1266
|
-
"-show_entries",
|
|
1267
|
-
"stream=r_frame_rate",
|
|
1268
|
-
"-of",
|
|
1269
|
-
"default=noprint_wrappers=1:nokey=1",
|
|
1270
|
-
video
|
|
1271
|
-
],
|
|
1272
|
-
{ capture: true, check: true, echo: false }
|
|
1273
|
-
);
|
|
1274
|
-
const rate = res.stdout.trim().split("\n")[0]?.trim() ?? "";
|
|
1275
|
-
return parseFrameRate(rate) !== null ? rate : null;
|
|
1276
|
-
} catch {
|
|
1277
|
-
return null;
|
|
1278
|
-
}
|
|
1279
|
-
}
|
|
1280
|
-
async function extractSegment(source, segStart, duration, gradeFilter, outPath, preview, draft, fps) {
|
|
1281
|
-
mkdirSync2(dirname(outPath), { recursive: true });
|
|
1282
|
-
const portrait = await isPortraitSource(source);
|
|
1283
|
-
const hdr = await isHdrSource(source);
|
|
1284
|
-
const args = segmentExtractArgs(source, segStart, duration, gradeFilter, outPath, {
|
|
1285
|
-
preview,
|
|
1286
|
-
draft,
|
|
1287
|
-
portrait,
|
|
1288
|
-
hdr,
|
|
1289
|
-
fps: fps ?? void 0
|
|
1290
|
-
});
|
|
1291
|
-
await run(args, { capture: true });
|
|
1292
|
-
}
|
|
1293
|
-
async function extractAllSegments(edl, editDir, preview, draft) {
|
|
1294
|
-
const resolved = resolveGradeFilter(edl.grade);
|
|
1295
|
-
const isAuto = resolved === "__AUTO__";
|
|
1296
|
-
const clipsName = draft ? "clips_draft" : preview ? "clips_preview" : "clips_graded";
|
|
1297
|
-
const clipsDir = resolve(editDir, clipsName);
|
|
1298
|
-
mkdirSync2(clipsDir, { recursive: true });
|
|
1299
|
-
const ranges = edl.ranges;
|
|
1300
|
-
const sources = edl.sources;
|
|
1301
|
-
const segPaths = [];
|
|
1302
|
-
const fpsBySource = {};
|
|
1303
|
-
process.stderr.write(`extracting ${ranges.length} segment(s) -> ${clipsName}/
|
|
1304
|
-
`);
|
|
1305
|
-
for (let i = 0; i < ranges.length; i++) {
|
|
1306
|
-
const r = ranges[i];
|
|
1307
|
-
const srcName = r.source;
|
|
1308
|
-
const srcPath = resolvePath(sources[srcName], editDir);
|
|
1309
|
-
if (!(srcName in fpsBySource)) fpsBySource[srcName] = await sourceFrameRate(srcPath);
|
|
1310
|
-
const fpsRate = fpsBySource[srcName];
|
|
1311
|
-
const fps = fpsRate !== null ? parseFrameRate(fpsRate) : null;
|
|
1312
|
-
let start = Number(r.start);
|
|
1313
|
-
let end = Number(r.end);
|
|
1314
|
-
if (fps !== null) {
|
|
1315
|
-
start = Math.round(start * fps) / fps;
|
|
1316
|
-
end = Math.max(start + 1 / fps, Math.round(end * fps) / fps);
|
|
1317
|
-
}
|
|
1318
|
-
const duration = end - start;
|
|
1319
|
-
const ii = String(i).padStart(2, "0");
|
|
1320
|
-
const outPath = resolve(clipsDir, `seg_${ii}_${srcName}.mov`);
|
|
1321
|
-
let segFilter;
|
|
1322
|
-
if (isAuto) {
|
|
1323
|
-
segFilter = await autoGradeForClip(srcPath);
|
|
1324
|
-
} else {
|
|
1325
|
-
segFilter = resolved;
|
|
1326
|
-
}
|
|
1327
|
-
await extractSegment(srcPath, start, duration, segFilter, outPath, preview, draft, fpsRate);
|
|
1328
|
-
segPaths.push(outPath);
|
|
1329
|
-
}
|
|
1330
|
-
return segPaths;
|
|
1331
|
-
}
|
|
1332
|
-
async function concatSegments(segmentPaths, outPath, editDir) {
|
|
1333
|
-
mkdirSync2(dirname(outPath), { recursive: true });
|
|
1334
|
-
const concatList = resolve(editDir, "_concat.txt");
|
|
1335
|
-
writeFileSync4(concatList, concatListContent(segmentPaths.map((p) => resolve(p))));
|
|
1336
|
-
await run(concatArgs(concatList, outPath), { capture: true });
|
|
1337
|
-
rmSync3(concatList, { force: true });
|
|
1338
|
-
await warnOnAvDrift(outPath);
|
|
1339
|
-
}
|
|
1340
|
-
async function warnOnAvDrift(path) {
|
|
1341
|
-
try {
|
|
1342
|
-
const res = await run(
|
|
1343
|
-
[
|
|
1344
|
-
"ffprobe",
|
|
1345
|
-
"-v",
|
|
1346
|
-
"error",
|
|
1347
|
-
"-show_entries",
|
|
1348
|
-
"stream=codec_type,duration",
|
|
1349
|
-
"-of",
|
|
1350
|
-
"json",
|
|
1351
|
-
path
|
|
1352
|
-
],
|
|
1353
|
-
{ capture: true, check: true, echo: false }
|
|
1354
|
-
);
|
|
1355
|
-
const streams = JSON.parse(res.stdout).streams ?? [];
|
|
1356
|
-
const v = Number(streams.find((s) => s.codec_type === "video")?.duration);
|
|
1357
|
-
const a = Number(streams.find((s) => s.codec_type === "audio")?.duration);
|
|
1358
|
-
if (!Number.isFinite(v) || !Number.isFinite(a)) return;
|
|
1359
|
-
const driftMs = Math.round((v - a) * 1e3);
|
|
1360
|
-
if (Math.abs(driftMs) > 100) {
|
|
1361
|
-
process.stderr.write(
|
|
1362
|
-
` WARNING: A/V stream durations differ by ${driftMs}ms after concat (video ${v.toFixed(3)}s, audio ${a.toFixed(3)}s) \u2014 likely audible desync
|
|
1363
|
-
`
|
|
1364
|
-
);
|
|
1365
|
-
}
|
|
1366
|
-
} catch {
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
1369
|
-
function loadTranscripts(edl, editDir) {
|
|
1370
|
-
const transcriptsDir = resolve(editDir, "transcripts");
|
|
1371
|
-
const out = {};
|
|
1372
|
-
for (const r of edl.ranges) {
|
|
1373
|
-
const srcName = r.source;
|
|
1374
|
-
if (srcName in out) continue;
|
|
1375
|
-
const trPath = resolve(transcriptsDir, `${srcName}.json`);
|
|
1376
|
-
if (!existsSync4(trPath)) {
|
|
1377
|
-
out[srcName] = void 0;
|
|
1378
|
-
continue;
|
|
1379
|
-
}
|
|
1380
|
-
try {
|
|
1381
|
-
out[srcName] = JSON.parse(readFileSync5(trPath, "utf-8"));
|
|
1382
|
-
} catch {
|
|
1383
|
-
out[srcName] = void 0;
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
return out;
|
|
1387
|
-
}
|
|
1388
|
-
async function buildFinalComposite(basePath, overlays, subtitlesPath, outPath, editDir) {
|
|
1389
|
-
const hasOverlays = overlays.length > 0;
|
|
1390
|
-
const hasSubs = subtitlesPath !== null && existsSync4(subtitlesPath);
|
|
1391
|
-
if (!hasOverlays && !hasSubs) {
|
|
1392
|
-
await run(["ffmpeg", "-y", "-i", basePath, "-c", "copy", outPath], { echo: false, capture: true });
|
|
1393
|
-
return;
|
|
1394
|
-
}
|
|
1395
|
-
const overlayPaths = overlays.map((ov) => resolvePath(ov.file, editDir));
|
|
1396
|
-
const { filterComplex, outLabel } = compositeFilterComplex(overlays, hasSubs ? subtitlesPath : null);
|
|
1397
|
-
const args = compositeArgs(basePath, overlayPaths, filterComplex, outLabel, outPath);
|
|
1398
|
-
await run(args, { capture: true });
|
|
1399
|
-
}
|
|
1400
|
-
async function applyLoudnormTwoPass(inputPath, outputPath, preview) {
|
|
1401
|
-
if (preview) {
|
|
1402
|
-
await run(loudnormApplyArgs(inputPath, outputPath, loudnormOnePassFilter()), { capture: true });
|
|
1403
|
-
return true;
|
|
1404
|
-
}
|
|
1405
|
-
const res = await run(loudnormPass1Args(inputPath), { capture: true, check: false, echo: true });
|
|
1406
|
-
const measurement = parseLoudnessJson(res.stderr);
|
|
1407
|
-
if (measurement === null) {
|
|
1408
|
-
process.stderr.write(" loudnorm measurement failed \u2014 falling back to 1-pass\n");
|
|
1409
|
-
return applyLoudnormTwoPass(inputPath, outputPath, true);
|
|
1410
|
-
}
|
|
1411
|
-
const filterStr = loudnormPass2Filter(measurement);
|
|
1412
|
-
await run(loudnormApplyArgs(inputPath, outputPath, filterStr), { capture: true });
|
|
1413
|
-
return true;
|
|
1414
|
-
}
|
|
1415
|
-
async function render(edlPath2, output, opts = {}) {
|
|
1416
|
-
const preview = opts.preview ?? false;
|
|
1417
|
-
const draft = opts.draft ?? false;
|
|
1418
|
-
const buildSubtitles = opts.buildSubtitles ?? false;
|
|
1419
|
-
const noSubtitles = opts.noSubtitles ?? false;
|
|
1420
|
-
const noLoudnorm = opts.noLoudnorm ?? false;
|
|
1421
|
-
const resolvedEdlPath = resolve(edlPath2);
|
|
1422
|
-
if (!existsSync4(resolvedEdlPath)) {
|
|
1423
|
-
throw new Error(`edl not found: ${resolvedEdlPath}`);
|
|
1424
|
-
}
|
|
1425
|
-
const edl = JSON.parse(readFileSync5(resolvedEdlPath, "utf-8"));
|
|
1426
|
-
const editDir = dirname(resolvedEdlPath);
|
|
1427
|
-
const outPath = resolve(output);
|
|
1428
|
-
const segmentPaths = await extractAllSegments(edl, editDir, preview, draft);
|
|
1429
|
-
const baseName = draft ? "base_draft.mov" : preview ? "base_preview.mov" : "base.mov";
|
|
1430
|
-
const basePath = resolve(editDir, baseName);
|
|
1431
|
-
await concatSegments(segmentPaths, basePath, editDir);
|
|
1432
|
-
let subsPath = null;
|
|
1433
|
-
if (!noSubtitles) {
|
|
1434
|
-
if (buildSubtitles) {
|
|
1435
|
-
subsPath = resolve(editDir, "master.srt");
|
|
1436
|
-
const transcripts = loadTranscripts(edl, editDir);
|
|
1437
|
-
writeFileSync4(subsPath, buildSrt(edl.ranges, transcripts));
|
|
1438
|
-
} else if (edl.subtitles) {
|
|
1439
|
-
const candidate = resolvePath(edl.subtitles, editDir);
|
|
1440
|
-
if (existsSync4(candidate)) {
|
|
1441
|
-
subsPath = candidate;
|
|
1442
|
-
} else {
|
|
1443
|
-
process.stderr.write(`warning: subtitles path in EDL does not exist: ${candidate}
|
|
1444
|
-
`);
|
|
1445
|
-
subsPath = null;
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
}
|
|
1449
|
-
const overlays = edl.overlays ?? [];
|
|
1450
|
-
const tmpComposite = outPath.replace(/\.[^./\\]*$/, "") + ".prenorm.mov";
|
|
1451
|
-
await buildFinalComposite(basePath, overlays, subsPath, tmpComposite, editDir);
|
|
1452
|
-
if (noLoudnorm) {
|
|
1453
|
-
await run(
|
|
1454
|
-
[
|
|
1455
|
-
"ffmpeg",
|
|
1456
|
-
"-y",
|
|
1457
|
-
"-i",
|
|
1458
|
-
tmpComposite,
|
|
1459
|
-
"-c:v",
|
|
1460
|
-
"copy",
|
|
1461
|
-
"-c:a",
|
|
1462
|
-
"aac",
|
|
1463
|
-
"-b:a",
|
|
1464
|
-
"192k",
|
|
1465
|
-
"-ar",
|
|
1466
|
-
"48000",
|
|
1467
|
-
"-movflags",
|
|
1468
|
-
"+faststart",
|
|
1469
|
-
outPath
|
|
1470
|
-
],
|
|
1471
|
-
{ capture: true }
|
|
1472
|
-
);
|
|
1473
|
-
} else {
|
|
1474
|
-
await applyLoudnormTwoPass(tmpComposite, outPath, draft);
|
|
1475
|
-
}
|
|
1476
|
-
rmSync3(tmpComposite, { force: true });
|
|
1477
|
-
const sizeMb = statSync2(outPath).size / (1024 * 1024);
|
|
1478
|
-
process.stderr.write(`
|
|
1479
|
-
done: ${outPath} (${pyFixed(sizeMb, 1)} MB)
|
|
1480
|
-
`);
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
// src/timeline.ts
|
|
1484
|
-
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "fs/promises";
|
|
1485
|
-
import { tmpdir as tmpdir3 } from "os";
|
|
1486
|
-
import { dirname as dirname2, join as join6, basename as basename4 } from "path";
|
|
1487
|
-
import { fileURLToPath } from "url";
|
|
1488
|
-
import { createCanvas, loadImage, GlobalFonts } from "@napi-rs/canvas";
|
|
1489
|
-
var BG = "rgb(18, 18, 22)";
|
|
1490
|
-
var FG = "rgb(235, 235, 235)";
|
|
1491
|
-
var DIM = "rgb(110, 110, 120)";
|
|
1492
|
-
var WAVE = "rgb(140, 180, 255)";
|
|
1493
|
-
var WAVE_FILL = "rgba(140, 180, 255, 0.235)";
|
|
1494
|
-
var SILENCE = "rgba(50, 80, 120, 0.470)";
|
|
1495
|
-
var WAVE_BG = "rgb(28, 28, 34)";
|
|
1496
|
-
var FONT_FAMILY = "DejaVuSansMono";
|
|
1497
|
-
var fontRegistered = false;
|
|
1498
|
-
function ensureFont() {
|
|
1499
|
-
if (fontRegistered) return;
|
|
1500
|
-
fontRegistered = true;
|
|
1501
|
-
try {
|
|
1502
|
-
const fontPath = fileURLToPath(new URL("./assets/DejaVuSansMono.ttf", import.meta.url));
|
|
1503
|
-
GlobalFonts.registerFromPath(fontPath, FONT_FAMILY);
|
|
1504
|
-
} catch {
|
|
1505
|
-
}
|
|
1506
|
-
}
|
|
1507
|
-
function font(size) {
|
|
1508
|
-
return `${size}px "${FONT_FAMILY}", monospace`;
|
|
1509
|
-
}
|
|
1510
|
-
async function extractFrames(video, start, end, n, destDir) {
|
|
1511
|
-
await mkdir(destDir, { recursive: true });
|
|
1512
|
-
if (n < 1) n = 1;
|
|
1513
|
-
let times;
|
|
1514
|
-
if (n === 1) {
|
|
1515
|
-
times = [(start + end) / 2];
|
|
1516
|
-
} else {
|
|
1517
|
-
const step = (end - start) / (n - 1);
|
|
1518
|
-
times = Array.from({ length: n }, (_, i) => start + i * step);
|
|
1519
|
-
}
|
|
1520
|
-
const paths = [];
|
|
1521
|
-
for (let i = 0; i < times.length; i++) {
|
|
1522
|
-
const t = times[i];
|
|
1523
|
-
const out = join6(destDir, `f_${String(i).padStart(3, "0")}.jpg`);
|
|
1524
|
-
const cmd = [
|
|
1525
|
-
"ffmpeg",
|
|
1526
|
-
"-y",
|
|
1527
|
-
"-ss",
|
|
1528
|
-
t.toFixed(3),
|
|
1529
|
-
"-i",
|
|
1530
|
-
video,
|
|
1531
|
-
"-frames:v",
|
|
1532
|
-
"1",
|
|
1533
|
-
"-q:v",
|
|
1534
|
-
"4",
|
|
1535
|
-
"-vf",
|
|
1536
|
-
"scale=320:-2",
|
|
1537
|
-
out
|
|
1538
|
-
];
|
|
1539
|
-
await run(cmd, { capture: true, check: true, echo: false });
|
|
1540
|
-
paths.push(out);
|
|
1541
|
-
}
|
|
1542
|
-
return paths;
|
|
1543
|
-
}
|
|
1544
|
-
function computeEnvelope(pcm, samples) {
|
|
1545
|
-
const n = pcm.length;
|
|
1546
|
-
if (n === 0 || samples <= 0) {
|
|
1547
|
-
return new Array(Math.max(0, samples)).fill(0);
|
|
1548
|
-
}
|
|
1549
|
-
const window = Math.max(1, Math.floor(n / samples));
|
|
1550
|
-
const usableWindows = Math.floor(n / window);
|
|
1551
|
-
const nWindows = Math.min(usableWindows, samples);
|
|
1552
|
-
const env = new Array(samples).fill(0);
|
|
1553
|
-
let maxVal = 0;
|
|
1554
|
-
for (let w = 0; w < nWindows; w++) {
|
|
1555
|
-
let sumSq = 0;
|
|
1556
|
-
const base = w * window;
|
|
1557
|
-
for (let k = 0; k < window; k++) {
|
|
1558
|
-
const f = pcm[base + k] / 32768;
|
|
1559
|
-
sumSq += f * f;
|
|
1560
|
-
}
|
|
1561
|
-
const rms = Math.sqrt(sumSq / window);
|
|
1562
|
-
env[w] = rms;
|
|
1563
|
-
if (rms > maxVal) maxVal = rms;
|
|
1564
|
-
}
|
|
1565
|
-
if (maxVal > 0) {
|
|
1566
|
-
for (let i = 0; i < samples; i++) env[i] = env[i] / maxVal;
|
|
1567
|
-
}
|
|
1568
|
-
return env;
|
|
1569
|
-
}
|
|
1570
|
-
async function computeEnvelopeFromVideo(video, start, end, samples) {
|
|
1571
|
-
const tmp = await mkdtemp(join6(tmpdir3(), "tl-wav-"));
|
|
1572
|
-
const wav = join6(tmp, "audio.wav");
|
|
1573
|
-
try {
|
|
1574
|
-
const cmd = [
|
|
1575
|
-
"ffmpeg",
|
|
1576
|
-
"-y",
|
|
1577
|
-
"-ss",
|
|
1578
|
-
start.toFixed(3),
|
|
1579
|
-
"-i",
|
|
1580
|
-
video,
|
|
1581
|
-
"-t",
|
|
1582
|
-
(end - start).toFixed(3),
|
|
1583
|
-
"-vn",
|
|
1584
|
-
"-ac",
|
|
1585
|
-
"1",
|
|
1586
|
-
"-ar",
|
|
1587
|
-
"16000",
|
|
1588
|
-
"-c:a",
|
|
1589
|
-
"pcm_s16le",
|
|
1590
|
-
wav
|
|
1591
|
-
];
|
|
1592
|
-
const r = await run(cmd, { capture: true, check: false, echo: false });
|
|
1593
|
-
if (r.code !== 0) return new Array(samples).fill(0);
|
|
1594
|
-
let buf;
|
|
1595
|
-
try {
|
|
1596
|
-
buf = await readFile(wav);
|
|
1597
|
-
} catch {
|
|
1598
|
-
return new Array(samples).fill(0);
|
|
1599
|
-
}
|
|
1600
|
-
if (buf.length === 0) return new Array(samples).fill(0);
|
|
1601
|
-
const pcm = parseWavPcmS16le(buf);
|
|
1602
|
-
if (pcm.length === 0) return new Array(samples).fill(0);
|
|
1603
|
-
return computeEnvelope(pcm, samples);
|
|
1604
|
-
} finally {
|
|
1605
|
-
await rm(tmp, { recursive: true, force: true });
|
|
1606
|
-
}
|
|
1607
|
-
}
|
|
1608
|
-
function parseWavPcmS16le(buf) {
|
|
1609
|
-
if (buf.length < 12 || buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WAVE") {
|
|
1610
|
-
return bytesToInt16(buf, 0, buf.length);
|
|
1611
|
-
}
|
|
1612
|
-
let offset = 12;
|
|
1613
|
-
while (offset + 8 <= buf.length) {
|
|
1614
|
-
const chunkId = buf.toString("ascii", offset, offset + 4);
|
|
1615
|
-
const chunkSize = buf.readUInt32LE(offset + 4);
|
|
1616
|
-
const dataStart = offset + 8;
|
|
1617
|
-
if (chunkId === "data") {
|
|
1618
|
-
const dataEnd = Math.min(dataStart + chunkSize, buf.length);
|
|
1619
|
-
return bytesToInt16(buf, dataStart, dataEnd);
|
|
1620
|
-
}
|
|
1621
|
-
offset = dataStart + chunkSize + chunkSize % 2;
|
|
1622
|
-
}
|
|
1623
|
-
return new Int16Array(0);
|
|
1624
|
-
}
|
|
1625
|
-
function bytesToInt16(buf, startByte, endByte) {
|
|
1626
|
-
let len = endByte - startByte;
|
|
1627
|
-
if (len < 0) len = 0;
|
|
1628
|
-
const count = Math.floor(len / 2);
|
|
1629
|
-
const out = new Int16Array(count);
|
|
1630
|
-
for (let i = 0; i < count; i++) {
|
|
1631
|
-
out[i] = buf.readInt16LE(startByte + i * 2);
|
|
1632
|
-
}
|
|
1633
|
-
return out;
|
|
1634
|
-
}
|
|
1635
|
-
function wordsInRange2(transcript, start, end) {
|
|
1636
|
-
const out = [];
|
|
1637
|
-
for (const w of transcript.words ?? []) {
|
|
1638
|
-
const ws = w.start;
|
|
1639
|
-
const we = w.end;
|
|
1640
|
-
if (ws === null || ws === void 0 || we === null || we === void 0) continue;
|
|
1641
|
-
if (we <= start || ws >= end) continue;
|
|
1642
|
-
out.push(w);
|
|
1643
|
-
}
|
|
1644
|
-
return out;
|
|
1645
|
-
}
|
|
1646
|
-
function findSilences(words, start, end, threshold = 0.4) {
|
|
1647
|
-
const gaps = [];
|
|
1648
|
-
let prevEnd = start;
|
|
1649
|
-
for (const w of words) {
|
|
1650
|
-
if (w.type === "spacing") continue;
|
|
1651
|
-
const wStart = w.start ?? start;
|
|
1652
|
-
const ws = Math.max(start, wStart);
|
|
1653
|
-
if (ws - prevEnd >= threshold) {
|
|
1654
|
-
gaps.push([prevEnd, ws]);
|
|
1655
|
-
}
|
|
1656
|
-
const we = w.end ?? ws;
|
|
1657
|
-
prevEnd = Math.max(prevEnd, we);
|
|
1658
|
-
}
|
|
1659
|
-
if (end - prevEnd >= threshold) {
|
|
1660
|
-
gaps.push([prevEnd, end]);
|
|
1661
|
-
}
|
|
1662
|
-
return gaps;
|
|
1663
|
-
}
|
|
1664
|
-
async function timelineView(video, start, end, output, opts = {}) {
|
|
1665
|
-
ensureFont();
|
|
1666
|
-
const nFrames = opts.nFrames ?? 10;
|
|
1667
|
-
if (end <= start) {
|
|
1668
|
-
throw new Error("end must be > start");
|
|
1669
|
-
}
|
|
1670
|
-
try {
|
|
1671
|
-
const dur2 = await mediaDuration(video);
|
|
1672
|
-
if (dur2 > 0 && end > dur2) {
|
|
1673
|
-
end = dur2;
|
|
1674
|
-
if (end <= start) {
|
|
1675
|
-
throw new Error("end clamps to <= start (range is past EOF)");
|
|
1676
|
-
}
|
|
1677
|
-
}
|
|
1678
|
-
} catch {
|
|
1679
|
-
}
|
|
1680
|
-
const tmp = await mkdtemp(join6(tmpdir3(), "tl-frames-"));
|
|
1681
|
-
try {
|
|
1682
|
-
process.stderr.write(
|
|
1683
|
-
`extracting ${nFrames} frames from ${start.toFixed(2)}s to ${end.toFixed(2)}s
|
|
1684
|
-
`
|
|
1685
|
-
);
|
|
1686
|
-
const framePaths = await extractFrames(video, start, end, nFrames, tmp);
|
|
1687
|
-
const frameH = 180;
|
|
1688
|
-
const filmstripY = 50;
|
|
1689
|
-
const filmstripH = frameH;
|
|
1690
|
-
const waveY = filmstripY + filmstripH + 20;
|
|
1691
|
-
const waveH = 220;
|
|
1692
|
-
const labelY = waveY + waveH + 10;
|
|
1693
|
-
const canvasHeight = labelY + 60;
|
|
1694
|
-
const frames = [];
|
|
1695
|
-
for (const fp of framePaths) {
|
|
1696
|
-
const img = await loadImage(fp);
|
|
1697
|
-
const aspect = img.width / img.height;
|
|
1698
|
-
const newW = Math.trunc(frameH * aspect);
|
|
1699
|
-
frames.push({ img, w: newW });
|
|
1700
|
-
}
|
|
1701
|
-
const totalFrameW = frames.reduce((acc, f) => acc + f.w, 0) + Math.max(0, frames.length - 1) * 4;
|
|
1702
|
-
const contentW = Math.max(1400, totalFrameW);
|
|
1703
|
-
const canvasWidth = Math.max(1920, contentW + 100);
|
|
1704
|
-
const canvas = createCanvas(canvasWidth, canvasHeight);
|
|
1705
|
-
const ctx = canvas.getContext("2d");
|
|
1706
|
-
ctx.fillStyle = BG;
|
|
1707
|
-
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
|
1708
|
-
ctx.textBaseline = "top";
|
|
1709
|
-
ctx.textAlign = "left";
|
|
1710
|
-
ctx.fillStyle = FG;
|
|
1711
|
-
ctx.font = font(22);
|
|
1712
|
-
ctx.fillText(
|
|
1713
|
-
`${basename4(video)} ${start.toFixed(2)}s \u2192 ${end.toFixed(2)}s (${(end - start).toFixed(2)}s, ${nFrames} frames)`,
|
|
1714
|
-
50,
|
|
1715
|
-
12
|
|
1716
|
-
);
|
|
1717
|
-
const stripWidth = canvasWidth - 100;
|
|
1718
|
-
let drawWidth;
|
|
1719
|
-
if (totalFrameW <= stripWidth) {
|
|
1720
|
-
let cursor = 50;
|
|
1721
|
-
for (const f of frames) {
|
|
1722
|
-
ctx.drawImage(f.img, cursor, filmstripY, f.w, frameH);
|
|
1723
|
-
cursor += f.w + 4;
|
|
1724
|
-
}
|
|
1725
|
-
drawWidth = cursor - 50;
|
|
1726
|
-
} else {
|
|
1727
|
-
const scale = stripWidth / totalFrameW;
|
|
1728
|
-
const newH = Math.trunc(frameH * scale);
|
|
1729
|
-
let cursor = 50;
|
|
1730
|
-
for (const f of frames) {
|
|
1731
|
-
const newW = Math.trunc(f.w * scale);
|
|
1732
|
-
ctx.drawImage(f.img, cursor, filmstripY + Math.trunc((filmstripH - newH) / 2), newW, newH);
|
|
1733
|
-
cursor += newW + Math.max(2, Math.trunc(4 * scale));
|
|
1734
|
-
}
|
|
1735
|
-
drawWidth = cursor - 50;
|
|
1736
|
-
}
|
|
1737
|
-
const stripX0 = 50;
|
|
1738
|
-
const stripX1 = 50 + drawWidth;
|
|
1739
|
-
const stripSpan = stripX1 - stripX0;
|
|
1740
|
-
const timeToX = (t) => {
|
|
1741
|
-
const frac = (t - start) / Math.max(1e-6, end - start);
|
|
1742
|
-
return Math.trunc(stripX0 + frac * stripSpan);
|
|
1743
|
-
};
|
|
1744
|
-
ctx.fillStyle = WAVE_BG;
|
|
1745
|
-
ctx.fillRect(stripX0, waveY, stripX1 - stripX0, waveH);
|
|
1746
|
-
let words = [];
|
|
1747
|
-
if (opts.transcript) {
|
|
1748
|
-
try {
|
|
1749
|
-
const raw = await readFile(opts.transcript, "utf-8");
|
|
1750
|
-
const data = JSON.parse(raw);
|
|
1751
|
-
words = wordsInRange2(data, start, end);
|
|
1752
|
-
} catch {
|
|
1753
|
-
words = [];
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
const silences = words.length > 0 ? findSilences(words, start, end, 0.4) : [];
|
|
1757
|
-
ctx.fillStyle = SILENCE;
|
|
1758
|
-
for (const [a, b] of silences) {
|
|
1759
|
-
const xa = timeToX(a);
|
|
1760
|
-
const xb = timeToX(b);
|
|
1761
|
-
ctx.fillRect(xa, waveY, xb - xa, waveH);
|
|
1762
|
-
}
|
|
1763
|
-
const samples = Math.max(stripSpan, 200);
|
|
1764
|
-
const env = await computeEnvelopeFromVideo(video, start, end, samples);
|
|
1765
|
-
const midY = waveY + Math.trunc(waveH / 2);
|
|
1766
|
-
const maxAmp = Math.trunc(waveH / 2) - 8;
|
|
1767
|
-
const pointsTop = [];
|
|
1768
|
-
const pointsBot = [];
|
|
1769
|
-
const denom = Math.max(1, env.length - 1);
|
|
1770
|
-
for (let i = 0; i < env.length; i++) {
|
|
1771
|
-
const v = env[i];
|
|
1772
|
-
const xi = stripX0 + Math.trunc(i * stripSpan / denom);
|
|
1773
|
-
const a = Math.trunc(v * maxAmp);
|
|
1774
|
-
pointsTop.push([xi, midY - a]);
|
|
1775
|
-
pointsBot.push([xi, midY + a]);
|
|
1776
|
-
}
|
|
1777
|
-
if (pointsTop.length > 0) {
|
|
1778
|
-
ctx.fillStyle = WAVE_FILL;
|
|
1779
|
-
ctx.beginPath();
|
|
1780
|
-
ctx.moveTo(pointsTop[0][0], pointsTop[0][1]);
|
|
1781
|
-
for (let i = 1; i < pointsTop.length; i++) ctx.lineTo(pointsTop[i][0], pointsTop[i][1]);
|
|
1782
|
-
for (let i = pointsBot.length - 1; i >= 0; i--) ctx.lineTo(pointsBot[i][0], pointsBot[i][1]);
|
|
1783
|
-
ctx.closePath();
|
|
1784
|
-
ctx.fill();
|
|
1785
|
-
ctx.strokeStyle = WAVE;
|
|
1786
|
-
ctx.lineWidth = 1;
|
|
1787
|
-
ctx.lineJoin = "round";
|
|
1788
|
-
ctx.beginPath();
|
|
1789
|
-
ctx.moveTo(pointsTop[0][0], pointsTop[0][1]);
|
|
1790
|
-
for (let i = 1; i < pointsTop.length; i++) ctx.lineTo(pointsTop[i][0], pointsTop[i][1]);
|
|
1791
|
-
ctx.stroke();
|
|
1792
|
-
ctx.beginPath();
|
|
1793
|
-
ctx.moveTo(pointsBot[0][0], pointsBot[0][1]);
|
|
1794
|
-
for (let i = 1; i < pointsBot.length; i++) ctx.lineTo(pointsBot[i][0], pointsBot[i][1]);
|
|
1795
|
-
ctx.stroke();
|
|
1796
|
-
}
|
|
1797
|
-
ctx.font = font(12);
|
|
1798
|
-
let lastLabelX = -9999;
|
|
1799
|
-
for (const w of words) {
|
|
1800
|
-
if (w.type !== "word") continue;
|
|
1801
|
-
const ws = w.start;
|
|
1802
|
-
const we = w.end;
|
|
1803
|
-
const text = (w.text ?? "").trim();
|
|
1804
|
-
if (!text || ws === null || ws === void 0 || we === null || we === void 0) continue;
|
|
1805
|
-
if (we - ws < 0.05) continue;
|
|
1806
|
-
const cx = Math.trunc((timeToX(ws) + timeToX(we)) / 2);
|
|
1807
|
-
if (cx - lastLabelX < 28) continue;
|
|
1808
|
-
ctx.strokeStyle = DIM;
|
|
1809
|
-
ctx.lineWidth = 1;
|
|
1810
|
-
ctx.beginPath();
|
|
1811
|
-
ctx.moveTo(cx + 0.5, waveY - 4);
|
|
1812
|
-
ctx.lineTo(cx + 0.5, waveY);
|
|
1813
|
-
ctx.stroke();
|
|
1814
|
-
ctx.fillStyle = FG;
|
|
1815
|
-
ctx.fillText(text, cx + 2, waveY - 18);
|
|
1816
|
-
lastLabelX = cx;
|
|
1817
|
-
}
|
|
1818
|
-
const rulerY = waveY + waveH + 2;
|
|
1819
|
-
const nTicks = 6;
|
|
1820
|
-
ctx.font = font(14);
|
|
1821
|
-
for (let i = 0; i <= nTicks; i++) {
|
|
1822
|
-
const frac = i / nTicks;
|
|
1823
|
-
const t = start + frac * (end - start);
|
|
1824
|
-
const xi = stripX0 + Math.trunc(frac * stripSpan);
|
|
1825
|
-
ctx.strokeStyle = DIM;
|
|
1826
|
-
ctx.lineWidth = 1;
|
|
1827
|
-
ctx.beginPath();
|
|
1828
|
-
ctx.moveTo(xi + 0.5, rulerY);
|
|
1829
|
-
ctx.lineTo(xi + 0.5, rulerY + 6);
|
|
1830
|
-
ctx.stroke();
|
|
1831
|
-
ctx.fillStyle = DIM;
|
|
1832
|
-
ctx.fillText(`${t.toFixed(2)}s`, xi - 20, rulerY + 8);
|
|
1833
|
-
}
|
|
1834
|
-
if (silences.length > 0) {
|
|
1835
|
-
const txt = `shaded bands = silences \u2265 400ms (${silences.length} gap(s))`;
|
|
1836
|
-
ctx.font = font(14);
|
|
1837
|
-
ctx.fillStyle = DIM;
|
|
1838
|
-
ctx.fillText(txt, stripX0, labelY + 30);
|
|
1839
|
-
}
|
|
1840
|
-
await mkdir(dirname2(output), { recursive: true });
|
|
1841
|
-
const png = await canvas.encode("png");
|
|
1842
|
-
await writeFile(output, png);
|
|
1843
|
-
try {
|
|
1844
|
-
const s = await stat(output);
|
|
1845
|
-
process.stderr.write(`saved: ${output} (${Math.trunc(s.size / 1024)} KB)
|
|
1846
|
-
`);
|
|
1847
|
-
} catch {
|
|
1848
|
-
}
|
|
1849
|
-
} finally {
|
|
1850
|
-
await rm(tmp, { recursive: true, force: true });
|
|
1851
|
-
}
|
|
1852
|
-
}
|
|
1853
|
-
|
|
1854
|
-
// src/artifacts.ts
|
|
1855
|
-
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync6, readdirSync as readdirSync2, rmSync as rmSync4, unlinkSync, writeFileSync as writeFileSync5 } from "fs";
|
|
1856
|
-
import { join as join7 } from "path";
|
|
1857
|
-
function loadWords(tpath, opts = {}) {
|
|
1858
|
-
const data = JSON.parse(readFileSync6(tpath, "utf8"));
|
|
1859
|
-
const out = [];
|
|
1860
|
-
for (const w of data.words ?? []) {
|
|
1861
|
-
const typ = w.type ?? "word";
|
|
1862
|
-
if (typ === "word" || opts.includeEvents && typ === "audio_event") {
|
|
1863
|
-
if (w.start !== null && w.start !== void 0 && w.end !== null && w.end !== void 0) out.push(w);
|
|
1864
|
-
}
|
|
1865
|
-
}
|
|
1866
|
-
out.sort((a, b) => Number(a.start) - Number(b.start) || Number(a.end) - Number(b.end));
|
|
1867
|
-
return out;
|
|
1868
|
-
}
|
|
1869
|
-
function writeWordDump(tpath, outPath, gapThreshold = 0.3) {
|
|
1870
|
-
const words = loadWords(tpath, { includeEvents: true });
|
|
1871
|
-
const lines = [];
|
|
1872
|
-
let prevEnd = null;
|
|
1873
|
-
for (const w of words) {
|
|
1874
|
-
const start = Number(w.start);
|
|
1875
|
-
const end = Number(w.end);
|
|
1876
|
-
if (prevEnd !== null && start - prevEnd >= gapThreshold) {
|
|
1877
|
-
lines.push(`---- gap ${(start - prevEnd).toFixed(3)}s ----`);
|
|
1878
|
-
}
|
|
1879
|
-
const typ = w.type ?? "word";
|
|
1880
|
-
const text = (w.text ?? "").trim();
|
|
1881
|
-
lines.push(`[${pyFixedPad(start, 7, 3)}-${pyFixedPad(end, 7, 3)}] ${typ}: ${text}`);
|
|
1882
|
-
prevEnd = end;
|
|
1883
|
-
}
|
|
1884
|
-
writeFileSync5(outPath, lines.join("\n") + "\n", "utf8");
|
|
1885
|
-
}
|
|
1886
|
-
async function writeCoverage(jobDir, source, tpath) {
|
|
1887
|
-
const words = loadWords(tpath);
|
|
1888
|
-
const lastEnd = words.length ? Number(words[words.length - 1].end) : 0;
|
|
1889
|
-
const sourceDuration = await mediaDuration(source);
|
|
1890
|
-
const path = join7(jobDir, "coverage.json");
|
|
1891
|
-
writeFileSync5(
|
|
1892
|
-
path,
|
|
1893
|
-
JSON.stringify(
|
|
1894
|
-
{
|
|
1895
|
-
source_duration_s: pyRound(sourceDuration, 3),
|
|
1896
|
-
last_word_end_s: pyRound(lastEnd, 3),
|
|
1897
|
-
coverage_gap_s: pyRound(sourceDuration - lastEnd, 3),
|
|
1898
|
-
word_count: words.length
|
|
1899
|
-
},
|
|
1900
|
-
null,
|
|
1901
|
-
2
|
|
1902
|
-
) + "\n",
|
|
1903
|
-
"utf8"
|
|
1904
|
-
);
|
|
1905
|
-
return path;
|
|
1906
|
-
}
|
|
1907
|
-
async function writeTranscriptArtifacts(jobDir, source, tpath) {
|
|
1908
|
-
packTranscripts(jobDir);
|
|
1909
|
-
writeFileSync5(join7(jobDir, "flags.txt"), screenTakes(tpath), "utf8");
|
|
1910
|
-
writeWordDump(tpath, join7(jobDir, "word_dump.txt"));
|
|
1911
|
-
await writeCoverage(jobDir, source, tpath);
|
|
1912
|
-
}
|
|
1913
|
-
async function ensureTranscribed(jobDir, source, stem, opts = {}) {
|
|
1914
|
-
const tpath = transcriptPath(jobDir, stem);
|
|
1915
|
-
if (opts.force && existsSync5(tpath)) unlinkSync(tpath);
|
|
1916
|
-
if (!existsSync5(tpath)) {
|
|
1917
|
-
await transcribeOne(source, {
|
|
1918
|
-
editDir: jobDir,
|
|
1919
|
-
language: opts.language,
|
|
1920
|
-
numSpeakers: opts.numSpeakers,
|
|
1921
|
-
noHeal: opts.noHeal,
|
|
1922
|
-
force: opts.force,
|
|
1923
|
-
apiKey: opts.apiKey
|
|
1924
|
-
});
|
|
1925
|
-
}
|
|
1926
|
-
await writeTranscriptArtifacts(jobDir, source, tpath);
|
|
1927
|
-
return tpath;
|
|
1928
|
-
}
|
|
1929
|
-
function edlPath(jobDir) {
|
|
1930
|
-
return join7(jobDir, "edl.json");
|
|
1931
|
-
}
|
|
1932
|
-
function readEdl(jobDir) {
|
|
1933
|
-
const path = edlPath(jobDir);
|
|
1934
|
-
if (!existsSync5(path)) return null;
|
|
1935
|
-
return JSON.parse(readFileSync6(path, "utf8"));
|
|
1936
|
-
}
|
|
1937
|
-
function cutBoundaries(ranges) {
|
|
1938
|
-
const boundaries = [];
|
|
1939
|
-
let outputTime = 0;
|
|
1940
|
-
for (let idx = 0; idx < ranges.length - 1; idx++) {
|
|
1941
|
-
const current = ranges[idx];
|
|
1942
|
-
const nxt = ranges[idx + 1];
|
|
1943
|
-
outputTime += Number(current.end) - Number(current.start);
|
|
1944
|
-
const removed = Math.max(0, Number(nxt.start) - Number(current.end));
|
|
1945
|
-
boundaries.push({
|
|
1946
|
-
index: idx,
|
|
1947
|
-
output_time_s: pyRound(outputTime, 3),
|
|
1948
|
-
removed_source_gap_s: pyRound(removed, 3),
|
|
1949
|
-
source_before: Number(current.end),
|
|
1950
|
-
source_after: Number(nxt.start)
|
|
1951
|
-
});
|
|
1952
|
-
}
|
|
1953
|
-
return boundaries;
|
|
1954
|
-
}
|
|
1955
|
-
function writeBoundaries(jobDir) {
|
|
1956
|
-
const edl = readEdl(jobDir);
|
|
1957
|
-
if (!edl) return null;
|
|
1958
|
-
const path = join7(jobDir, "cut_boundaries.json");
|
|
1959
|
-
writeFileSync5(path, JSON.stringify(cutBoundaries(edl.ranges ?? []), null, 2) + "\n", "utf8");
|
|
1960
|
-
return path;
|
|
1961
|
-
}
|
|
1962
|
-
async function timelinePng(video, start, end, output, transcript) {
|
|
1963
|
-
if (end <= start) return false;
|
|
1964
|
-
try {
|
|
1965
|
-
await timelineView(video, start, end, output, transcript ? { transcript } : {});
|
|
1966
|
-
return true;
|
|
1967
|
-
} catch {
|
|
1968
|
-
process.stderr.write(`warning: timeline_view failed for ${output}
|
|
1969
|
-
`);
|
|
1970
|
-
return false;
|
|
1971
|
-
}
|
|
1972
|
-
}
|
|
1973
|
-
async function generateQcImages(jobDir, opts = {}) {
|
|
1974
|
-
const maxImages = opts.maxImages ?? 24;
|
|
1975
|
-
const edl = readEdl(jobDir);
|
|
1976
|
-
if (!edl) return null;
|
|
1977
|
-
const preview = opts.preview ?? join7(jobDir, "preview.mp4");
|
|
1978
|
-
if (!existsSync5(preview)) return null;
|
|
1979
|
-
const qc = join7(jobDir, "qc");
|
|
1980
|
-
mkdirSync3(qc, { recursive: true });
|
|
1981
|
-
for (const f of readdirSync2(qc)) {
|
|
1982
|
-
if (f.endsWith(".png")) rmSync4(join7(qc, f));
|
|
1983
|
-
}
|
|
1984
|
-
const previewDuration = await mediaDuration(preview);
|
|
1985
|
-
if (previewDuration > 0.5) {
|
|
1986
|
-
const openingEnd = Math.min(previewDuration - 0.2, 3);
|
|
1987
|
-
if (openingEnd > 0.2) await timelinePng(preview, 0, openingEnd, join7(qc, "preview_opening.png"));
|
|
1988
|
-
const outroStart = Math.max(0, previewDuration - 3);
|
|
1989
|
-
const outroEnd = Math.max(0.05, previewDuration - 0.2);
|
|
1990
|
-
if (outroEnd > outroStart) await timelinePng(preview, outroStart, outroEnd, join7(qc, "preview_outro.png"));
|
|
1991
|
-
}
|
|
1992
|
-
const boundaries = cutBoundaries(edl.ranges ?? []);
|
|
1993
|
-
writeBoundaries(jobDir);
|
|
1994
|
-
const ranked = [...boundaries].sort((a, b) => b.removed_source_gap_s - a.removed_source_gap_s);
|
|
1995
|
-
let idx = 1;
|
|
1996
|
-
for (const boundary of ranked.slice(0, maxImages)) {
|
|
1997
|
-
const center = boundary.output_time_s;
|
|
1998
|
-
const start = Math.max(0, center - 1.25);
|
|
1999
|
-
const end = Math.min(Math.max(0.05, previewDuration - 0.2), center + 1.25);
|
|
2000
|
-
await timelinePng(preview, start, end, join7(qc, `preview_join_${String(idx).padStart(2, "0")}.png`));
|
|
2001
|
-
idx++;
|
|
2002
|
-
}
|
|
2003
|
-
return qc;
|
|
2004
|
-
}
|
|
2005
|
-
function parseTimeFromText(text) {
|
|
2006
|
-
const m = text.match(/(?<!\d)(\d{1,3}(?:\.\d+)?)(?:\s*s|\b)/);
|
|
2007
|
-
if (m) {
|
|
2008
|
-
const v = Number(m[1]);
|
|
2009
|
-
if (Number.isFinite(v)) return v;
|
|
2010
|
-
}
|
|
2011
|
-
const mmss = text.match(/(\d{1,2}):(\d{2})(?:\.(\d+))?/);
|
|
2012
|
-
if (mmss) {
|
|
2013
|
-
const minutes = Number(mmss[1]);
|
|
2014
|
-
const seconds = Number(mmss[2]);
|
|
2015
|
-
const frac = mmss[3] ? Number("0." + mmss[3]) : 0;
|
|
2016
|
-
return minutes * 60 + seconds + frac;
|
|
2017
|
-
}
|
|
2018
|
-
return null;
|
|
2019
|
-
}
|
|
2020
|
-
function outputTimeForIssue(edl, issue) {
|
|
2021
|
-
const ranges = edl.ranges ?? [];
|
|
2022
|
-
let index = issue.range_index;
|
|
2023
|
-
if (typeof index === "number" && !Number.isInteger(index)) index = Math.trunc(index);
|
|
2024
|
-
if (typeof index !== "number" || index < 0 || index >= ranges.length) return 0;
|
|
2025
|
-
const prior = ranges.slice(0, index).reduce((acc, r) => acc + (Number(r.end) - Number(r.start)), 0);
|
|
2026
|
-
const sourceT = parseTimeFromText(String(issue.where ?? ""));
|
|
2027
|
-
if (sourceT === null) return prior;
|
|
2028
|
-
const start = Number(ranges[index].start);
|
|
2029
|
-
const end = Number(ranges[index].end);
|
|
2030
|
-
if (start <= sourceT && sourceT <= end) return prior + (sourceT - start);
|
|
2031
|
-
return prior;
|
|
2032
|
-
}
|
|
2033
|
-
function writeWarningsFromReview(jobDir) {
|
|
2034
|
-
const reviewPath = join7(jobDir, "review.json");
|
|
2035
|
-
const edl = readEdl(jobDir) ?? { ranges: [] };
|
|
2036
|
-
const warnings = [];
|
|
2037
|
-
if (existsSync5(reviewPath)) {
|
|
2038
|
-
const review = JSON.parse(readFileSync6(reviewPath, "utf8"));
|
|
2039
|
-
(review.issues ?? []).forEach((issue, i) => {
|
|
2040
|
-
warnings.push({
|
|
2041
|
-
id: `w${i + 1}`,
|
|
2042
|
-
severity: issue.severity ?? "warning",
|
|
2043
|
-
output_time_s: pyRound(outputTimeForIssue(edl, issue), 2),
|
|
2044
|
-
range_index: issue.range_index,
|
|
2045
|
-
problem: issue.problem ?? "",
|
|
2046
|
-
fix: issue.fix ?? ""
|
|
2047
|
-
});
|
|
2048
|
-
});
|
|
2049
|
-
}
|
|
2050
|
-
const path = join7(jobDir, "warnings.json");
|
|
2051
|
-
writeFileSync5(path, JSON.stringify(warnings, null, 2) + "\n", "utf8");
|
|
2052
|
-
return path;
|
|
2053
|
-
}
|
|
2054
|
-
function artifactPaths(jobDir, stem) {
|
|
2055
|
-
return {
|
|
2056
|
-
source_json: join7(jobDir, "source.json"),
|
|
2057
|
-
vertical_src: join7(jobDir, "vertical_src.mp4"),
|
|
2058
|
-
transcript: transcriptPath(jobDir, stem),
|
|
2059
|
-
takes_packed: join7(jobDir, "takes_packed.md"),
|
|
2060
|
-
flags: join7(jobDir, "flags.txt"),
|
|
2061
|
-
word_dump: join7(jobDir, "word_dump.txt"),
|
|
2062
|
-
coverage: join7(jobDir, "coverage.json"),
|
|
2063
|
-
edl: join7(jobDir, "edl.json"),
|
|
2064
|
-
preview: join7(jobDir, "preview.mp4"),
|
|
2065
|
-
review: join7(jobDir, "review.json"),
|
|
2066
|
-
verify: join7(jobDir, "verify.json"),
|
|
2067
|
-
warnings: join7(jobDir, "warnings.json"),
|
|
2068
|
-
qc_dir: join7(jobDir, "qc"),
|
|
2069
|
-
author_prompt: join7(jobDir, "prompts", "author-edl.prompt.md"),
|
|
2070
|
-
review_prompt: join7(jobDir, "prompts", "review.prompt.md"),
|
|
2071
|
-
fix_prompt: join7(jobDir, "prompts", "fix.prompt.md")
|
|
2072
|
-
};
|
|
2073
|
-
}
|
|
2074
|
-
function writeAgentManifest(jobDir, source, stem, mode) {
|
|
2075
|
-
const manifest = {
|
|
2076
|
-
mode,
|
|
2077
|
-
job_dir: jobDir,
|
|
2078
|
-
source,
|
|
2079
|
-
artifacts: artifactPaths(jobDir, stem),
|
|
2080
|
-
agent_contract: {
|
|
2081
|
-
no_internal_agent_execution: true,
|
|
2082
|
-
cli_only: "use only the `video-editing` CLI; never run video-use helpers or ffmpeg directly",
|
|
2083
|
-
edl_author: "external agent writes edl.json from prompts and artifacts",
|
|
2084
|
-
render: "video-editing render --job-dir <job> --mode preview|draft|final",
|
|
2085
|
-
verify: "video-editing verify --job-dir <job> --json",
|
|
2086
|
-
inspect: "video-editing inspect --job-dir <job> --join <i>"
|
|
2087
|
-
}
|
|
2088
|
-
};
|
|
2089
|
-
const path = join7(jobDir, "agent_manifest.json");
|
|
2090
|
-
writeFileSync5(path, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
2091
|
-
return path;
|
|
2092
|
-
}
|
|
2093
|
-
async function statusPayload(jobDir) {
|
|
2094
|
-
const { loadJob } = await import("./job-OFCSLPBV.js");
|
|
2095
|
-
const job = loadJob(jobDir);
|
|
2096
|
-
const stem = job.stem;
|
|
2097
|
-
const previewPath = join7(jobDir, "preview.mp4");
|
|
2098
|
-
const payload = {
|
|
2099
|
-
job_dir: jobDir,
|
|
2100
|
-
source: job.source,
|
|
2101
|
-
source_duration_s: job.duration_s,
|
|
2102
|
-
artifacts: artifactPaths(jobDir, stem)
|
|
2103
|
-
};
|
|
2104
|
-
const tpath = transcriptPath(jobDir, stem);
|
|
2105
|
-
if (existsSync5(tpath)) {
|
|
2106
|
-
const words = loadWords(tpath);
|
|
2107
|
-
const lastEnd = words.length ? Number(words[words.length - 1].end) : 0;
|
|
2108
|
-
payload.transcript = {
|
|
2109
|
-
word_count: words.length,
|
|
2110
|
-
last_word_end_s: pyRound(lastEnd, 3),
|
|
2111
|
-
coverage_gap_s: pyRound((Number(job.duration_s) || 0) - lastEnd, 3)
|
|
2112
|
-
};
|
|
2113
|
-
}
|
|
2114
|
-
const edl = readEdl(jobDir);
|
|
2115
|
-
if (edl) {
|
|
2116
|
-
payload.edl = {
|
|
2117
|
-
range_count: (edl.ranges ?? []).length,
|
|
2118
|
-
total_duration_s: edl.total_duration_s,
|
|
2119
|
-
grade: edl.grade
|
|
2120
|
-
};
|
|
2121
|
-
}
|
|
2122
|
-
if (existsSync5(previewPath)) {
|
|
2123
|
-
const probe = await ffprobe(previewPath);
|
|
2124
|
-
const stream = videoStream(probe);
|
|
2125
|
-
payload.preview = {
|
|
2126
|
-
duration_s: Number(probe.format?.duration ?? 0) || 0,
|
|
2127
|
-
width: Math.trunc(Number(stream.width ?? 0)) || 0,
|
|
2128
|
-
height: Math.trunc(Number(stream.height ?? 0)) || 0
|
|
2129
|
-
};
|
|
2130
|
-
}
|
|
2131
|
-
const reviewPath = join7(jobDir, "review.json");
|
|
2132
|
-
if (existsSync5(reviewPath)) {
|
|
2133
|
-
const review = JSON.parse(readFileSync6(reviewPath, "utf8"));
|
|
2134
|
-
payload.review = { verdict: review.verdict, issue_count: (review.issues ?? []).length };
|
|
2135
|
-
}
|
|
2136
|
-
const verifyPath = join7(jobDir, "verify.json");
|
|
2137
|
-
if (existsSync5(verifyPath)) {
|
|
2138
|
-
const verify2 = JSON.parse(readFileSync6(verifyPath, "utf8"));
|
|
2139
|
-
payload.verify = { verdict: verify2.verdict, finding_count: (verify2.joins ?? []).length };
|
|
2140
|
-
}
|
|
2141
|
-
let qcCount = 0;
|
|
2142
|
-
const qcDir = join7(jobDir, "qc");
|
|
2143
|
-
if (existsSync5(qcDir)) qcCount = readdirSync2(qcDir).filter((f) => f.endsWith(".png")).length;
|
|
2144
|
-
payload.qc_png_count = qcCount;
|
|
2145
|
-
return payload;
|
|
2146
|
-
}
|
|
2147
|
-
|
|
2148
|
-
// src/verify.ts
|
|
2149
|
-
import { existsSync as existsSync6, mkdtempSync as mkdtempSync3, readFileSync as readFileSync7, rmSync as rmSync5 } from "fs";
|
|
2150
|
-
import { tmpdir as tmpdir4 } from "os";
|
|
2151
|
-
import { join as join8, parse as parsePath } from "path";
|
|
2152
|
-
async function transcribeWindow(preview, startS, lengthS, apiKey) {
|
|
2153
|
-
const work = mkdtempSync3(join8(tmpdir4(), "ve-verify-"));
|
|
2154
|
-
try {
|
|
2155
|
-
const wav = join8(work, "win.wav");
|
|
2156
|
-
await run(
|
|
2157
|
-
[
|
|
2158
|
-
"ffmpeg",
|
|
2159
|
-
"-y",
|
|
2160
|
-
"-ss",
|
|
2161
|
-
startS.toFixed(3),
|
|
2162
|
-
"-i",
|
|
2163
|
-
preview,
|
|
2164
|
-
"-t",
|
|
2165
|
-
lengthS.toFixed(3),
|
|
2166
|
-
"-vn",
|
|
2167
|
-
"-ac",
|
|
2168
|
-
"1",
|
|
2169
|
-
"-ar",
|
|
2170
|
-
"16000",
|
|
2171
|
-
"-c:a",
|
|
2172
|
-
"pcm_s16le",
|
|
2173
|
-
wav
|
|
2174
|
-
],
|
|
2175
|
-
{ echo: false }
|
|
2176
|
-
);
|
|
2177
|
-
await transcribeOne(wav, { editDir: work, noHeal: true, apiKey });
|
|
2178
|
-
const tpath = join8(work, "transcripts", `${parsePath(wav).name}.json`);
|
|
2179
|
-
if (!existsSync6(tpath)) return [];
|
|
2180
|
-
const data = JSON.parse(readFileSync7(tpath, "utf8"));
|
|
2181
|
-
const out = [];
|
|
2182
|
-
for (const w of data.words ?? []) {
|
|
2183
|
-
if ((w.type ?? "word") !== "word") continue;
|
|
2184
|
-
if (w.start === null || w.start === void 0 || w.end === null || w.end === void 0) continue;
|
|
2185
|
-
const text = (w.text ?? "").trim();
|
|
2186
|
-
const norm2 = anchorNorm(text);
|
|
2187
|
-
if (!norm2) continue;
|
|
2188
|
-
out.push({ text, norm: norm2, start: Number(w.start), end: Number(w.end) });
|
|
2189
|
-
}
|
|
2190
|
-
return out;
|
|
2191
|
-
} finally {
|
|
2192
|
-
rmSync5(work, { recursive: true, force: true });
|
|
2193
|
-
}
|
|
2194
|
-
}
|
|
2195
|
-
var ADJACENCY_SLACK = 3;
|
|
2196
|
-
function duplicatedSpans(words) {
|
|
2197
|
-
const out = [];
|
|
2198
|
-
let i = 0;
|
|
2199
|
-
while (i < words.length) {
|
|
2200
|
-
let matched = false;
|
|
2201
|
-
for (let n = 4; n >= 2 && !matched; n--) {
|
|
2202
|
-
if (i + n > words.length) continue;
|
|
2203
|
-
const gramA = words.slice(i, i + n).map((w) => w.norm).join(" ");
|
|
2204
|
-
for (let off = n; off <= n + ADJACENCY_SLACK; off++) {
|
|
2205
|
-
const j = i + off;
|
|
2206
|
-
if (j + n > words.length) break;
|
|
2207
|
-
const gramB = words.slice(j, j + n).map((w) => w.norm).join(" ");
|
|
2208
|
-
if (gramA === gramB) {
|
|
2209
|
-
out.push({ text: words.slice(i, j + n).map((w) => w.text).join(" "), n });
|
|
2210
|
-
i = j + n;
|
|
2211
|
-
matched = true;
|
|
2212
|
-
break;
|
|
2213
|
-
}
|
|
2214
|
-
}
|
|
2215
|
-
}
|
|
2216
|
-
if (!matched) i++;
|
|
2217
|
-
}
|
|
2218
|
-
return out;
|
|
2219
|
-
}
|
|
2220
|
-
function duplicatedPhrases(words) {
|
|
2221
|
-
return duplicatedSpans(words).map((d) => d.text);
|
|
2222
|
-
}
|
|
2223
|
-
function duplicatedPhrase(words) {
|
|
2224
|
-
return duplicatedPhrases(words)[0] ?? null;
|
|
2225
|
-
}
|
|
2226
|
-
function dupSeverity(span) {
|
|
2227
|
-
return span.n >= 3 ? "error" : "warning";
|
|
2228
|
-
}
|
|
2229
|
-
function straddlingGap(words, joinOffset) {
|
|
2230
|
-
let worst = 0;
|
|
2231
|
-
for (let i = 0; i < words.length - 1; i++) {
|
|
2232
|
-
const a = words[i];
|
|
2233
|
-
const b = words[i + 1];
|
|
2234
|
-
const gap = b.start - a.end;
|
|
2235
|
-
if (gap <= 0) continue;
|
|
2236
|
-
if (a.end <= joinOffset && joinOffset <= b.start) worst = Math.max(worst, gap);
|
|
2237
|
-
}
|
|
2238
|
-
return worst;
|
|
2239
|
-
}
|
|
2240
|
-
async function verify(jobDir, opts = {}) {
|
|
2241
|
-
const windowS = opts.windowS ?? 6;
|
|
2242
|
-
const gapThreshold = opts.gapThresholdS ?? 0.6;
|
|
2243
|
-
const preview = join8(jobDir, "preview.mp4");
|
|
2244
|
-
if (!existsSync6(preview)) throw new Error(`preview not found (render first): ${preview}`);
|
|
2245
|
-
const edl = readEdl(jobDir);
|
|
2246
|
-
if (!edl) throw new Error(`edl.json not found in ${jobDir}`);
|
|
2247
|
-
const previewDur = await mediaDuration(preview);
|
|
2248
|
-
const findings = [];
|
|
2249
|
-
if (opts.full) {
|
|
2250
|
-
const words = await transcribeWindow(preview, 0, previewDur, opts.apiKey);
|
|
2251
|
-
for (const span of duplicatedSpans(words)) {
|
|
2252
|
-
const severity = dupSeverity(span);
|
|
2253
|
-
const note = severity === "warning" ? " (2-word repeat \u2014 adjudicate: spelled acronym/number/emphasis vs retake)" : "";
|
|
2254
|
-
findings.push({ index: -1, output_time_s: 0, duplicated_phrase: span.text, severity, detail: `duplicated phrase in preview: "${span.text}"${note}` });
|
|
2255
|
-
}
|
|
2256
|
-
if (words.length) {
|
|
2257
|
-
const head = words[0].start;
|
|
2258
|
-
if (head > gapThreshold) {
|
|
2259
|
-
findings.push({ index: -1, output_time_s: 0, gap_s: head, severity: "warning", detail: `leading dead air ${head.toFixed(2)}s before first word` });
|
|
2260
|
-
}
|
|
2261
|
-
const tail = previewDur - words[words.length - 1].end;
|
|
2262
|
-
if (tail > gapThreshold) {
|
|
2263
|
-
findings.push({ index: -1, output_time_s: previewDur, gap_s: tail, severity: "warning", detail: `trailing dead air ${tail.toFixed(2)}s after last word` });
|
|
2264
|
-
}
|
|
2265
|
-
}
|
|
2266
|
-
const verdict2 = findings.some((f) => f.severity === "error") ? "needs-fixes" : "clean";
|
|
2267
|
-
return { verdict: verdict2, mode: "full", joins: findings, confidence: `re-transcribed full preview (${previewDur.toFixed(1)}s)` };
|
|
2268
|
-
}
|
|
2269
|
-
const boundaries = cutBoundaries(edl.ranges ?? []);
|
|
2270
|
-
for (const b of boundaries) {
|
|
2271
|
-
const center = b.output_time_s;
|
|
2272
|
-
const winStart = Math.max(0, center - windowS / 2);
|
|
2273
|
-
const winLen = Math.min(windowS, previewDur - winStart);
|
|
2274
|
-
if (winLen < 1) continue;
|
|
2275
|
-
const words = await transcribeWindow(preview, winStart, winLen, opts.apiKey);
|
|
2276
|
-
const joinOffset = center - winStart;
|
|
2277
|
-
for (const span of duplicatedSpans(words)) {
|
|
2278
|
-
const severity = dupSeverity(span);
|
|
2279
|
-
const detail = severity === "error" ? `duplicated phrase across join ${b.index}: "${span.text}" (likely a retake left in)` : `2-word repeat near join ${b.index}: "${span.text}" (adjudicate: spelled acronym/number/emphasis vs retake)`;
|
|
2280
|
-
findings.push({ index: b.index, output_time_s: center, duplicated_phrase: span.text, severity, detail });
|
|
2281
|
-
}
|
|
2282
|
-
const gap = straddlingGap(words, joinOffset);
|
|
2283
|
-
if (gap > gapThreshold) {
|
|
2284
|
-
const sev = gap > gapThreshold * 2 ? "error" : "warning";
|
|
2285
|
-
findings.push({ index: b.index, output_time_s: center, gap_s: gap, severity: sev, detail: `dead air ${gap.toFixed(2)}s at join ${b.index} (not tightened)` });
|
|
2286
|
-
}
|
|
2287
|
-
}
|
|
2288
|
-
const verdict = findings.some((f) => f.severity === "error") ? "needs-fixes" : "clean";
|
|
2289
|
-
return {
|
|
2290
|
-
verdict,
|
|
2291
|
-
mode: "joins",
|
|
2292
|
-
joins: findings,
|
|
2293
|
-
confidence: `re-transcribed ${boundaries.length} join window(s) @ ${windowS}s, heal off`
|
|
2294
|
-
};
|
|
2295
|
-
}
|
|
2296
|
-
|
|
2297
|
-
// src/silence.ts
|
|
2298
|
-
import { existsSync as existsSync7, readFileSync as readFileSync8, readdirSync as readdirSync3 } from "fs";
|
|
2299
|
-
import { join as join9, resolve as resolve2 } from "path";
|
|
2300
|
-
var INTRA_WORD_TOL = 0.06;
|
|
2301
|
-
function isIntraWord(a, b, words) {
|
|
2302
|
-
for (const w of words) {
|
|
2303
|
-
if (w.start < a - INTRA_WORD_TOL && w.end > b + INTRA_WORD_TOL) return true;
|
|
2304
|
-
}
|
|
2305
|
-
return false;
|
|
2306
|
-
}
|
|
2307
|
-
var DEFAULT_DESILENCE = {
|
|
2308
|
-
noiseDb: -36,
|
|
2309
|
-
minSilence: 0.14,
|
|
2310
|
-
targetGap: 0.09,
|
|
2311
|
-
leadKeep: 0.04,
|
|
2312
|
-
trailKeep: 0.05,
|
|
2313
|
-
minSegment: 0.1
|
|
2314
|
-
};
|
|
2315
|
-
var EPS = 1e-3;
|
|
2316
|
-
function f32(x) {
|
|
2317
|
-
return pyFixed(x, 3);
|
|
2318
|
-
}
|
|
2319
|
-
function silenceDetectArgs(source, noiseDb, minSilenceS) {
|
|
2320
|
-
return [
|
|
2321
|
-
"ffmpeg",
|
|
2322
|
-
"-hide_banner",
|
|
2323
|
-
"-nostats",
|
|
2324
|
-
"-i",
|
|
2325
|
-
source,
|
|
2326
|
-
"-af",
|
|
2327
|
-
`silencedetect=noise=${noiseDb}dB:d=${f32(minSilenceS)}`,
|
|
2328
|
-
"-vn",
|
|
2329
|
-
"-f",
|
|
2330
|
-
"null",
|
|
2331
|
-
"-"
|
|
2332
|
-
];
|
|
2333
|
-
}
|
|
2334
|
-
function parseSilenceDetect(stderr, sourceDurationS = 0) {
|
|
2335
|
-
const intervals = [];
|
|
2336
|
-
let pendingStart = null;
|
|
2337
|
-
for (const line of stderr.split(/\r?\n/)) {
|
|
2338
|
-
const mStart = line.match(/silence_start:\s*(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
2339
|
-
if (mStart) {
|
|
2340
|
-
pendingStart = Number(mStart[1]);
|
|
2341
|
-
continue;
|
|
2342
|
-
}
|
|
2343
|
-
const mEnd = line.match(/silence_end:\s*(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
2344
|
-
if (mEnd && pendingStart !== null) {
|
|
2345
|
-
const end = Number(mEnd[1]);
|
|
2346
|
-
if (end > pendingStart) intervals.push({ start: pendingStart, end });
|
|
2347
|
-
pendingStart = null;
|
|
2348
|
-
}
|
|
2349
|
-
}
|
|
2350
|
-
if (pendingStart !== null && sourceDurationS > pendingStart) {
|
|
2351
|
-
intervals.push({ start: pendingStart, end: sourceDurationS });
|
|
2352
|
-
}
|
|
2353
|
-
return intervals;
|
|
2354
|
-
}
|
|
2355
|
-
function appendNote(reason, note) {
|
|
2356
|
-
return reason && reason.length > 0 ? `${reason} [${note}]` : `[${note}]`;
|
|
2357
|
-
}
|
|
2358
|
-
function tightenRange(range, silences, opts = DEFAULT_DESILENCE, words = []) {
|
|
2359
|
-
const s = Number(range.start);
|
|
2360
|
-
const e = Number(range.end);
|
|
2361
|
-
if (!(e > s)) return [{ ...range }];
|
|
2362
|
-
const clipped = silences.map((sil) => {
|
|
2363
|
-
const a = Math.max(sil.start, s);
|
|
2364
|
-
const b = Math.min(sil.end, e);
|
|
2365
|
-
return { a, b, touchesStart: sil.start <= s + EPS, touchesEnd: sil.end >= e - EPS };
|
|
2366
|
-
}).filter((c) => c.b - c.a >= opts.minSilence - EPS).sort((x, y) => x.a - y.a);
|
|
2367
|
-
if (clipped.length === 0) return [{ ...range }];
|
|
2368
|
-
const segs = [];
|
|
2369
|
-
let segStart = s;
|
|
2370
|
-
let i = 0;
|
|
2371
|
-
while (i < clipped.length && clipped[i].touchesStart && clipped[i].a <= segStart + EPS) {
|
|
2372
|
-
segStart = Math.min(clipped[i].b - opts.leadKeep, e);
|
|
2373
|
-
i++;
|
|
2374
|
-
}
|
|
2375
|
-
let open = true;
|
|
2376
|
-
for (; i < clipped.length; i++) {
|
|
2377
|
-
const sil = clipped[i];
|
|
2378
|
-
if (!sil.touchesEnd && isIntraWord(sil.a, sil.b, words)) continue;
|
|
2379
|
-
const segEnd = Math.min(sil.a + opts.trailKeep, e);
|
|
2380
|
-
if (segEnd - segStart >= opts.minSegment) segs.push([segStart, segEnd]);
|
|
2381
|
-
if (sil.touchesEnd) {
|
|
2382
|
-
open = false;
|
|
2383
|
-
break;
|
|
2384
|
-
}
|
|
2385
|
-
segStart = Math.max(sil.b - opts.leadKeep, sil.a);
|
|
2386
|
-
}
|
|
2387
|
-
if (open && e - segStart >= opts.minSegment) segs.push([segStart, e]);
|
|
2388
|
-
if (segs.length === 0) return [{ ...range }];
|
|
2389
|
-
return segs.map(([a, b], idx) => ({
|
|
2390
|
-
...range,
|
|
2391
|
-
start: pyRound(a, 3),
|
|
2392
|
-
end: pyRound(b, 3),
|
|
2393
|
-
reason: appendNote(range.reason, idx === 0 ? "desilenced" : `desilenced ${idx + 1}/${segs.length}`)
|
|
2394
|
-
}));
|
|
2395
|
-
}
|
|
2396
|
-
function tightenRanges(ranges, silencesBySource, opts = DEFAULT_DESILENCE, wordsBySource = {}) {
|
|
2397
|
-
return ranges.flatMap(
|
|
2398
|
-
(r) => tightenRange(r, silencesBySource[r.source] ?? [], opts, wordsBySource[r.source] ?? [])
|
|
2399
|
-
);
|
|
2400
|
-
}
|
|
2401
|
-
function wordsFromTranscriptFile(path) {
|
|
2402
|
-
const t = JSON.parse(readFileSync8(path, "utf8"));
|
|
2403
|
-
const out = [];
|
|
2404
|
-
for (const w of t.words ?? []) {
|
|
2405
|
-
if (w.type !== "word") continue;
|
|
2406
|
-
if (typeof w.start === "number" && typeof w.end === "number" && w.end > w.start) {
|
|
2407
|
-
out.push({ start: w.start, end: w.end });
|
|
2408
|
-
}
|
|
2409
|
-
}
|
|
2410
|
-
return out;
|
|
2411
|
-
}
|
|
2412
|
-
function loadWordsBySource(edl, editDir) {
|
|
2413
|
-
const dir = resolve2(editDir, "transcripts");
|
|
2414
|
-
const allJson = existsSync7(dir) ? readdirSync3(dir).filter((f) => f.endsWith(".json")) : [];
|
|
2415
|
-
const out = {};
|
|
2416
|
-
for (const name of Object.keys(edl.sources)) {
|
|
2417
|
-
let path = join9(dir, `${name}.json`);
|
|
2418
|
-
if (!existsSync7(path)) {
|
|
2419
|
-
if (allJson.length === 1) path = join9(dir, allJson[0]);
|
|
2420
|
-
else {
|
|
2421
|
-
out[name] = [];
|
|
2422
|
-
continue;
|
|
2423
|
-
}
|
|
2424
|
-
}
|
|
2425
|
-
try {
|
|
2426
|
-
out[name] = wordsFromTranscriptFile(path);
|
|
2427
|
-
} catch {
|
|
2428
|
-
out[name] = [];
|
|
2429
|
-
}
|
|
2430
|
-
}
|
|
2431
|
-
return out;
|
|
2432
|
-
}
|
|
2433
|
-
function totalDuration(ranges) {
|
|
2434
|
-
return ranges.reduce((acc, r) => acc + (Number(r.end) - Number(r.start)), 0);
|
|
2435
|
-
}
|
|
2436
|
-
async function detectSilences(source, noiseDb, minSilenceS, sourceDurationS) {
|
|
2437
|
-
const res = await run(silenceDetectArgs(source, noiseDb, minSilenceS), {
|
|
2438
|
-
capture: true,
|
|
2439
|
-
check: false,
|
|
2440
|
-
echo: false
|
|
2441
|
-
});
|
|
2442
|
-
return parseSilenceDetect(res.stderr, sourceDurationS);
|
|
2443
|
-
}
|
|
2444
|
-
async function desilenceEdl(edl, editDir, opts = DEFAULT_DESILENCE) {
|
|
2445
|
-
const silencesBySource = {};
|
|
2446
|
-
for (const [name, rel] of Object.entries(edl.sources)) {
|
|
2447
|
-
const srcPath = resolvePath(rel, editDir);
|
|
2448
|
-
const dur2 = await mediaDuration(srcPath);
|
|
2449
|
-
silencesBySource[name] = await detectSilences(srcPath, opts.noiseDb, opts.minSilence, dur2);
|
|
2450
|
-
}
|
|
2451
|
-
const wordsBySource = loadWordsBySource(edl, editDir);
|
|
2452
|
-
const durationBefore = totalDuration(edl.ranges);
|
|
2453
|
-
const newRanges = tightenRanges(edl.ranges, silencesBySource, opts, wordsBySource);
|
|
2454
|
-
const durationAfter = totalDuration(newRanges);
|
|
2455
|
-
const newEdl = {
|
|
2456
|
-
...edl,
|
|
2457
|
-
ranges: newRanges,
|
|
2458
|
-
total_duration_s: pyRound(durationAfter, 2),
|
|
2459
|
-
desilenced: true
|
|
2460
|
-
};
|
|
2461
|
-
return {
|
|
2462
|
-
edl: newEdl,
|
|
2463
|
-
stats: {
|
|
2464
|
-
rangesBefore: edl.ranges.length,
|
|
2465
|
-
rangesAfter: newRanges.length,
|
|
2466
|
-
durationBefore: pyRound(durationBefore, 2),
|
|
2467
|
-
durationAfter: pyRound(durationAfter, 2),
|
|
2468
|
-
removedS: pyRound(durationBefore - durationAfter, 2)
|
|
2469
|
-
}
|
|
2470
|
-
};
|
|
2471
|
-
}
|
|
2472
|
-
|
|
2473
|
-
// src/prompts.ts
|
|
2474
|
-
import { readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
|
|
2475
|
-
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2476
|
-
import { join as join10 } from "path";
|
|
2477
|
-
function learnedEditingGuardrails(jobDir, stem) {
|
|
2478
|
-
const tpath = transcriptPath(jobDir, stem);
|
|
2479
|
-
return `
|
|
2480
|
-
## V1 Learned Guardrails
|
|
2481
|
-
|
|
2482
|
-
Before writing or reviewing the EDL, build a short plain-English understanding of the video:
|
|
2483
|
-
- What is the topic?
|
|
2484
|
-
- What named people, companies, products, and technical terms are likely being said?
|
|
2485
|
-
- What is the intended sentence meaning around each retake cluster?
|
|
2486
|
-
|
|
2487
|
-
Treat the ElevenLabs Scribe transcript as word-timing evidence, not perfect text. Proper nouns and domain terms may be wrong. If a token looks semantically wrong but the surrounding sentence makes a better reading obvious, use the transcript timing but write the corrected human meaning in \`quote\` / \`reason\`.
|
|
2488
|
-
|
|
2489
|
-
When transcript text and content disagree, inspect media at the decision point:
|
|
2490
|
-
- Use \`video-editing inspect --job-dir "${jobDir}" --video vertical_src --start <start> --end <end>\` (filmstrip + waveform + transcript labels PNG; transcript "${tpath}").
|
|
2491
|
-
- Read the filmstrip/waveform/labels before choosing the cut. Use it to find where the real spoken word starts, especially when Scribe mishears a proper noun or collapses a word.
|
|
2492
|
-
- For the opener, any named entity, and every retake join, verify the rendered preview with \`video-editing verify\` and \`video-editing inspect\` after rendering.
|
|
2493
|
-
|
|
2494
|
-
Boundary rules learned from preview failures:
|
|
2495
|
-
- Clipped tokens ending in \`-\` (examples: \`s-\`, \`re-\`, \`li-\`, \`ei-\`) are danger zones. Do not end a kept range only a few milliseconds before a clipped token. Leave an audio guard band, preferably 80-150ms when the source gives you room.
|
|
2496
|
-
- Keep the first token of the chosen final take, even if it is a connector like "and", "so", or "but", when it belongs to the spoken sentence. Do not trim to the first important-looking content word.
|
|
2497
|
-
- The first 10 seconds are high stakes. After rendering, inspect the opening by listening or by \`video-editing inspect\` and fix any clipped syllable, doubled consonant, or missing connector before presenting the preview.
|
|
2498
|
-
- A clean text range is not enough. If the preview sounds like a doubled first letter/syllable across a join, move the previous range end earlier or the next range start later on word boundaries and rerender.
|
|
2499
|
-
`;
|
|
2500
|
-
}
|
|
2501
|
-
function agentizePrompt(text, jobDir, stem) {
|
|
2502
|
-
let out = text.replace(/\{\{JOBDIR\}\}/g, jobDir).replace(/\{\{STEM\}\}/g, stem);
|
|
2503
|
-
out = out.replace("{{JOBDIR}}/transcripts/<stem>.json", transcriptPath(jobDir, stem));
|
|
2504
|
-
out = out.replace(/HEADLESS RULES[^\n]*:/, "AGENT RULES:");
|
|
2505
|
-
if (!out.includes("## V1 Learned Guardrails")) {
|
|
2506
|
-
out = out.trimEnd() + "\n\n" + learnedEditingGuardrails(jobDir, stem).trim() + "\n";
|
|
2507
|
-
}
|
|
2508
|
-
return out;
|
|
2509
|
-
}
|
|
2510
|
-
function templatePath(name) {
|
|
2511
|
-
return fileURLToPath2(new URL(`./templates/${name}`, import.meta.url));
|
|
2512
|
-
}
|
|
2513
|
-
function writeVideoEditingPrompt(jobDir, stem, templateName, outName) {
|
|
2514
|
-
const text = readFileSync9(templatePath(templateName), "utf8");
|
|
2515
|
-
const path = join10(jobDir, "prompts", outName);
|
|
2516
|
-
writeFileSync6(path, agentizePrompt(text, jobDir, stem), "utf8");
|
|
2517
|
-
return path;
|
|
2518
|
-
}
|
|
2519
|
-
function writeSilencePrompt(jobDir, stem, opts) {
|
|
2520
|
-
const tpath = transcriptPath(jobDir, stem);
|
|
2521
|
-
const text = `You are an expert video editor using the \`video-editing\` CLI.
|
|
2522
|
-
|
|
2523
|
-
The CLI has already prepared the job. Your job is to write ${jobDir}/edl.json. Use ONLY the \`video-editing\` CLI for execution \u2014 never run video-use helpers or ffmpeg directly.
|
|
2524
|
-
|
|
2525
|
-
Inputs (already generated):
|
|
2526
|
-
- Master: ${jobDir}/vertical_src.mp4
|
|
2527
|
-
- Transcript: ${tpath}
|
|
2528
|
-
- Packed transcript: ${jobDir}/takes_packed.md
|
|
2529
|
-
- Flags: ${jobDir}/flags.txt
|
|
2530
|
-
- Word dump: ${jobDir}/word_dump.txt
|
|
2531
|
-
|
|
2532
|
-
Brief:
|
|
2533
|
-
- Cut silences only.
|
|
2534
|
-
- Keep every spoken word and every complete phrase.
|
|
2535
|
-
- Trim dead air at the head and outro.
|
|
2536
|
-
- For inter-phrase gaps >= ${opts.minGap.toFixed(2)}s, tighten toward about ${opts.targetGap.toFixed(2)}s of air.
|
|
2537
|
-
- Snap every cut to word boundaries from the ElevenLabs Scribe transcript.
|
|
2538
|
-
- Use lead pad about ${opts.leadPad.toFixed(2)}s and trail pad about ${opts.trailPad.toFixed(2)}s, staying within clean silence.
|
|
2539
|
-
- Output clean cuts only: grade "none", overlays [], no subtitles.
|
|
2540
|
-
|
|
2541
|
-
${learnedEditingGuardrails(jobDir, stem)}
|
|
2542
|
-
|
|
2543
|
-
Write exactly this EDL schema:
|
|
2544
|
-
{
|
|
2545
|
-
"version": 1,
|
|
2546
|
-
"sources": {"SRC": "${jobDir}/vertical_src.mp4"},
|
|
2547
|
-
"grade": "none",
|
|
2548
|
-
"ranges": [
|
|
2549
|
-
{"source": "SRC", "start": 0.0, "end": 1.0, "beat": "speech 1", "quote": "...", "reason": "..."}
|
|
2550
|
-
],
|
|
2551
|
-
"overlays": [],
|
|
2552
|
-
"total_duration_s": 0.0
|
|
2553
|
-
}
|
|
2554
|
-
|
|
2555
|
-
After writing the EDL, run:
|
|
2556
|
-
video-editing render --job-dir ${jobDir} --mode preview
|
|
2557
|
-
video-editing verify --job-dir ${jobDir} --json
|
|
2558
|
-
`;
|
|
2559
|
-
const path = join10(jobDir, "prompts", "cut-silences.prompt.md");
|
|
2560
|
-
writeFileSync6(path, text, "utf8");
|
|
2561
|
-
return path;
|
|
2562
|
-
}
|
|
2563
|
-
|
|
2564
|
-
// src/timeline-v2/schema.ts
|
|
2565
|
-
import { z } from "zod";
|
|
2566
|
-
var provenanceSchema = z.object({
|
|
2567
|
-
round: z.number().int().nullable().default(null),
|
|
2568
|
-
changeId: z.string().nullable().default(null),
|
|
2569
|
-
actor: z.enum(["agent", "user"]).default("agent")
|
|
2570
|
-
}).strict().default({ round: null, changeId: null, actor: "agent" });
|
|
2571
|
-
var wordsAnchorSchema = z.object({
|
|
2572
|
-
type: z.literal("words"),
|
|
2573
|
-
source: z.string().min(1),
|
|
2574
|
-
phrase: z.string().min(1),
|
|
2575
|
-
/** 1-based occurrence index when the phrase appears more than once. */
|
|
2576
|
-
occurrence: z.number().int().min(1).optional(),
|
|
2577
|
-
/** Cached resolution in SOURCE seconds. */
|
|
2578
|
-
resolved: z.object({ start: z.number().min(0), end: z.number().min(0) }).strict().optional(),
|
|
2579
|
-
/** Collapse the span to one instant (phrase start or end). */
|
|
2580
|
-
edge: z.enum(["start", "end"]).optional()
|
|
2581
|
-
}).strict();
|
|
2582
|
-
var sourceAnchorSchema = z.object({
|
|
2583
|
-
type: z.literal("source"),
|
|
2584
|
-
source: z.string().min(1),
|
|
2585
|
-
start: z.number().min(0),
|
|
2586
|
-
end: z.number().min(0)
|
|
2587
|
-
}).strict();
|
|
2588
|
-
var outputAnchorSchema = z.object({
|
|
2589
|
-
type: z.literal("output"),
|
|
2590
|
-
at: z.union([z.enum(["start", "end", "full"]), z.object({ after: z.string().min(1) }).strict()]),
|
|
2591
|
-
durationS: z.number().positive().optional()
|
|
2592
|
-
}).strict();
|
|
2593
|
-
var anchorSchema = z.discriminatedUnion("type", [
|
|
2594
|
-
wordsAnchorSchema,
|
|
2595
|
-
sourceAnchorSchema,
|
|
2596
|
-
outputAnchorSchema
|
|
2597
|
-
]);
|
|
2598
|
-
var timelineSourceSchema = z.object({
|
|
2599
|
-
/** Path (absolute or job-dir-relative) to the media file. */
|
|
2600
|
-
path: z.string().min(1),
|
|
2601
|
-
durationS: z.number().positive().optional(),
|
|
2602
|
-
/** "sha256:<hex>" of the transcript JSON this doc's cached anchor
|
|
2603
|
-
* resolutions were computed against. */
|
|
2604
|
-
transcriptHash: z.string().optional()
|
|
2605
|
-
}).strict();
|
|
2606
|
-
var desilenceSettingsSchema = z.object({
|
|
2607
|
-
noiseDb: z.number(),
|
|
2608
|
-
minSilence: z.number().positive(),
|
|
2609
|
-
targetGap: z.number().min(0),
|
|
2610
|
-
leadKeep: z.number().min(0),
|
|
2611
|
-
trailKeep: z.number().min(0),
|
|
2612
|
-
minSegment: z.number().min(0)
|
|
2613
|
-
}).partial().strict();
|
|
2614
|
-
var gradeSettingSchema = z.object({ preset: z.string(), intensity: z.number().min(0).max(1).optional() }).strict();
|
|
2615
|
-
var settingsSchema = z.object({
|
|
2616
|
-
canvas: z.object({ w: z.number().int().positive(), h: z.number().int().positive() }).strict().default({ w: 1080, h: 1920 }),
|
|
2617
|
-
/** "none" | preset name | raw ffmpeg filter | "auto" (same as EDL grade),
|
|
2618
|
-
* or a declarative {preset, intensity?} grade for the composition path. */
|
|
2619
|
-
grade: z.union([z.string(), gradeSettingSchema]).default("none"),
|
|
2620
|
-
loudnorm: z.boolean().default(true),
|
|
2621
|
-
/** Optional DesilenceOptions subset; merged over DEFAULT_DESILENCE. */
|
|
2622
|
-
desilence: desilenceSettingsSchema.optional()
|
|
2623
|
-
}).strict().default({ canvas: { w: 1080, h: 1920 }, grade: "none", loudnorm: true });
|
|
2624
|
-
var cutRangeSchema = z.object({
|
|
2625
|
-
id: z.string().min(1),
|
|
2626
|
-
source: z.string().min(1),
|
|
2627
|
-
start: z.number().min(0),
|
|
2628
|
-
end: z.number().min(0),
|
|
2629
|
-
beat: z.string().optional(),
|
|
2630
|
-
quote: z.string().optional(),
|
|
2631
|
-
reason: z.string().optional(),
|
|
2632
|
-
/** Optional per-range grade override (kept for v1 EDL round-trips). */
|
|
2633
|
-
grade: z.string().optional(),
|
|
2634
|
-
provenance: provenanceSchema
|
|
2635
|
-
}).strict();
|
|
2636
|
-
var cutSchema = z.object({ ranges: z.array(cutRangeSchema).default([]) }).strict().default({ ranges: [] });
|
|
2637
|
-
var CSS_COLOR_RE = /^(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|[a-zA-Z]+)$/;
|
|
2638
|
-
var captionOverrideSchema = z.object({
|
|
2639
|
-
id: z.string().min(1),
|
|
2640
|
-
anchor: wordsAnchorSchema,
|
|
2641
|
-
op: z.enum(["replace-text", "hide", "break-line", "style"]),
|
|
2642
|
-
text: z.string().optional(),
|
|
2643
|
-
/** op:"style" — per-word color (hex or simple CSS color name). */
|
|
2644
|
-
color: z.string().regex(CSS_COLOR_RE, "must be a hex color or a simple CSS color name").optional(),
|
|
2645
|
-
/** op:"style" — per-word animated emphasis at the word's onset. */
|
|
2646
|
-
emphasis: z.enum(["pop", "highlight"]).optional(),
|
|
2647
|
-
provenance: provenanceSchema
|
|
2648
|
-
}).strict();
|
|
2649
|
-
var captionPresetSchema = z.enum(["grouped", "opus-karaoke"]);
|
|
2650
|
-
var captionStyleSchema = z.object({
|
|
2651
|
-
/** Max words per grouped cue (preset "grouped" only). */
|
|
2652
|
-
maxWords: z.number().int().min(1).max(20).optional(),
|
|
2653
|
-
/** Inter-word gap (seconds) that ends a phrase — a hard cue boundary
|
|
2654
|
-
* (preset "grouped" only). */
|
|
2655
|
-
gapBreakS: z.number().min(0).max(5).optional(),
|
|
2656
|
-
/** Seconds each cue's display start is shifted EARLIER (clamped to the
|
|
2657
|
-
* previous cue's end / the segment's outStart). */
|
|
2658
|
-
leadS: z.number().min(0).max(1).optional(),
|
|
2659
|
-
/** Max seconds a cue is held past its last word to bridge the gap to the
|
|
2660
|
-
* next cue (never past the segment's outEnd). */
|
|
2661
|
-
holdS: z.number().min(0).max(5).optional(),
|
|
2662
|
-
/** Per-word highlight sweep within grouped cues (karaoke-style). */
|
|
2663
|
-
karaoke: z.boolean().optional()
|
|
2664
|
-
}).passthrough();
|
|
2665
|
-
var CAPTION_STYLE_DEFAULTS = { maxWords: 4, gapBreakS: 0.6, leadS: 0.08, holdS: 0.9 };
|
|
2666
|
-
var captionsSchema = z.object({
|
|
2667
|
-
enabled: z.boolean().default(false),
|
|
2668
|
-
preset: captionPresetSchema.default("grouped"),
|
|
2669
|
-
style: captionStyleSchema.default({}),
|
|
2670
|
-
overrides: z.array(captionOverrideSchema).default([])
|
|
2671
|
-
}).strict().default({ enabled: false, preset: "grouped", style: {}, overrides: [] });
|
|
2672
|
-
var onCutSchema = z.enum(["clip", "drop", "error"]);
|
|
2673
|
-
var zoomRectSchema = z.object({
|
|
2674
|
-
x: z.number().min(0).max(1),
|
|
2675
|
-
y: z.number().min(0).max(1),
|
|
2676
|
-
w: z.number().min(0).max(1),
|
|
2677
|
-
h: z.number().min(0).max(1)
|
|
2678
|
-
}).strict();
|
|
2679
|
-
var zoomSchema = z.object({
|
|
2680
|
-
id: z.string().min(1),
|
|
2681
|
-
anchor: anchorSchema,
|
|
2682
|
-
onCut: onCutSchema.default("clip"),
|
|
2683
|
-
rect: zoomRectSchema,
|
|
2684
|
-
scale: z.number().gt(1).lte(3),
|
|
2685
|
-
/** Ramp target: when present, the zoom animates from the base rect/scale
|
|
2686
|
-
* to `to` over the zoom window (`ease` shapes the ramp). */
|
|
2687
|
-
to: z.object({
|
|
2688
|
-
rect: zoomRectSchema.optional(),
|
|
2689
|
-
scale: z.number().gt(1).lte(3).optional()
|
|
2690
|
-
}).strict().optional(),
|
|
2691
|
-
ease: z.object({ in: z.number().min(0), out: z.number().min(0) }).strict().default({ in: 0.25, out: 0.25 }),
|
|
2692
|
-
provenance: provenanceSchema
|
|
2693
|
-
}).strict();
|
|
2694
|
-
var assetRefSchema = z.object({ ref: z.string().min(1) }).strict();
|
|
2695
|
-
var overlayFxSchema = z.object({
|
|
2696
|
-
preset: z.enum(["fade", "pop", "slide-up", "slide-down", "scale-in"]),
|
|
2697
|
-
durationS: z.number().positive().max(2)
|
|
2698
|
-
}).strict();
|
|
2699
|
-
var overlaySchema = z.object({
|
|
2700
|
-
id: z.string().min(1),
|
|
2701
|
-
kind: z.enum(["text", "title-card", "image", "sticker"]),
|
|
2702
|
-
anchor: anchorSchema,
|
|
2703
|
-
onCut: onCutSchema.default("drop"),
|
|
2704
|
-
layout: z.object({
|
|
2705
|
-
x: z.number().min(0).max(1).default(0.5),
|
|
2706
|
-
y: z.number().min(0).max(1).default(0.82),
|
|
2707
|
-
align: z.enum(["left", "center", "right"]).default("center"),
|
|
2708
|
-
safeArea: z.boolean().default(true)
|
|
2709
|
-
}).strict().default({ x: 0.5, y: 0.82, align: "center", safeArea: true }),
|
|
2710
|
-
text: z.string().optional(),
|
|
2711
|
-
style: z.object({ preset: z.string().optional() }).passthrough().default({}),
|
|
2712
|
-
durationS: z.number().positive().optional(),
|
|
2713
|
-
asset: assetRefSchema.optional(),
|
|
2714
|
-
/** Entrance/exit presets (image/sticker overlays). */
|
|
2715
|
-
enter: overlayFxSchema.optional(),
|
|
2716
|
-
exit: overlayFxSchema.optional(),
|
|
2717
|
-
/** Size as canvas fractions (w of canvas width, h of canvas height). */
|
|
2718
|
-
size: z.object({
|
|
2719
|
-
w: z.number().min(0).max(1).optional(),
|
|
2720
|
-
h: z.number().min(0).max(1).optional()
|
|
2721
|
-
}).strict().optional(),
|
|
2722
|
-
/** Stacking order (CSS z-index); temporal lanes stay non-overlapping. */
|
|
2723
|
-
z: z.number().int().min(0).max(50).optional(),
|
|
2724
|
-
provenance: provenanceSchema
|
|
2725
|
-
}).strict();
|
|
2726
|
-
var audioSchema = z.object({
|
|
2727
|
-
id: z.string().min(1),
|
|
2728
|
-
kind: z.enum(["music", "sfx"]),
|
|
2729
|
-
asset: assetRefSchema,
|
|
2730
|
-
anchor: anchorSchema,
|
|
2731
|
-
gainDb: z.number().default(-18),
|
|
2732
|
-
duckToSpeech: z.boolean().default(true),
|
|
2733
|
-
fadeOutS: z.number().min(0).optional(),
|
|
2734
|
-
provenance: provenanceSchema
|
|
2735
|
-
}).strict();
|
|
2736
|
-
var timelineV2Schema = z.object({
|
|
2737
|
-
version: z.literal(2),
|
|
2738
|
-
id: z.string().regex(/^tl_/, 'timeline id must start with "tl_"'),
|
|
2739
|
-
sources: z.record(timelineSourceSchema).refine((s) => Object.keys(s).length > 0, {
|
|
2740
|
-
message: "at least one source is required"
|
|
2741
|
-
}),
|
|
2742
|
-
settings: settingsSchema,
|
|
2743
|
-
cut: cutSchema,
|
|
2744
|
-
captions: captionsSchema,
|
|
2745
|
-
zooms: z.array(zoomSchema).default([]),
|
|
2746
|
-
overlays: z.array(overlaySchema).default([]),
|
|
2747
|
-
audio: z.array(audioSchema).default([])
|
|
2748
|
-
}).strict();
|
|
2749
|
-
function emptyDiagnostics() {
|
|
2750
|
-
return { errors: [], warnings: [], dropped: [] };
|
|
2751
|
-
}
|
|
2752
|
-
function parseTimeline(raw) {
|
|
2753
|
-
if (raw !== null && typeof raw === "object" && typeof raw.captions === "object" && raw.captions !== null && "cues" in raw.captions) {
|
|
2754
|
-
return {
|
|
2755
|
-
doc: null,
|
|
2756
|
-
errors: [
|
|
2757
|
-
{
|
|
2758
|
-
path: "captions.cues",
|
|
2759
|
-
code: "captions-authored-cues",
|
|
2760
|
-
message: "caption cues are derived by compile and must never be stored in timeline.json",
|
|
2761
|
-
suggestion: "remove captions.cues; author only enabled/preset/style/overrides"
|
|
2762
|
-
}
|
|
2763
|
-
]
|
|
2764
|
-
};
|
|
2765
|
-
}
|
|
2766
|
-
const parsed = timelineV2Schema.safeParse(raw);
|
|
2767
|
-
if (!parsed.success) {
|
|
2768
|
-
return {
|
|
2769
|
-
doc: null,
|
|
2770
|
-
errors: parsed.error.issues.map((iss) => ({
|
|
2771
|
-
path: iss.path.map((p) => typeof p === "number" ? `[${p}]` : p).join(".").replace(/\.\[/g, "["),
|
|
2772
|
-
code: `schema:${iss.code}`,
|
|
2773
|
-
message: iss.message
|
|
2774
|
-
}))
|
|
2775
|
-
};
|
|
2776
|
-
}
|
|
2777
|
-
return { doc: parsed.data, errors: [] };
|
|
2778
|
-
}
|
|
2779
|
-
var EPS2 = 1e-6;
|
|
2780
|
-
function itemIds(doc) {
|
|
2781
|
-
return [
|
|
2782
|
-
...doc.cut.ranges.map((r) => r.id),
|
|
2783
|
-
...doc.captions.overrides.map((o) => o.id),
|
|
2784
|
-
...doc.zooms.map((zm) => zm.id),
|
|
2785
|
-
...doc.overlays.map((ov) => ov.id),
|
|
2786
|
-
...doc.audio.map((a) => a.id)
|
|
2787
|
-
];
|
|
2788
|
-
}
|
|
2789
|
-
function checkAnchorSource(anchor, doc, path, errors) {
|
|
2790
|
-
if (anchor.type === "output") return;
|
|
2791
|
-
if (!(anchor.source in doc.sources)) {
|
|
2792
|
-
errors.push({
|
|
2793
|
-
path: `${path}.source`,
|
|
2794
|
-
code: "unknown-source",
|
|
2795
|
-
message: `anchor references unknown source "${anchor.source}"`,
|
|
2796
|
-
suggestion: `known sources: ${Object.keys(doc.sources).join(", ")}`
|
|
2797
|
-
});
|
|
2798
|
-
}
|
|
2799
|
-
if (anchor.type === "source" && anchor.end <= anchor.start) {
|
|
2800
|
-
errors.push({
|
|
2801
|
-
path,
|
|
2802
|
-
code: "bad-anchor-span",
|
|
2803
|
-
message: `source anchor end (${anchor.end}) must be greater than start (${anchor.start})`
|
|
2804
|
-
});
|
|
2805
|
-
}
|
|
2806
|
-
}
|
|
2807
|
-
function validateTimeline(doc) {
|
|
2808
|
-
const errors = [];
|
|
2809
|
-
const warnings = [];
|
|
2810
|
-
const seen = /* @__PURE__ */ new Map();
|
|
2811
|
-
for (const id of itemIds(doc)) seen.set(id, (seen.get(id) ?? 0) + 1);
|
|
2812
|
-
for (const [id, count] of seen) {
|
|
2813
|
-
if (count > 1) {
|
|
2814
|
-
errors.push({
|
|
2815
|
-
path: "(items)",
|
|
2816
|
-
code: "duplicate-id",
|
|
2817
|
-
message: `item id "${id}" is used ${count} times; ids must be unique across all items`
|
|
2818
|
-
});
|
|
2819
|
-
}
|
|
2820
|
-
}
|
|
2821
|
-
const lastEndBySource = /* @__PURE__ */ new Map();
|
|
2822
|
-
doc.cut.ranges.forEach((r, i) => {
|
|
2823
|
-
const path = `cut.ranges[${i}]`;
|
|
2824
|
-
if (!(r.source in doc.sources)) {
|
|
2825
|
-
errors.push({
|
|
2826
|
-
path: `${path}.source`,
|
|
2827
|
-
code: "unknown-source",
|
|
2828
|
-
message: `cut range "${r.id}" references unknown source "${r.source}"`,
|
|
2829
|
-
suggestion: `known sources: ${Object.keys(doc.sources).join(", ")}`
|
|
2830
|
-
});
|
|
2831
|
-
return;
|
|
2832
|
-
}
|
|
2833
|
-
if (r.end <= r.start) {
|
|
2834
|
-
errors.push({
|
|
2835
|
-
path,
|
|
2836
|
-
code: "bad-range",
|
|
2837
|
-
message: `cut range "${r.id}" has end (${r.end}) <= start (${r.start})`
|
|
2838
|
-
});
|
|
2839
|
-
return;
|
|
2840
|
-
}
|
|
2841
|
-
const dur2 = doc.sources[r.source].durationS;
|
|
2842
|
-
if (dur2 !== void 0 && r.end > dur2 + EPS2) {
|
|
2843
|
-
errors.push({
|
|
2844
|
-
path: `${path}.end`,
|
|
2845
|
-
code: "range-out-of-bounds",
|
|
2846
|
-
message: `cut range "${r.id}" ends at ${r.end}s but source "${r.source}" is ${dur2}s long`
|
|
2847
|
-
});
|
|
2848
|
-
}
|
|
2849
|
-
const prev = lastEndBySource.get(r.source);
|
|
2850
|
-
if (prev !== void 0) {
|
|
2851
|
-
if (r.start < prev.end - EPS2) {
|
|
2852
|
-
const code = r.start <= doc.cut.ranges[prev.index].start + EPS2 ? "range-order" : "range-overlap";
|
|
2853
|
-
errors.push({
|
|
2854
|
-
path,
|
|
2855
|
-
code,
|
|
2856
|
-
message: code === "range-order" ? `cut range "${r.id}" is out of order: ranges of source "${r.source}" must be sorted by start` : `cut range "${r.id}" overlaps the previous range of source "${r.source}" (starts at ${r.start} before previous end ${prev.end})`
|
|
2857
|
-
});
|
|
2858
|
-
}
|
|
2859
|
-
}
|
|
2860
|
-
lastEndBySource.set(r.source, { end: r.end, index: i });
|
|
2861
|
-
});
|
|
2862
|
-
doc.captions.overrides.forEach((o, i) => {
|
|
2863
|
-
checkAnchorSource(o.anchor, doc, `captions.overrides[${i}].anchor`, errors);
|
|
2864
|
-
if (o.op === "replace-text" && (o.text === void 0 || o.text.length === 0)) {
|
|
2865
|
-
errors.push({
|
|
2866
|
-
path: `captions.overrides[${i}].text`,
|
|
2867
|
-
code: "missing-text",
|
|
2868
|
-
message: `caption override "${o.id}" has op "replace-text" but no text`
|
|
2869
|
-
});
|
|
2870
|
-
}
|
|
2871
|
-
if (o.op === "style" && o.color === void 0 && o.emphasis === void 0) {
|
|
2872
|
-
errors.push({
|
|
2873
|
-
path: `captions.overrides[${i}]`,
|
|
2874
|
-
code: "missing-style",
|
|
2875
|
-
message: `caption override "${o.id}" has op "style" but neither color nor emphasis`,
|
|
2876
|
-
suggestion: 'set color (e.g. "#ff5c5c") and/or emphasis ("pop" | "highlight")'
|
|
2877
|
-
});
|
|
2878
|
-
}
|
|
2879
|
-
});
|
|
2880
|
-
doc.zooms.forEach((zm, i) => {
|
|
2881
|
-
const path = `zooms[${i}]`;
|
|
2882
|
-
checkAnchorSource(zm.anchor, doc, `${path}.anchor`, errors);
|
|
2883
|
-
if (zm.rect.x + zm.rect.w > 1 + EPS2 || zm.rect.y + zm.rect.h > 1 + EPS2) {
|
|
2884
|
-
errors.push({
|
|
2885
|
-
path: `${path}.rect`,
|
|
2886
|
-
code: "bad-rect",
|
|
2887
|
-
message: `zoom "${zm.id}" rect exceeds the unit canvas (x+w=${zm.rect.x + zm.rect.w}, y+h=${zm.rect.y + zm.rect.h})`,
|
|
2888
|
-
suggestion: "keep x+w <= 1 and y+h <= 1"
|
|
2889
|
-
});
|
|
2890
|
-
}
|
|
2891
|
-
if (zm.to !== void 0) {
|
|
2892
|
-
if (zm.to.rect === void 0 && zm.to.scale === void 0) {
|
|
2893
|
-
errors.push({
|
|
2894
|
-
path: `${path}.to`,
|
|
2895
|
-
code: "empty-ramp",
|
|
2896
|
-
message: `zoom "${zm.id}" has a ramp target "to" with neither rect nor scale`,
|
|
2897
|
-
suggestion: "set to.rect and/or to.scale, or remove to"
|
|
2898
|
-
});
|
|
2899
|
-
}
|
|
2900
|
-
const tr = zm.to.rect;
|
|
2901
|
-
if (tr && (tr.x + tr.w > 1 + EPS2 || tr.y + tr.h > 1 + EPS2)) {
|
|
2902
|
-
errors.push({
|
|
2903
|
-
path: `${path}.to.rect`,
|
|
2904
|
-
code: "bad-rect",
|
|
2905
|
-
message: `zoom "${zm.id}" to.rect exceeds the unit canvas (x+w=${tr.x + tr.w}, y+h=${tr.y + tr.h})`,
|
|
2906
|
-
suggestion: "keep x+w <= 1 and y+h <= 1"
|
|
2907
|
-
});
|
|
2908
|
-
}
|
|
2909
|
-
}
|
|
2910
|
-
});
|
|
2911
|
-
doc.overlays.forEach((ov, i) => {
|
|
2912
|
-
const path = `overlays[${i}]`;
|
|
2913
|
-
checkAnchorSource(ov.anchor, doc, `${path}.anchor`, errors);
|
|
2914
|
-
if ((ov.kind === "image" || ov.kind === "sticker") && !ov.asset) {
|
|
2915
|
-
errors.push({
|
|
2916
|
-
path: `${path}.asset`,
|
|
2917
|
-
code: "missing-asset",
|
|
2918
|
-
message: `overlay "${ov.id}" has kind "${ov.kind}" but no asset`
|
|
2919
|
-
});
|
|
2920
|
-
}
|
|
2921
|
-
if ((ov.kind === "text" || ov.kind === "title-card") && (ov.text === void 0 || ov.text.length === 0)) {
|
|
2922
|
-
errors.push({
|
|
2923
|
-
path: `${path}.text`,
|
|
2924
|
-
code: "missing-text",
|
|
2925
|
-
message: `overlay "${ov.id}" has kind "${ov.kind}" but no text`
|
|
2926
|
-
});
|
|
2927
|
-
}
|
|
2928
|
-
});
|
|
2929
|
-
doc.audio.forEach((a, i) => {
|
|
2930
|
-
checkAnchorSource(a.anchor, doc, `audio[${i}].anchor`, errors);
|
|
2931
|
-
});
|
|
2932
|
-
const fullAudio = doc.audio.filter((a) => a.anchor.type === "output" && a.anchor.at === "full");
|
|
2933
|
-
if (fullAudio.length > 1) {
|
|
2934
|
-
errors.push({
|
|
2935
|
-
path: "audio",
|
|
2936
|
-
code: "multiple-full-audio",
|
|
2937
|
-
message: `at most one audio item may use at:"full" (found ${fullAudio.length}: ${fullAudio.map((a) => a.id).join(", ")})`
|
|
2938
|
-
});
|
|
2939
|
-
}
|
|
2940
|
-
const ids = new Set(itemIds(doc));
|
|
2941
|
-
const checkAfter = (anchor, ownerId, path) => {
|
|
2942
|
-
if (anchor.type !== "output" || typeof anchor.at === "string") return;
|
|
2943
|
-
const target = anchor.at.after;
|
|
2944
|
-
if (!ids.has(target)) {
|
|
2945
|
-
errors.push({
|
|
2946
|
-
path,
|
|
2947
|
-
code: "unknown-after-target",
|
|
2948
|
-
message: `after:"${target}" does not reference any existing item id`
|
|
2949
|
-
});
|
|
2950
|
-
} else if (target === ownerId) {
|
|
2951
|
-
errors.push({
|
|
2952
|
-
path,
|
|
2953
|
-
code: "after-cycle",
|
|
2954
|
-
message: `item "${ownerId}" cannot be anchored after itself`
|
|
2955
|
-
});
|
|
2956
|
-
}
|
|
2957
|
-
};
|
|
2958
|
-
doc.zooms.forEach((zm, i) => checkAfter(zm.anchor, zm.id, `zooms[${i}].anchor.at.after`));
|
|
2959
|
-
doc.overlays.forEach((ov, i) => checkAfter(ov.anchor, ov.id, `overlays[${i}].anchor.at.after`));
|
|
2960
|
-
doc.audio.forEach((a, i) => checkAfter(a.anchor, a.id, `audio[${i}].anchor.at.after`));
|
|
2961
|
-
return { errors, warnings };
|
|
2962
|
-
}
|
|
2963
|
-
|
|
2964
|
-
// src/timeline-v2/capabilities.ts
|
|
2965
|
-
var CAPABILITIES = {
|
|
2966
|
-
cut: { status: "ready" },
|
|
2967
|
-
"captions.karaoke": { status: "ready" },
|
|
2968
|
-
// Per-word color/emphasis styling — rendered by composeHtml (render-proven).
|
|
2969
|
-
"captions.word-style": { status: "ready" },
|
|
2970
|
-
// Karaoke per-word sweep compiles + composes, but the sweep itself has not
|
|
2971
|
-
// been through a validation render yet.
|
|
2972
|
-
"captions.karaoke-highlight": { status: "staged" },
|
|
2973
|
-
zoom: { status: "ready" },
|
|
2974
|
-
// Animated zoom ramps (base → to over the window) — render-proven GSAP path.
|
|
2975
|
-
"zoom.ramp": { status: "ready" },
|
|
2976
|
-
"overlay.text": { status: "ready" },
|
|
2977
|
-
"overlay.title-card": { status: "ready" },
|
|
2978
|
-
"overlay.image": { status: "ready" },
|
|
2979
|
-
"overlay.sticker": { status: "ready" },
|
|
2980
|
-
"overlay.enter-exit": { status: "ready" },
|
|
2981
|
-
// Audio beds/sfx are schema-complete but the render integration is staged.
|
|
2982
|
-
"audio.music": { status: "staged" },
|
|
2983
|
-
"audio.sfx": { status: "staged" },
|
|
2984
|
-
// Declarative color grade (data-color-grading) — not yet render-proven.
|
|
2985
|
-
grade: { status: "staged" }
|
|
2986
|
-
};
|
|
2987
|
-
function usesFromDoc(doc) {
|
|
2988
|
-
const uses = [];
|
|
2989
|
-
const add = (k) => {
|
|
2990
|
-
if (!uses.includes(k)) uses.push(k);
|
|
2991
|
-
};
|
|
2992
|
-
if (doc.cut.ranges.length > 0) add("cut");
|
|
2993
|
-
if (doc.captions.enabled) {
|
|
2994
|
-
add("captions.karaoke");
|
|
2995
|
-
if (doc.captions.overrides.some((o) => o.op === "style")) add("captions.word-style");
|
|
2996
|
-
if (doc.captions.style.karaoke === true) add("captions.karaoke-highlight");
|
|
2997
|
-
}
|
|
2998
|
-
if (doc.zooms.length > 0) add("zoom");
|
|
2999
|
-
if (doc.zooms.some((zm) => zm.to !== void 0)) add("zoom.ramp");
|
|
3000
|
-
for (const ov of doc.overlays) {
|
|
3001
|
-
add(`overlay.${ov.kind}`);
|
|
3002
|
-
if (ov.enter !== void 0 || ov.exit !== void 0) add("overlay.enter-exit");
|
|
3003
|
-
}
|
|
3004
|
-
for (const a of doc.audio) add(`audio.${a.kind}`);
|
|
3005
|
-
if (typeof doc.settings.grade === "object") add("grade");
|
|
3006
|
-
return uses;
|
|
3007
|
-
}
|
|
3008
|
-
function checkCapabilities(uses, registry = CAPABILITIES) {
|
|
3009
|
-
const errors = [];
|
|
3010
|
-
const warnings = [];
|
|
3011
|
-
for (const key of uses) {
|
|
3012
|
-
const status = registry[key]?.status ?? "none";
|
|
3013
|
-
if (status === "none") {
|
|
3014
|
-
errors.push({
|
|
3015
|
-
path: "(capabilities)",
|
|
3016
|
-
code: "capability-none",
|
|
3017
|
-
message: `feature "${key}" is used by this timeline but is not available in this build`,
|
|
3018
|
-
suggestion: "remove the items using it, or upgrade the renderer",
|
|
3019
|
-
context: { feature: key, status }
|
|
3020
|
-
});
|
|
3021
|
-
} else if (status === "staged") {
|
|
3022
|
-
warnings.push({
|
|
3023
|
-
path: "(capabilities)",
|
|
3024
|
-
code: "capability-staged",
|
|
3025
|
-
message: `feature "${key}" is staged: it compiles into the plan but the render path may not honor it yet`,
|
|
3026
|
-
context: { feature: key, status }
|
|
3027
|
-
});
|
|
3028
|
-
}
|
|
3029
|
-
}
|
|
3030
|
-
return { errors, warnings };
|
|
3031
|
-
}
|
|
3032
|
-
|
|
3033
|
-
// src/timeline-v2/anchors.ts
|
|
3034
|
-
function normalizeToken(text) {
|
|
3035
|
-
return text.trim().toLowerCase().replace(/^[^\p{L}\p{N}]+/gu, "").replace(/[^\p{L}\p{N}]+$/gu, "");
|
|
3036
|
-
}
|
|
3037
|
-
function normalizePhrase(phrase) {
|
|
3038
|
-
return phrase.split(/\s+/).map(normalizeToken).filter((t) => t.length > 0);
|
|
3039
|
-
}
|
|
3040
|
-
function wordStream(transcript) {
|
|
3041
|
-
const words = [];
|
|
3042
|
-
for (const w of transcript.words ?? []) {
|
|
3043
|
-
if ((w.type ?? "word") !== "word") continue;
|
|
3044
|
-
if (typeof w.start !== "number" || typeof w.end !== "number") continue;
|
|
3045
|
-
words.push(w);
|
|
3046
|
-
}
|
|
3047
|
-
words.sort((a, b) => Number(a.start) - Number(b.start) || Number(a.end) - Number(b.end));
|
|
3048
|
-
return words.map((w, i) => {
|
|
3049
|
-
const text = (w.text ?? "").trim();
|
|
3050
|
-
return { index: i, start: Number(w.start), end: Number(w.end), text, norm: normalizeToken(text) };
|
|
3051
|
-
});
|
|
3052
|
-
}
|
|
3053
|
-
function contextAround(stream, from, to, pad = 5) {
|
|
3054
|
-
const before = stream.slice(Math.max(0, from - pad), from).map((w) => w.text);
|
|
3055
|
-
const matched = stream.slice(from, to + 1).map((w) => w.text);
|
|
3056
|
-
const after = stream.slice(to + 1, to + 1 + pad).map((w) => w.text);
|
|
3057
|
-
const parts = [];
|
|
3058
|
-
if (from - pad > 0) parts.push("\u2026");
|
|
3059
|
-
parts.push(...before, `\xAB${matched.join(" ")}\xBB`, ...after);
|
|
3060
|
-
if (to + 1 + pad < stream.length) parts.push("\u2026");
|
|
3061
|
-
return parts.join(" ");
|
|
3062
|
-
}
|
|
3063
|
-
function findPhrase(stream, phrase) {
|
|
3064
|
-
const tokens = normalizePhrase(phrase);
|
|
3065
|
-
if (tokens.length === 0) return [];
|
|
3066
|
-
const matches = [];
|
|
3067
|
-
for (let i = 0; i + tokens.length <= stream.length; i++) {
|
|
3068
|
-
let ok = true;
|
|
3069
|
-
for (let j = 0; j < tokens.length; j++) {
|
|
3070
|
-
if (stream[i + j].norm !== tokens[j]) {
|
|
3071
|
-
ok = false;
|
|
3072
|
-
break;
|
|
3073
|
-
}
|
|
3074
|
-
}
|
|
3075
|
-
if (!ok) continue;
|
|
3076
|
-
const last = i + tokens.length - 1;
|
|
3077
|
-
matches.push({
|
|
3078
|
-
occurrence: matches.length + 1,
|
|
3079
|
-
start: stream[i].start,
|
|
3080
|
-
end: stream[last].end,
|
|
3081
|
-
words: stream.slice(i, last + 1).map((w) => ({ start: w.start, end: w.end, text: w.text })),
|
|
3082
|
-
context: contextAround(stream, i, last),
|
|
3083
|
-
wordIndex: i
|
|
3084
|
-
});
|
|
3085
|
-
}
|
|
3086
|
-
return matches;
|
|
3087
|
-
}
|
|
3088
|
-
function levenshtein(a, b) {
|
|
3089
|
-
const m = a.length;
|
|
3090
|
-
const n = b.length;
|
|
3091
|
-
if (m === 0) return n;
|
|
3092
|
-
if (n === 0) return m;
|
|
3093
|
-
let prev = new Array(n + 1);
|
|
3094
|
-
let cur = new Array(n + 1);
|
|
3095
|
-
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
3096
|
-
for (let i = 1; i <= m; i++) {
|
|
3097
|
-
cur[0] = i;
|
|
3098
|
-
for (let j = 1; j <= n; j++) {
|
|
3099
|
-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
3100
|
-
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
|
|
3101
|
-
}
|
|
3102
|
-
[prev, cur] = [cur, prev];
|
|
3103
|
-
}
|
|
3104
|
-
return prev[n];
|
|
3105
|
-
}
|
|
3106
|
-
function fuzzySuggestions(stream, phrase, limit = 3) {
|
|
3107
|
-
const tokens = normalizePhrase(phrase);
|
|
3108
|
-
if (tokens.length === 0 || stream.length === 0) return [];
|
|
3109
|
-
const target = tokens.join(" ");
|
|
3110
|
-
const n = Math.min(tokens.length, stream.length);
|
|
3111
|
-
const scored = [];
|
|
3112
|
-
for (let i = 0; i + n <= stream.length; i++) {
|
|
3113
|
-
const window = stream.slice(i, i + n);
|
|
3114
|
-
const joined = window.map((w) => w.norm).join(" ");
|
|
3115
|
-
const dist = levenshtein(target, joined);
|
|
3116
|
-
const similarity = 1 - dist / Math.max(target.length, joined.length, 1);
|
|
3117
|
-
scored.push({
|
|
3118
|
-
start: window[0].start,
|
|
3119
|
-
end: window[n - 1].end,
|
|
3120
|
-
text: window.map((w) => w.text).join(" "),
|
|
3121
|
-
similarity: Math.round(similarity * 1e3) / 1e3
|
|
3122
|
-
});
|
|
3123
|
-
}
|
|
3124
|
-
scored.sort((a, b) => b.similarity - a.similarity || a.start - b.start);
|
|
3125
|
-
return scored.slice(0, limit).filter((s) => s.similarity > 0);
|
|
3126
|
-
}
|
|
3127
|
-
function resolvePhrase(stream, phrase, opts = {}) {
|
|
3128
|
-
const matches = findPhrase(stream, phrase);
|
|
3129
|
-
if (matches.length === 0) {
|
|
3130
|
-
return { status: "not-found", suggestions: fuzzySuggestions(stream, phrase) };
|
|
3131
|
-
}
|
|
3132
|
-
if (opts.occurrence !== void 0) {
|
|
3133
|
-
const m = matches[opts.occurrence - 1];
|
|
3134
|
-
if (!m) return { status: "ambiguous", candidates: matches };
|
|
3135
|
-
return { status: "ok", match: m, matches };
|
|
3136
|
-
}
|
|
3137
|
-
if (matches.length === 1) return { status: "ok", match: matches[0], matches };
|
|
3138
|
-
if (opts.nearS !== void 0) {
|
|
3139
|
-
const near = opts.nearS;
|
|
3140
|
-
let best = matches[0];
|
|
3141
|
-
for (const m of matches) {
|
|
3142
|
-
if (Math.abs(m.start - near) < Math.abs(best.start - near)) best = m;
|
|
3143
|
-
}
|
|
3144
|
-
return { status: "ok", match: best, matches };
|
|
3145
|
-
}
|
|
3146
|
-
return { status: "ambiguous", candidates: matches };
|
|
3147
|
-
}
|
|
3148
|
-
|
|
3149
|
-
// src/timeline-v2/compile.ts
|
|
3150
|
-
import { createHash } from "crypto";
|
|
3151
|
-
var EPS3 = 1e-3;
|
|
3152
|
-
function timelineHash(doc) {
|
|
3153
|
-
const canon = (v) => {
|
|
3154
|
-
if (Array.isArray(v)) return v.map(canon);
|
|
3155
|
-
if (v !== null && typeof v === "object") {
|
|
3156
|
-
const out = {};
|
|
3157
|
-
for (const k of Object.keys(v).sort()) {
|
|
3158
|
-
const val = v[k];
|
|
3159
|
-
if (val !== void 0) out[k] = canon(val);
|
|
3160
|
-
}
|
|
3161
|
-
return out;
|
|
3162
|
-
}
|
|
3163
|
-
return v;
|
|
3164
|
-
};
|
|
3165
|
-
const json = JSON.stringify(canon(doc));
|
|
3166
|
-
return "sha256:" + createHash("sha256").update(json, "utf8").digest("hex");
|
|
3167
|
-
}
|
|
3168
|
-
function r3(x) {
|
|
3169
|
-
return pyRound(x, 3);
|
|
3170
|
-
}
|
|
3171
|
-
function wordSpansFromTranscript(t) {
|
|
3172
|
-
if (!t) return [];
|
|
3173
|
-
const out = [];
|
|
3174
|
-
for (const w of t.words ?? []) {
|
|
3175
|
-
if (w.type !== "word") continue;
|
|
3176
|
-
if (typeof w.start === "number" && typeof w.end === "number" && w.end > w.start) {
|
|
3177
|
-
out.push({ start: w.start, end: w.end });
|
|
3178
|
-
}
|
|
3179
|
-
}
|
|
3180
|
-
return out;
|
|
3181
|
-
}
|
|
3182
|
-
function mapSpan(segments, source, a, b) {
|
|
3183
|
-
let covered = 0;
|
|
3184
|
-
let outStart = Infinity;
|
|
3185
|
-
let outEnd = -Infinity;
|
|
3186
|
-
const parts = [];
|
|
3187
|
-
segments.forEach((seg, segIndex) => {
|
|
3188
|
-
if (seg.source !== source) return;
|
|
3189
|
-
const srcA = Math.max(a, seg.srcStart);
|
|
3190
|
-
const srcB = Math.min(b, seg.srcEnd);
|
|
3191
|
-
const isPoint = b - a <= EPS3;
|
|
3192
|
-
if (isPoint ? !(a >= seg.srcStart - EPS3 && a <= seg.srcEnd + EPS3) : srcB - srcA <= 0) return;
|
|
3193
|
-
covered += Math.max(0, srcB - srcA);
|
|
3194
|
-
const oA = seg.outStart + (Math.max(srcA, seg.srcStart) - seg.srcStart);
|
|
3195
|
-
const oB = seg.outStart + (Math.min(srcB, seg.srcEnd) - seg.srcStart);
|
|
3196
|
-
outStart = Math.min(outStart, oA);
|
|
3197
|
-
outEnd = Math.max(outEnd, isPoint ? oA : oB);
|
|
3198
|
-
parts.push({ segIndex, srcA, srcB });
|
|
3199
|
-
});
|
|
3200
|
-
if (parts.length === 0) return { covered: 0, outStart: 0, outEnd: 0, parts };
|
|
3201
|
-
return { covered, outStart, outEnd, parts };
|
|
3202
|
-
}
|
|
3203
|
-
function compileTimeline(doc, ctx = {}) {
|
|
3204
|
-
const diagnostics = emptyDiagnostics();
|
|
3205
|
-
const uses = usesFromDoc(doc);
|
|
3206
|
-
const sem = validateTimeline(doc);
|
|
3207
|
-
diagnostics.errors.push(...sem.errors);
|
|
3208
|
-
diagnostics.warnings.push(...sem.warnings);
|
|
3209
|
-
const cap = checkCapabilities(uses, ctx.capabilities ?? CAPABILITIES);
|
|
3210
|
-
diagnostics.errors.push(...cap.errors);
|
|
3211
|
-
diagnostics.warnings.push(...cap.warnings);
|
|
3212
|
-
if (ctx.assetsPresent) {
|
|
3213
|
-
const checkAsset = (ref, path, id) => {
|
|
3214
|
-
if (ref === void 0 || ctx.assetsPresent[ref] !== false) return;
|
|
3215
|
-
diagnostics.errors.push({
|
|
3216
|
-
path,
|
|
3217
|
-
code: "asset-missing",
|
|
3218
|
-
message: `"${id}" references asset "${ref}" but no such file exists in the job dir`,
|
|
3219
|
-
suggestion: `place the file at <job>/${ref} (asset refs are job-dir-relative paths)`
|
|
3220
|
-
});
|
|
3221
|
-
};
|
|
3222
|
-
doc.overlays.forEach((ov, i) => checkAsset(ov.asset?.ref, `overlays[${i}].asset.ref`, ov.id));
|
|
3223
|
-
doc.audio.forEach((a, i) => checkAsset(a.asset.ref, `audio[${i}].asset.ref`, a.id));
|
|
3224
|
-
}
|
|
3225
|
-
if (diagnostics.errors.length > 0) {
|
|
3226
|
-
return { ok: false, plan: null, legacyEdl: null, diagnostics, uses };
|
|
3227
|
-
}
|
|
3228
|
-
const streams = {};
|
|
3229
|
-
for (const name of Object.keys(doc.sources)) {
|
|
3230
|
-
const t = ctx.transcripts?.[name];
|
|
3231
|
-
streams[name] = t ? wordStream(t) : void 0;
|
|
3232
|
-
}
|
|
3233
|
-
const resolveWords = (anchor, path) => {
|
|
3234
|
-
const src = doc.sources[anchor.source];
|
|
3235
|
-
const hashMatches = src.transcriptHash !== void 0 && src.transcriptHash === ctx.transcriptHashes?.[anchor.source];
|
|
3236
|
-
if (anchor.resolved && hashMatches) return anchor.resolved;
|
|
3237
|
-
const stream = streams[anchor.source];
|
|
3238
|
-
if (!stream) {
|
|
3239
|
-
if (anchor.resolved) {
|
|
3240
|
-
diagnostics.warnings.push({
|
|
3241
|
-
path,
|
|
3242
|
-
code: "anchor-unverified-cache",
|
|
3243
|
-
message: `no transcript available for source "${anchor.source}"; using the cached resolved span for "${anchor.phrase}" without verification`
|
|
3244
|
-
});
|
|
3245
|
-
return anchor.resolved;
|
|
3246
|
-
}
|
|
3247
|
-
diagnostics.errors.push({
|
|
3248
|
-
path,
|
|
3249
|
-
code: "anchor-unresolvable",
|
|
3250
|
-
message: `cannot resolve phrase "${anchor.phrase}": no transcript available for source "${anchor.source}"`,
|
|
3251
|
-
suggestion: "run `video-editing transcribe` first"
|
|
3252
|
-
});
|
|
3253
|
-
return null;
|
|
3254
|
-
}
|
|
3255
|
-
const outcome = resolvePhrase(stream, anchor.phrase, { occurrence: anchor.occurrence });
|
|
3256
|
-
if (outcome.status === "ok") return { start: outcome.match.start, end: outcome.match.end };
|
|
3257
|
-
if (outcome.status === "ambiguous") {
|
|
3258
|
-
diagnostics.errors.push({
|
|
3259
|
-
path,
|
|
3260
|
-
code: "anchor-ambiguous",
|
|
3261
|
-
message: `phrase "${anchor.phrase}" matches ${outcome.candidates.length} times in source "${anchor.source}"; add occurrence or a tighter phrase`,
|
|
3262
|
-
suggestion: "set anchor.occurrence (1-based), or use `timeline resolve --near`",
|
|
3263
|
-
context: {
|
|
3264
|
-
candidates: outcome.candidates.map((c) => ({
|
|
3265
|
-
occurrence: c.occurrence,
|
|
3266
|
-
start: c.start,
|
|
3267
|
-
end: c.end,
|
|
3268
|
-
context: c.context
|
|
3269
|
-
}))
|
|
3270
|
-
}
|
|
3271
|
-
});
|
|
3272
|
-
return null;
|
|
3273
|
-
}
|
|
3274
|
-
diagnostics.errors.push({
|
|
3275
|
-
path,
|
|
3276
|
-
code: "anchor-not-found",
|
|
3277
|
-
message: `phrase "${anchor.phrase}" not found in source "${anchor.source}"`,
|
|
3278
|
-
suggestion: outcome.suggestions.length ? `nearest matches: ${outcome.suggestions.map((s) => `"${s.text}" @${s.start}s`).join("; ")}` : void 0,
|
|
3279
|
-
context: { suggestions: outcome.suggestions }
|
|
3280
|
-
});
|
|
3281
|
-
return null;
|
|
3282
|
-
};
|
|
3283
|
-
const desilenceOpts = { ...DEFAULT_DESILENCE, ...doc.settings.desilence ?? {} };
|
|
3284
|
-
const runDesilence = ctx.desilence !== false;
|
|
3285
|
-
const tightened = [];
|
|
3286
|
-
for (const r of doc.cut.ranges) {
|
|
3287
|
-
const clean = { source: r.source, start: r.start, end: r.end };
|
|
3288
|
-
if (r.beat !== void 0) clean.beat = r.beat;
|
|
3289
|
-
if (r.quote !== void 0) clean.quote = r.quote;
|
|
3290
|
-
if (r.reason !== void 0) clean.reason = r.reason;
|
|
3291
|
-
if (r.grade !== void 0) clean.grade = r.grade;
|
|
3292
|
-
const subs = runDesilence ? tightenRange(
|
|
3293
|
-
clean,
|
|
3294
|
-
ctx.silencesBySource?.[r.source] ?? [],
|
|
3295
|
-
desilenceOpts,
|
|
3296
|
-
ctx.wordsBySource?.[r.source] ?? wordSpansFromTranscript(ctx.transcripts?.[r.source])
|
|
3297
|
-
) : [clean];
|
|
3298
|
-
for (const sub of subs) tightened.push({ range: sub, rangeId: r.id });
|
|
3299
|
-
}
|
|
3300
|
-
const segments = [];
|
|
3301
|
-
let cursor = 0;
|
|
3302
|
-
tightened.forEach(({ range, rangeId }, i) => {
|
|
3303
|
-
const dur2 = Number(range.end) - Number(range.start);
|
|
3304
|
-
segments.push({
|
|
3305
|
-
id: `seg_${i}`,
|
|
3306
|
-
source: range.source,
|
|
3307
|
-
srcStart: Number(range.start),
|
|
3308
|
-
srcEnd: Number(range.end),
|
|
3309
|
-
outStart: r3(cursor),
|
|
3310
|
-
outEnd: r3(cursor + dur2),
|
|
3311
|
-
rangeId,
|
|
3312
|
-
videoOps: []
|
|
3313
|
-
});
|
|
3314
|
-
cursor += dur2;
|
|
3315
|
-
});
|
|
3316
|
-
const outputDurationS = r3(cursor);
|
|
3317
|
-
const itemOutEnd = /* @__PURE__ */ new Map();
|
|
3318
|
-
for (const seg of segments) itemOutEnd.set(seg.rangeId, seg.outEnd);
|
|
3319
|
-
const dropItem = (id, path, why) => {
|
|
3320
|
-
diagnostics.warnings.push({
|
|
3321
|
-
path,
|
|
3322
|
-
code: "on-cut-dropped",
|
|
3323
|
-
message: why
|
|
3324
|
-
});
|
|
3325
|
-
diagnostics.dropped.push(id);
|
|
3326
|
-
};
|
|
3327
|
-
const sourceSpanFor = (item, path, applyEdge) => {
|
|
3328
|
-
const anchor = item.anchor;
|
|
3329
|
-
if (anchor.type === "source") return { source: anchor.source, a: anchor.start, b: anchor.end };
|
|
3330
|
-
if (anchor.type === "words") {
|
|
3331
|
-
const span = resolveWords(anchor, `${path}.anchor`);
|
|
3332
|
-
if (!span) return null;
|
|
3333
|
-
let a = span.start;
|
|
3334
|
-
let b = span.end;
|
|
3335
|
-
if (applyEdge && anchor.edge === "start") b = a;
|
|
3336
|
-
else if (applyEdge && anchor.edge === "end") a = b;
|
|
3337
|
-
return { source: anchor.source, a, b };
|
|
3338
|
-
}
|
|
3339
|
-
return null;
|
|
3340
|
-
};
|
|
3341
|
-
let fatal = false;
|
|
3342
|
-
doc.zooms.forEach((zm, i) => {
|
|
3343
|
-
const path = `zooms[${i}]`;
|
|
3344
|
-
if (zm.anchor.type === "output") {
|
|
3345
|
-
const span = resolveOutputSpan(zm.anchor, void 0, path);
|
|
3346
|
-
if (!span) return;
|
|
3347
|
-
attachZoomOpsByOutput(zm, span.outStart, span.outEnd);
|
|
3348
|
-
return;
|
|
3349
|
-
}
|
|
3350
|
-
if (zm.anchor.type === "words" && zm.anchor.edge !== void 0) {
|
|
3351
|
-
diagnostics.warnings.push({
|
|
3352
|
-
path: `${path}.anchor.edge`,
|
|
3353
|
-
code: "edge-ignored",
|
|
3354
|
-
message: `zoom "${zm.id}": anchor.edge is ignored for zooms; the full phrase span is used`
|
|
3355
|
-
});
|
|
3356
|
-
}
|
|
3357
|
-
const src = sourceSpanFor(zm, path, false);
|
|
3358
|
-
if (!src) {
|
|
3359
|
-
fatal = true;
|
|
3360
|
-
return;
|
|
3361
|
-
}
|
|
3362
|
-
const { a, b } = src;
|
|
3363
|
-
const m = mapSpan(segments, src.source, a, b);
|
|
3364
|
-
const want = b - a;
|
|
3365
|
-
if (m.covered <= EPS3) {
|
|
3366
|
-
if (zm.onCut === "error") {
|
|
3367
|
-
diagnostics.errors.push({
|
|
3368
|
-
path,
|
|
3369
|
-
code: "on-cut-error",
|
|
3370
|
-
message: `zoom "${zm.id}" spans [${a}, ${b}]s of source "${src.source}" which is entirely removed by the cut`
|
|
3371
|
-
});
|
|
3372
|
-
fatal = true;
|
|
3373
|
-
} else {
|
|
3374
|
-
dropItem(zm.id, path, `zoom "${zm.id}" was dropped: its span [${a}, ${b}]s is entirely outside the kept ranges`);
|
|
3375
|
-
}
|
|
3376
|
-
return;
|
|
3377
|
-
}
|
|
3378
|
-
if (m.covered < want - EPS3) {
|
|
3379
|
-
if (zm.onCut === "error") {
|
|
3380
|
-
diagnostics.errors.push({
|
|
3381
|
-
path,
|
|
3382
|
-
code: "on-cut-error",
|
|
3383
|
-
message: `zoom "${zm.id}" spans [${a}, ${b}]s of source "${src.source}" but only ${m.covered.toFixed(3)}s of it survives the cut (onCut:"error")`
|
|
3384
|
-
});
|
|
3385
|
-
fatal = true;
|
|
3386
|
-
return;
|
|
3387
|
-
}
|
|
3388
|
-
if (zm.onCut === "drop") {
|
|
3389
|
-
dropItem(zm.id, path, `zoom "${zm.id}" was dropped: its span is only partially inside the kept ranges (onCut:"drop")`);
|
|
3390
|
-
return;
|
|
3391
|
-
}
|
|
3392
|
-
}
|
|
3393
|
-
for (const part of m.parts) {
|
|
3394
|
-
const seg = segments[part.segIndex];
|
|
3395
|
-
seg.videoOps.push({
|
|
3396
|
-
kind: "zoom",
|
|
3397
|
-
itemId: zm.id,
|
|
3398
|
-
rect: zm.rect,
|
|
3399
|
-
scale: zm.scale,
|
|
3400
|
-
ease: zm.ease,
|
|
3401
|
-
tIn: r3(part.srcA - seg.srcStart),
|
|
3402
|
-
tOut: r3(part.srcB - seg.srcStart),
|
|
3403
|
-
...rampFields(zm)
|
|
3404
|
-
});
|
|
3405
|
-
}
|
|
3406
|
-
});
|
|
3407
|
-
function rampFields(zm) {
|
|
3408
|
-
if (zm.to === void 0) return {};
|
|
3409
|
-
return {
|
|
3410
|
-
...zm.to.rect !== void 0 ? { toRect: zm.to.rect } : {},
|
|
3411
|
-
...zm.to.scale !== void 0 ? { toScale: zm.to.scale } : {}
|
|
3412
|
-
};
|
|
3413
|
-
}
|
|
3414
|
-
function attachZoomOpsByOutput(zm, outA, outB) {
|
|
3415
|
-
for (const seg of segments) {
|
|
3416
|
-
const a = Math.max(outA, seg.outStart);
|
|
3417
|
-
const b = Math.min(outB, seg.outEnd);
|
|
3418
|
-
if (b - a <= EPS3) continue;
|
|
3419
|
-
seg.videoOps.push({
|
|
3420
|
-
kind: "zoom",
|
|
3421
|
-
itemId: zm.id,
|
|
3422
|
-
rect: zm.rect,
|
|
3423
|
-
scale: zm.scale,
|
|
3424
|
-
ease: zm.ease,
|
|
3425
|
-
tIn: r3(a - seg.outStart),
|
|
3426
|
-
tOut: r3(b - seg.outStart),
|
|
3427
|
-
...rampFields(zm)
|
|
3428
|
-
});
|
|
3429
|
-
}
|
|
3430
|
-
}
|
|
3431
|
-
function resolveOutputSpan(anchor, itemDurationS, path) {
|
|
3432
|
-
const d = anchor.durationS ?? itemDurationS;
|
|
3433
|
-
if (typeof anchor.at === "string") {
|
|
3434
|
-
if (anchor.at === "full") return { outStart: 0, outEnd: outputDurationS };
|
|
3435
|
-
if (d === void 0) {
|
|
3436
|
-
diagnostics.errors.push({
|
|
3437
|
-
path,
|
|
3438
|
-
code: "missing-duration",
|
|
3439
|
-
message: `output anchor at:"${anchor.at}" needs a durationS`
|
|
3440
|
-
});
|
|
3441
|
-
fatal = true;
|
|
3442
|
-
return null;
|
|
3443
|
-
}
|
|
3444
|
-
if (anchor.at === "start") return { outStart: 0, outEnd: Math.min(d, outputDurationS) };
|
|
3445
|
-
return { outStart: Math.max(0, outputDurationS - d), outEnd: outputDurationS };
|
|
3446
|
-
}
|
|
3447
|
-
const targetEnd = itemOutEnd.get(anchor.at.after);
|
|
3448
|
-
if (targetEnd === void 0) {
|
|
3449
|
-
diagnostics.errors.push({
|
|
3450
|
-
path,
|
|
3451
|
-
code: "after-unresolved",
|
|
3452
|
-
message: `after:"${anchor.at.after}" could not be resolved to an output time (missing, cyclic, or dropped target)`
|
|
3453
|
-
});
|
|
3454
|
-
fatal = true;
|
|
3455
|
-
return null;
|
|
3456
|
-
}
|
|
3457
|
-
if (d === void 0) {
|
|
3458
|
-
diagnostics.errors.push({
|
|
3459
|
-
path,
|
|
3460
|
-
code: "missing-duration",
|
|
3461
|
-
message: `output anchor after:"${anchor.at.after}" needs a durationS`
|
|
3462
|
-
});
|
|
3463
|
-
fatal = true;
|
|
3464
|
-
return null;
|
|
3465
|
-
}
|
|
3466
|
-
const outStart = Math.min(targetEnd, outputDurationS);
|
|
3467
|
-
return { outStart, outEnd: Math.min(outStart + d, outputDurationS) };
|
|
3468
|
-
}
|
|
3469
|
-
const events = [];
|
|
3470
|
-
const pending = [
|
|
3471
|
-
...doc.overlays.map((item, i) => ({ kind: "overlay", item, path: `overlays[${i}]` })),
|
|
3472
|
-
...doc.audio.map((item, i) => ({ kind: "audio", item, path: `audio[${i}]` }))
|
|
3473
|
-
];
|
|
3474
|
-
const emitOverlay = (item, outStart, outEnd) => {
|
|
3475
|
-
const overlay = {
|
|
3476
|
-
kind: item.kind,
|
|
3477
|
-
layout: item.layout,
|
|
3478
|
-
style: item.style
|
|
3479
|
-
};
|
|
3480
|
-
if (item.text !== void 0) overlay.text = item.text;
|
|
3481
|
-
if (item.asset !== void 0) overlay.asset = item.asset;
|
|
3482
|
-
if (item.enter !== void 0) overlay.enter = item.enter;
|
|
3483
|
-
if (item.exit !== void 0) overlay.exit = item.exit;
|
|
3484
|
-
if (item.size !== void 0) overlay.size = item.size;
|
|
3485
|
-
if (item.z !== void 0) overlay.z = item.z;
|
|
3486
|
-
events.push({ kind: "overlay", itemId: item.id, outStart: r3(outStart), outEnd: r3(outEnd), overlay });
|
|
3487
|
-
itemOutEnd.set(item.id, r3(outEnd));
|
|
3488
|
-
};
|
|
3489
|
-
const emitAudio = (item, outStart, outEnd) => {
|
|
3490
|
-
const audio = {
|
|
3491
|
-
kind: item.kind,
|
|
3492
|
-
asset: item.asset,
|
|
3493
|
-
gainDb: item.gainDb,
|
|
3494
|
-
duckToSpeech: item.duckToSpeech
|
|
3495
|
-
};
|
|
3496
|
-
if (item.fadeOutS !== void 0) audio.fadeOutS = item.fadeOutS;
|
|
3497
|
-
events.push({ kind: "audio", itemId: item.id, outStart: r3(outStart), outEnd: r3(outEnd), audio });
|
|
3498
|
-
itemOutEnd.set(item.id, r3(outEnd));
|
|
3499
|
-
};
|
|
3500
|
-
const resolvePending = (p, deferOk) => {
|
|
3501
|
-
const { item, path } = p;
|
|
3502
|
-
const anchor = item.anchor;
|
|
3503
|
-
if (anchor.type === "output") {
|
|
3504
|
-
if (typeof anchor.at !== "string" && !itemOutEnd.has(anchor.at.after)) {
|
|
3505
|
-
if (diagnostics.dropped.includes(anchor.at.after)) {
|
|
3506
|
-
dropItem(item.id, path, `"${item.id}" was dropped: its after:"${anchor.at.after}" target was dropped`);
|
|
3507
|
-
return true;
|
|
3508
|
-
}
|
|
3509
|
-
if (deferOk) return false;
|
|
3510
|
-
diagnostics.errors.push({
|
|
3511
|
-
path,
|
|
3512
|
-
code: "after-unresolved",
|
|
3513
|
-
message: `after:"${anchor.at.after}" could not be resolved to an output time (missing or cyclic target)`
|
|
3514
|
-
});
|
|
3515
|
-
fatal = true;
|
|
3516
|
-
return true;
|
|
3517
|
-
}
|
|
3518
|
-
const itemDur2 = p.kind === "overlay" ? p.item.durationS : void 0;
|
|
3519
|
-
const span = resolveOutputSpan(anchor, itemDur2, path);
|
|
3520
|
-
if (!span) return true;
|
|
3521
|
-
if (p.kind === "overlay") emitOverlay(p.item, span.outStart, span.outEnd);
|
|
3522
|
-
else emitAudio(p.item, span.outStart, span.outEnd);
|
|
3523
|
-
return true;
|
|
3524
|
-
}
|
|
3525
|
-
const src = sourceSpanFor(item, path, true);
|
|
3526
|
-
if (!src) {
|
|
3527
|
-
fatal = true;
|
|
3528
|
-
return true;
|
|
3529
|
-
}
|
|
3530
|
-
let { a, b } = src;
|
|
3531
|
-
const itemDur = p.kind === "overlay" ? p.item.durationS : void 0;
|
|
3532
|
-
if (itemDur !== void 0) b = a + itemDur;
|
|
3533
|
-
if (b - a <= EPS3) {
|
|
3534
|
-
diagnostics.errors.push({
|
|
3535
|
-
path,
|
|
3536
|
-
code: "missing-duration",
|
|
3537
|
-
message: `"${item.id}" resolves to a zero-length span; give it a durationS or a wider anchor`
|
|
3538
|
-
});
|
|
3539
|
-
fatal = true;
|
|
3540
|
-
return true;
|
|
3541
|
-
}
|
|
3542
|
-
const m = mapSpan(segments, src.source, a, b);
|
|
3543
|
-
const onCut = p.kind === "overlay" ? p.item.onCut : "clip";
|
|
3544
|
-
if (m.covered <= EPS3) {
|
|
3545
|
-
if (onCut === "error") {
|
|
3546
|
-
diagnostics.errors.push({
|
|
3547
|
-
path,
|
|
3548
|
-
code: "on-cut-error",
|
|
3549
|
-
message: `"${item.id}" spans [${a}, ${b}]s of source "${src.source}" which is entirely removed by the cut`
|
|
3550
|
-
});
|
|
3551
|
-
fatal = true;
|
|
3552
|
-
} else {
|
|
3553
|
-
dropItem(item.id, path, `"${item.id}" was dropped: its span [${a}, ${b}]s is entirely outside the kept ranges`);
|
|
3554
|
-
}
|
|
3555
|
-
return true;
|
|
3556
|
-
}
|
|
3557
|
-
if (m.covered < b - a - EPS3) {
|
|
3558
|
-
if (onCut === "error") {
|
|
3559
|
-
diagnostics.errors.push({
|
|
3560
|
-
path,
|
|
3561
|
-
code: "on-cut-error",
|
|
3562
|
-
message: `"${item.id}" is only partially inside the kept ranges (onCut:"error")`
|
|
3563
|
-
});
|
|
3564
|
-
fatal = true;
|
|
3565
|
-
return true;
|
|
3566
|
-
}
|
|
3567
|
-
if (onCut === "drop") {
|
|
3568
|
-
dropItem(item.id, path, `"${item.id}" was dropped: its span is only partially inside the kept ranges (onCut:"drop")`);
|
|
3569
|
-
return true;
|
|
3570
|
-
}
|
|
3571
|
-
}
|
|
3572
|
-
if (p.kind === "overlay") emitOverlay(p.item, m.outStart, m.outEnd);
|
|
3573
|
-
else emitAudio(p.item, m.outStart, m.outEnd);
|
|
3574
|
-
return true;
|
|
3575
|
-
};
|
|
3576
|
-
let queue = pending;
|
|
3577
|
-
for (let pass = 0; pass < pending.length + 1 && queue.length > 0; pass++) {
|
|
3578
|
-
const next = [];
|
|
3579
|
-
for (const p of queue) {
|
|
3580
|
-
if (!resolvePending(p, true)) next.push(p);
|
|
3581
|
-
}
|
|
3582
|
-
if (next.length === queue.length) {
|
|
3583
|
-
for (const p of next) resolvePending(p, false);
|
|
3584
|
-
queue = [];
|
|
3585
|
-
break;
|
|
3586
|
-
}
|
|
3587
|
-
queue = next;
|
|
3588
|
-
}
|
|
3589
|
-
if (doc.captions.enabled) {
|
|
3590
|
-
deriveCaptionCues(doc, segments, streams, events, diagnostics, resolveWords, () => {
|
|
3591
|
-
fatal = true;
|
|
3592
|
-
});
|
|
3593
|
-
}
|
|
3594
|
-
if (diagnostics.errors.length > 0 || fatal) {
|
|
3595
|
-
return { ok: false, plan: null, legacyEdl: null, diagnostics, uses };
|
|
3596
|
-
}
|
|
3597
|
-
const legacyRanges = tightened.map(({ range }) => range);
|
|
3598
|
-
const legacyEdl = {
|
|
3599
|
-
version: 1,
|
|
3600
|
-
sources: Object.fromEntries(Object.entries(doc.sources).map(([k, v]) => [k, v.path])),
|
|
3601
|
-
// The declarative {preset, intensity?} grade belongs to the composition
|
|
3602
|
-
// path (data-color-grading); the ffmpeg EDL only understands strings.
|
|
3603
|
-
grade: typeof doc.settings.grade === "string" ? doc.settings.grade : "none",
|
|
3604
|
-
ranges: legacyRanges,
|
|
3605
|
-
overlays: [],
|
|
3606
|
-
total_duration_s: pyRound(
|
|
3607
|
-
legacyRanges.reduce((acc, r) => acc + (Number(r.end) - Number(r.start)), 0),
|
|
3608
|
-
2
|
|
3609
|
-
)
|
|
3610
|
-
};
|
|
3611
|
-
if (runDesilence) legacyEdl.desilenced = true;
|
|
3612
|
-
events.sort((x, y) => x.outStart - y.outStart);
|
|
3613
|
-
const plan = {
|
|
3614
|
-
version: 1,
|
|
3615
|
-
fromTimeline: doc.id,
|
|
3616
|
-
timelineHash: timelineHash(doc),
|
|
3617
|
-
outputDurationS,
|
|
3618
|
-
segments,
|
|
3619
|
-
events,
|
|
3620
|
-
uses,
|
|
3621
|
-
boundaries: cutBoundaries(legacyRanges)
|
|
3622
|
-
};
|
|
3623
|
-
return { ok: true, plan, legacyEdl, diagnostics, uses };
|
|
3624
|
-
}
|
|
3625
|
-
var SENTENCE_END_RE = /[.?!]["'”’»)\]׳״]*$/u;
|
|
3626
|
-
var SECONDARY_PUNCT_RE = /[,;:—-]["'”’»)\]׳״]*$/u;
|
|
3627
|
-
function packPhrase(phrase, maxWords) {
|
|
3628
|
-
const L = phrase.length;
|
|
3629
|
-
if (L <= maxWords) return [phrase];
|
|
3630
|
-
const k = Math.ceil(L / maxWords);
|
|
3631
|
-
const base = Math.floor(L / k);
|
|
3632
|
-
const rem = L % k;
|
|
3633
|
-
const targets = [];
|
|
3634
|
-
for (let i = 0; i < k; i++) targets.push(i < rem ? base + 1 : base);
|
|
3635
|
-
const minSize = Math.min(2, base);
|
|
3636
|
-
const splits = [];
|
|
3637
|
-
let prev = 0;
|
|
3638
|
-
let even = 0;
|
|
3639
|
-
for (let i = 1; i < k; i++) {
|
|
3640
|
-
even += targets[i - 1];
|
|
3641
|
-
const cuesLeft = k - i;
|
|
3642
|
-
let best = -1;
|
|
3643
|
-
let bestKey = null;
|
|
3644
|
-
for (const c of [even - 1, even, even + 1]) {
|
|
3645
|
-
if (c <= prev || c >= L) continue;
|
|
3646
|
-
const size = c - prev;
|
|
3647
|
-
if (size < minSize || size > maxWords) continue;
|
|
3648
|
-
const remaining = L - c;
|
|
3649
|
-
if (remaining < cuesLeft * minSize || remaining > cuesLeft * maxWords) continue;
|
|
3650
|
-
const punct = SECONDARY_PUNCT_RE.test(phrase[c - 1].text) ? 1 : 0;
|
|
3651
|
-
const gap = pyRound(phrase[c].start - phrase[c - 1].end, 3);
|
|
3652
|
-
const key = [punct, gap, -Math.abs(c - even), -c];
|
|
3653
|
-
if (bestKey === null || key[0] > bestKey[0] || key[0] === bestKey[0] && (key[1] > bestKey[1] || key[1] === bestKey[1] && (key[2] > bestKey[2] || key[2] === bestKey[2] && key[3] > bestKey[3]))) {
|
|
3654
|
-
best = c;
|
|
3655
|
-
bestKey = key;
|
|
3656
|
-
}
|
|
3657
|
-
}
|
|
3658
|
-
if (best < 0) best = Math.min(Math.max(even, prev + 1), L - cuesLeft);
|
|
3659
|
-
splits.push(best);
|
|
3660
|
-
prev = best;
|
|
3661
|
-
}
|
|
3662
|
-
const cues = [];
|
|
3663
|
-
let at = 0;
|
|
3664
|
-
for (const s of [...splits, L]) {
|
|
3665
|
-
cues.push(phrase.slice(at, s));
|
|
3666
|
-
at = s;
|
|
3667
|
-
}
|
|
3668
|
-
return cues;
|
|
3669
|
-
}
|
|
3670
|
-
function deriveCaptionCues(doc, segments, streams, events, diagnostics, resolveWords, markFatal) {
|
|
3671
|
-
const overrides = [];
|
|
3672
|
-
for (let i = 0; i < doc.captions.overrides.length; i++) {
|
|
3673
|
-
const o = doc.captions.overrides[i];
|
|
3674
|
-
const span = resolveWords(o.anchor, `captions.overrides[${i}].anchor`);
|
|
3675
|
-
if (!span) {
|
|
3676
|
-
markFatal();
|
|
3677
|
-
continue;
|
|
3678
|
-
}
|
|
3679
|
-
const ro = { id: o.id, op: o.op, source: o.anchor.source, ...span };
|
|
3680
|
-
if (o.text !== void 0) ro.text = o.text;
|
|
3681
|
-
if (o.color !== void 0) ro.color = o.color;
|
|
3682
|
-
if (o.emphasis !== void 0) ro.emphasis = o.emphasis;
|
|
3683
|
-
overrides.push(ro);
|
|
3684
|
-
}
|
|
3685
|
-
const TOL = 1e-3;
|
|
3686
|
-
const covers = (o, source, w) => o.source === source && w.start >= o.start - TOL && w.end <= o.end + TOL;
|
|
3687
|
-
const overrideFor = (source, w) => overrides.find((o) => o.op !== "style" && covers(o, source, w));
|
|
3688
|
-
const styleFor = (source, w) => overrides.find((o) => o.op === "style" && covers(o, source, w));
|
|
3689
|
-
const style = doc.captions.style;
|
|
3690
|
-
const maxWords = style.maxWords ?? CAPTION_STYLE_DEFAULTS.maxWords;
|
|
3691
|
-
const gapBreakS = style.gapBreakS ?? CAPTION_STYLE_DEFAULTS.gapBreakS;
|
|
3692
|
-
const leadS = style.leadS ?? CAPTION_STYLE_DEFAULTS.leadS;
|
|
3693
|
-
const holdS = style.holdS ?? CAPTION_STYLE_DEFAULTS.holdS;
|
|
3694
|
-
const karaoke = style.karaoke === true;
|
|
3695
|
-
const wordByWord = doc.captions.preset === "opus-karaoke";
|
|
3696
|
-
const emittedReplacements = /* @__PURE__ */ new Set();
|
|
3697
|
-
for (const seg of segments) {
|
|
3698
|
-
const stream = streams[seg.source];
|
|
3699
|
-
if (!stream) {
|
|
3700
|
-
diagnostics.warnings.push({
|
|
3701
|
-
path: "captions",
|
|
3702
|
-
code: "no-transcript",
|
|
3703
|
-
message: `captions are enabled but source "${seg.source}" has no transcript; no cues derived for it`
|
|
3704
|
-
});
|
|
3705
|
-
continue;
|
|
3706
|
-
}
|
|
3707
|
-
const rawCues = [];
|
|
3708
|
-
let group = [];
|
|
3709
|
-
let groupBreakAfter = false;
|
|
3710
|
-
const flush = () => {
|
|
3711
|
-
if (group.length === 0) return;
|
|
3712
|
-
const chunks = wordByWord ? [group] : packPhrase(group, maxWords);
|
|
3713
|
-
chunks.forEach((words, ci) => {
|
|
3714
|
-
const cue = {
|
|
3715
|
-
start: words[0].start,
|
|
3716
|
-
end: words[words.length - 1].end,
|
|
3717
|
-
words,
|
|
3718
|
-
endCap: seg.outEnd
|
|
3719
|
-
};
|
|
3720
|
-
if (groupBreakAfter && ci === chunks.length - 1) cue.lineBreakAfter = true;
|
|
3721
|
-
rawCues.push(cue);
|
|
3722
|
-
});
|
|
3723
|
-
group = [];
|
|
3724
|
-
groupBreakAfter = false;
|
|
3725
|
-
};
|
|
3726
|
-
for (const w of stream) {
|
|
3727
|
-
if (w.end <= seg.srcStart || w.start >= seg.srcEnd) continue;
|
|
3728
|
-
if (!w.text) continue;
|
|
3729
|
-
const ov = overrideFor(seg.source, w);
|
|
3730
|
-
if (ov?.op === "hide") continue;
|
|
3731
|
-
if (ov?.op === "replace-text") {
|
|
3732
|
-
flush();
|
|
3733
|
-
if (emittedReplacements.has(ov.id)) continue;
|
|
3734
|
-
emittedReplacements.add(ov.id);
|
|
3735
|
-
const m = mapSpan(segments, ov.source, Math.max(ov.start, seg.srcStart), ov.end);
|
|
3736
|
-
if (m.covered <= 0) continue;
|
|
3737
|
-
rawCues.push({
|
|
3738
|
-
start: m.outStart,
|
|
3739
|
-
end: m.outEnd,
|
|
3740
|
-
words: [{ start: m.outStart, end: m.outEnd, text: ov.text ?? "" }],
|
|
3741
|
-
itemId: ov.id,
|
|
3742
|
-
endCap: Math.max(seg.outEnd, m.outEnd)
|
|
3743
|
-
});
|
|
3744
|
-
continue;
|
|
3745
|
-
}
|
|
3746
|
-
const localStart = Math.max(w.start, seg.srcStart);
|
|
3747
|
-
const localEnd = Math.min(w.end, seg.srcEnd);
|
|
3748
|
-
const outStart = seg.outStart + (localStart - seg.srcStart);
|
|
3749
|
-
const outEnd = seg.outStart + (localEnd - seg.srcStart);
|
|
3750
|
-
if (group.length > 0) {
|
|
3751
|
-
const prev = group[group.length - 1];
|
|
3752
|
-
if (outStart - prev.end > gapBreakS || SENTENCE_END_RE.test(prev.text)) {
|
|
3753
|
-
flush();
|
|
3754
|
-
}
|
|
3755
|
-
}
|
|
3756
|
-
const rw = { start: outStart, end: outEnd, text: w.text };
|
|
3757
|
-
const styleOv = styleFor(seg.source, w);
|
|
3758
|
-
if (styleOv?.color !== void 0) rw.color = styleOv.color;
|
|
3759
|
-
if (styleOv?.emphasis !== void 0) rw.emphasis = styleOv.emphasis;
|
|
3760
|
-
group.push(rw);
|
|
3761
|
-
const brk = overrides.find(
|
|
3762
|
-
(o) => o.op === "break-line" && o.source === seg.source && Math.abs(w.end - o.end) <= 0.05 && w.start >= o.start - TOL
|
|
3763
|
-
);
|
|
3764
|
-
if (brk) {
|
|
3765
|
-
groupBreakAfter = true;
|
|
3766
|
-
flush();
|
|
3767
|
-
} else if (wordByWord) {
|
|
3768
|
-
flush();
|
|
3769
|
-
}
|
|
3770
|
-
}
|
|
3771
|
-
flush();
|
|
3772
|
-
let floor = Math.max(seg.outStart, 0);
|
|
3773
|
-
for (let i = 0; i < rawCues.length; i++) {
|
|
3774
|
-
const cue = rawCues[i];
|
|
3775
|
-
const displayStart = Math.max(cue.start - leadS, Math.min(floor, cue.start));
|
|
3776
|
-
const capped = Math.min(cue.end + holdS, cue.endCap);
|
|
3777
|
-
const next = rawCues[i + 1];
|
|
3778
|
-
const displayEnd = next ? Math.min(Math.max(next.start - leadS, cue.end), capped) : capped;
|
|
3779
|
-
floor = displayEnd;
|
|
3780
|
-
const outStart = pyRound(displayStart, 3);
|
|
3781
|
-
const event = {
|
|
3782
|
-
kind: "caption-cue",
|
|
3783
|
-
outStart,
|
|
3784
|
-
outEnd: pyRound(displayEnd, 3),
|
|
3785
|
-
words: cue.words.map((rw) => {
|
|
3786
|
-
const word = {
|
|
3787
|
-
t: pyRound(rw.start - displayStart, 3),
|
|
3788
|
-
d: pyRound(rw.end - rw.start, 3),
|
|
3789
|
-
text: rw.text
|
|
3790
|
-
};
|
|
3791
|
-
if (rw.color !== void 0) word.color = rw.color;
|
|
3792
|
-
if (rw.emphasis !== void 0) word.emphasis = rw.emphasis;
|
|
3793
|
-
return word;
|
|
3794
|
-
})
|
|
3795
|
-
};
|
|
3796
|
-
if (karaoke) event.karaoke = true;
|
|
3797
|
-
if (cue.lineBreakAfter) event.lineBreakAfter = true;
|
|
3798
|
-
if (cue.itemId !== void 0) event.itemId = cue.itemId;
|
|
3799
|
-
events.push(event);
|
|
3800
|
-
}
|
|
3801
|
-
}
|
|
3802
|
-
}
|
|
3803
|
-
|
|
3804
|
-
// src/timeline-v2/edl-lift.ts
|
|
3805
|
-
import { randomUUID } from "crypto";
|
|
3806
|
-
function timelineFromEdl(edl, opts = {}) {
|
|
3807
|
-
const sources = {};
|
|
3808
|
-
for (const [name, path] of Object.entries(edl.sources ?? {})) {
|
|
3809
|
-
sources[name] = { path };
|
|
3810
|
-
const dur2 = opts.sourceDurations?.[name];
|
|
3811
|
-
if (dur2 !== void 0) sources[name].durationS = dur2;
|
|
3812
|
-
const hash = opts.transcriptHashes?.[name];
|
|
3813
|
-
if (hash !== void 0) sources[name].transcriptHash = hash;
|
|
3814
|
-
}
|
|
3815
|
-
const ranges = (edl.ranges ?? []).map((r, i) => {
|
|
3816
|
-
const out = {
|
|
3817
|
-
id: `rng_${i + 1}`,
|
|
3818
|
-
source: r.source,
|
|
3819
|
-
start: Number(r.start),
|
|
3820
|
-
end: Number(r.end),
|
|
3821
|
-
provenance: { round: null, changeId: null, actor: "agent" }
|
|
3822
|
-
};
|
|
3823
|
-
if (r.beat !== void 0) out.beat = r.beat;
|
|
3824
|
-
if (r.quote !== void 0) out.quote = r.quote;
|
|
3825
|
-
if (r.reason !== void 0) out.reason = r.reason;
|
|
3826
|
-
if (r.grade !== void 0) out.grade = r.grade;
|
|
3827
|
-
return out;
|
|
3828
|
-
});
|
|
3829
|
-
return {
|
|
3830
|
-
version: 2,
|
|
3831
|
-
id: opts.id ?? `tl_${randomUUID()}`,
|
|
3832
|
-
sources,
|
|
3833
|
-
settings: {
|
|
3834
|
-
canvas: { w: 1080, h: 1920 },
|
|
3835
|
-
grade: edl.grade ?? "none",
|
|
3836
|
-
loudnorm: true
|
|
3837
|
-
},
|
|
3838
|
-
cut: { ranges },
|
|
3839
|
-
captions: { enabled: false, preset: "grouped", style: {}, overrides: [] },
|
|
3840
|
-
zooms: [],
|
|
3841
|
-
overlays: [],
|
|
3842
|
-
audio: []
|
|
3843
|
-
};
|
|
3844
|
-
}
|
|
3845
|
-
|
|
3846
|
-
// src/timeline-v2/compose.ts
|
|
3847
|
-
var COMPOSE_WIDTH = 1080;
|
|
3848
|
-
var COMPOSE_HEIGHT = 1920;
|
|
3849
|
-
var COMPOSE_ENTRY = "index.html";
|
|
3850
|
-
var COMPOSE_VIDEO_SRC = "media/source.mp4";
|
|
3851
|
-
var COMPOSE_AUDIO_SRC = "media/audio.m4a";
|
|
3852
|
-
var COMPOSE_ASSET_PREFIX = "media/";
|
|
3853
|
-
var COMPOSE_GSAP_SRC = "https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js";
|
|
3854
|
-
var STICKER_DEFAULT_W = 0.25;
|
|
3855
|
-
var IMAGE_DEFAULT_W = 0.5;
|
|
3856
|
-
function isImgOverlay(ev) {
|
|
3857
|
-
return ev.kind === "overlay" && ev.overlay !== void 0 && (ev.overlay.kind === "image" || ev.overlay.kind === "sticker") && typeof ev.overlay.asset?.ref === "string";
|
|
3858
|
-
}
|
|
3859
|
-
function composeCounts(plan) {
|
|
3860
|
-
const events = plan.events ?? [];
|
|
3861
|
-
let cues = 0;
|
|
3862
|
-
let overlays = 0;
|
|
3863
|
-
for (const ev of events) {
|
|
3864
|
-
if (ev.kind === "caption-cue") {
|
|
3865
|
-
const text = (ev.words ?? []).map((w) => w.text).join(" ").trim();
|
|
3866
|
-
if (text) cues++;
|
|
3867
|
-
} else if (ev.kind === "overlay" && ev.overlay && typeof ev.overlay.text === "string" && (ev.overlay.kind === "title-card" || ev.overlay.kind === "text")) {
|
|
3868
|
-
overlays++;
|
|
3869
|
-
} else if (isImgOverlay(ev)) {
|
|
3870
|
-
overlays++;
|
|
3871
|
-
}
|
|
3872
|
-
}
|
|
3873
|
-
const zooms = plan.segments.reduce(
|
|
3874
|
-
(n, s) => n + (s.videoOps ?? []).filter((op) => op.kind === "zoom").length,
|
|
3875
|
-
0
|
|
3876
|
-
);
|
|
3877
|
-
return { segments: plan.segments.length, cues, zooms, overlays };
|
|
3878
|
-
}
|
|
3879
|
-
function composeHtml(plan, options = {}) {
|
|
3880
|
-
const segments = plan.segments;
|
|
3881
|
-
const events = plan.events ?? [];
|
|
3882
|
-
const src = escAttr(options.videoSrc ?? COMPOSE_VIDEO_SRC);
|
|
3883
|
-
const audioSrc = escAttr(options.audioSrc ?? COMPOSE_AUDIO_SRC);
|
|
3884
|
-
const parts = [];
|
|
3885
|
-
const tweens = [];
|
|
3886
|
-
const totalDuration2 = segments.length ? Math.max(...segments.map((s) => s.outEnd)) : 0;
|
|
3887
|
-
const gradeAttr = options.grade ? ` data-color-grading='${escSquote(
|
|
3888
|
-
JSON.stringify({
|
|
3889
|
-
preset: options.grade.preset,
|
|
3890
|
-
...options.grade.intensity !== void 0 ? { intensity: options.grade.intensity } : {}
|
|
3891
|
-
})
|
|
3892
|
-
)}'` : "";
|
|
3893
|
-
segments.forEach((seg, i) => {
|
|
3894
|
-
parts.push(
|
|
3895
|
-
`<video id="seg-${i}" class="clip vid" src="${src}" preload="auto" muted playsinline data-start="${num(seg.outStart)}" data-duration="${num(seg.outEnd - seg.outStart)}" data-media-start="${num(seg.srcStart)}" data-track-index="0"${gradeAttr}></video>`
|
|
3896
|
-
);
|
|
3897
|
-
});
|
|
3898
|
-
parts.push(
|
|
3899
|
-
`<audio id="cut-audio" class="clip" src="${audioSrc}" preload="auto" data-start="0" data-duration="${num(totalDuration2)}" data-media-start="0" data-track-index="1" data-volume="1"></audio>`
|
|
3900
|
-
);
|
|
3901
|
-
segments.forEach((seg, i) => {
|
|
3902
|
-
(seg.videoOps ?? []).forEach((op, j) => {
|
|
3903
|
-
if (op.kind !== "zoom") return;
|
|
3904
|
-
const rect = op.rect ?? { x: 0.25, y: 0.25, w: 0.5, h: 0.5 };
|
|
3905
|
-
const cx = ((rect.x + rect.w / 2) * 100).toFixed(1);
|
|
3906
|
-
const cy = ((rect.y + rect.h / 2) * 100).toFixed(1);
|
|
3907
|
-
parts.push(
|
|
3908
|
-
`<video id="zoom-${i}-${j}" class="clip vid" src="${src}" preload="auto" muted playsinline data-start="${num(seg.outStart + op.tIn)}" data-duration="${num(op.tOut - op.tIn)}" data-media-start="${num(seg.srcStart + op.tIn)}" data-track-index="5" style="transform: scale(${op.scale}); transform-origin: ${cx}% ${cy}%"></video>`
|
|
3909
|
-
);
|
|
3910
|
-
if (isRamp(op)) {
|
|
3911
|
-
const from = { scale: op.scale };
|
|
3912
|
-
const to = { scale: op.toScale ?? op.scale };
|
|
3913
|
-
if (op.toRect) {
|
|
3914
|
-
const tr = op.toRect;
|
|
3915
|
-
from.transformOrigin = `${cx}% ${cy}%`;
|
|
3916
|
-
to.transformOrigin = `${((tr.x + tr.w / 2) * 100).toFixed(1)}% ${((tr.y + tr.h / 2) * 100).toFixed(1)}%`;
|
|
3917
|
-
}
|
|
3918
|
-
to.duration = Math.max(0.05, op.tOut - op.tIn);
|
|
3919
|
-
to.ease = rampEase(op.ease);
|
|
3920
|
-
tweens.push(gsapFromTo(`#zoom-${i}-${j}`, from, to, seg.outStart + op.tIn));
|
|
3921
|
-
}
|
|
3922
|
-
});
|
|
3923
|
-
});
|
|
3924
|
-
let cueIdx = 0;
|
|
3925
|
-
let ovIdx = 0;
|
|
3926
|
-
let imgLane = 0;
|
|
3927
|
-
let styledCues = false;
|
|
3928
|
-
let imgOverlays = false;
|
|
3929
|
-
for (const ev of events) {
|
|
3930
|
-
if (ev.kind === "caption-cue") {
|
|
3931
|
-
const words = ev.words ?? [];
|
|
3932
|
-
const text = words.map((w) => w.text).join(" ").trim();
|
|
3933
|
-
if (!text) continue;
|
|
3934
|
-
const idx = cueIdx++;
|
|
3935
|
-
const styled = ev.karaoke === true || words.some((w) => w.color !== void 0 || w.emphasis !== void 0);
|
|
3936
|
-
let inner = escHtml(text);
|
|
3937
|
-
if (styled) {
|
|
3938
|
-
styledCues = true;
|
|
3939
|
-
inner = words.map((w, wi) => {
|
|
3940
|
-
const style = w.color !== void 0 ? ` style="color:${escAttr(w.color)}"` : "";
|
|
3941
|
-
return `<span id="w-${idx}-${wi}" class="wd"${style}>${escHtml(w.text)}</span>`;
|
|
3942
|
-
}).join(" ");
|
|
3943
|
-
words.forEach((w, wi) => {
|
|
3944
|
-
const at = ev.outStart + w.t;
|
|
3945
|
-
const target = `#w-${idx}-${wi}`;
|
|
3946
|
-
if (w.emphasis === "pop") {
|
|
3947
|
-
tweens.push(gsapFromTo(target, { scale: 1 }, { scale: 1.35, duration: 0.3, ease: "back.out(2)" }, at));
|
|
3948
|
-
} else if (w.emphasis === "highlight") {
|
|
3949
|
-
tweens.push(
|
|
3950
|
-
gsapFromTo(
|
|
3951
|
-
target,
|
|
3952
|
-
{ backgroundColor: "rgba(255,235,59,0)" },
|
|
3953
|
-
{ backgroundColor: "rgba(255,235,59,0.85)", duration: 0.25, ease: "none" },
|
|
3954
|
-
at
|
|
3955
|
-
)
|
|
3956
|
-
);
|
|
3957
|
-
}
|
|
3958
|
-
if (ev.karaoke === true) {
|
|
3959
|
-
const d = Math.min(0.25, Math.max(0.05, w.d));
|
|
3960
|
-
tweens.push(gsapFromTo(target, { opacity: 0.4 }, { opacity: 1, duration: d, ease: "none" }, at));
|
|
3961
|
-
}
|
|
3962
|
-
});
|
|
3963
|
-
}
|
|
3964
|
-
parts.push(
|
|
3965
|
-
`<div id="cue-${idx}" class="clip cue" dir="auto" data-start="${num(ev.outStart)}" data-duration="${num(Math.max(0.05, ev.outEnd - ev.outStart))}" data-track-index="10">${inner}</div>`
|
|
3966
|
-
);
|
|
3967
|
-
continue;
|
|
3968
|
-
}
|
|
3969
|
-
if (ev.kind === "overlay" && isImgOverlay(ev)) {
|
|
3970
|
-
imgOverlays = true;
|
|
3971
|
-
const o = ev.overlay;
|
|
3972
|
-
const idx = ovIdx++;
|
|
3973
|
-
const defaultW = o.kind === "sticker" ? STICKER_DEFAULT_W : IMAGE_DEFAULT_W;
|
|
3974
|
-
const wPx = (o.size?.w ?? defaultW) * COMPOSE_WIDTH;
|
|
3975
|
-
const hPx = o.size?.h !== void 0 ? o.size.h * COMPOSE_HEIGHT : wPx;
|
|
3976
|
-
const left = (o.layout?.x ?? 0.5) * COMPOSE_WIDTH - wPx / 2;
|
|
3977
|
-
const top = (o.layout?.y ?? 0.82) * COMPOSE_HEIGHT - hPx / 2;
|
|
3978
|
-
const zStyle = o.z !== void 0 ? `;z-index:${o.z}` : "";
|
|
3979
|
-
const dur2 = Math.max(0.05, ev.outEnd - ev.outStart);
|
|
3980
|
-
parts.push(
|
|
3981
|
-
`<img id="ov-${idx}" class="clip img" src="${escAttr(COMPOSE_ASSET_PREFIX + o.asset.ref)}" alt="" data-start="${num(ev.outStart)}" data-duration="${num(dur2)}" data-track-index="${30 + imgLane++}" style="left:${num(left)}px;top:${num(top)}px;width:${num(wPx)}px;height:${num(hPx)}px${zStyle}">`
|
|
3982
|
-
);
|
|
3983
|
-
if (o.enter) {
|
|
3984
|
-
const d = Math.min(o.enter.durationS, dur2);
|
|
3985
|
-
const [from, to] = fxVars(o.enter.preset, "enter");
|
|
3986
|
-
tweens.push(gsapFromTo(`#ov-${idx}`, from, { ...to, duration: d }, ev.outStart));
|
|
3987
|
-
}
|
|
3988
|
-
if (o.exit) {
|
|
3989
|
-
const d = Math.min(o.exit.durationS, dur2);
|
|
3990
|
-
const [from, to] = fxVars(o.exit.preset, "exit");
|
|
3991
|
-
tweens.push(gsapFromTo(`#ov-${idx}`, from, { ...to, duration: d }, ev.outEnd - d));
|
|
3992
|
-
}
|
|
3993
|
-
continue;
|
|
3994
|
-
}
|
|
3995
|
-
if (ev.kind === "overlay" && ev.overlay && typeof ev.overlay.text === "string") {
|
|
3996
|
-
const o = ev.overlay;
|
|
3997
|
-
const text = ev.overlay.text;
|
|
3998
|
-
const dur2 = num(Math.max(0.05, ev.outEnd - ev.outStart));
|
|
3999
|
-
if (o.kind === "title-card") {
|
|
4000
|
-
parts.push(
|
|
4001
|
-
`<div id="ov-${ovIdx++}" class="clip card" data-start="${num(ev.outStart)}" data-duration="${dur2}" data-track-index="20"><span>${escHtml(text)}</span></div>`
|
|
4002
|
-
);
|
|
4003
|
-
} else if (o.kind === "text") {
|
|
4004
|
-
const x = ((o.layout?.x ?? 0.5) * 100).toFixed(1);
|
|
4005
|
-
const y = ((o.layout?.y ?? 0.82) * 100).toFixed(1);
|
|
4006
|
-
parts.push(
|
|
4007
|
-
`<div id="ov-${ovIdx++}" class="clip otext" style="left:${x}%;top:${y}%" data-start="${num(ev.outStart)}" data-duration="${dur2}" data-track-index="20">${escHtml(text)}</div>`
|
|
4008
|
-
);
|
|
4009
|
-
}
|
|
4010
|
-
}
|
|
4011
|
-
}
|
|
4012
|
-
const animated = tweens.length > 0;
|
|
4013
|
-
let extraCss = "";
|
|
4014
|
-
if (styledCues) {
|
|
4015
|
-
extraCss += ` .wd { display: inline-block; border-radius: 0.12em; }
|
|
4016
|
-
`;
|
|
4017
|
-
}
|
|
4018
|
-
if (imgOverlays) {
|
|
4019
|
-
extraCss += ` .img { inset: auto; object-fit: contain; }
|
|
4020
|
-
`;
|
|
4021
|
-
}
|
|
4022
|
-
const gsapTag = animated ? `
|
|
4023
|
-
<script src="${COMPOSE_GSAP_SRC}"></script>` : "";
|
|
4024
|
-
const noTimeline = animated ? "" : "data-no-timeline ";
|
|
4025
|
-
const script = animated ? `
|
|
4026
|
-
<script>
|
|
4027
|
-
window.__timelines = window.__timelines || {};
|
|
4028
|
-
const tl = gsap.timeline({ paused: true });
|
|
4029
|
-
${tweens.join(
|
|
4030
|
-
"\n"
|
|
4031
|
-
)}
|
|
4032
|
-
window.__timelines["root"] = tl;
|
|
4033
|
-
</script>` : "";
|
|
4034
|
-
return `<!doctype html>
|
|
4035
|
-
<html>
|
|
4036
|
-
<head>
|
|
4037
|
-
<meta charset="utf-8">${gsapTag}
|
|
4038
|
-
<style>
|
|
4039
|
-
html, body { margin: 0; padding: 0; background: #000; }
|
|
4040
|
-
#root { position: relative; width: ${COMPOSE_WIDTH}px; height: ${COMPOSE_HEIGHT}px; overflow: hidden; background: #000; }
|
|
4041
|
-
.clip { position: absolute; inset: 0; }
|
|
4042
|
-
.vid { width: 100%; height: 100%; object-fit: cover; }
|
|
4043
|
-
.cue {
|
|
4044
|
-
top: auto; bottom: 15%; height: auto; inset-inline: 0;
|
|
4045
|
-
text-align: center; color: #fff; font: 600 54px/1.25 -apple-system, "Segoe UI", Arial, sans-serif;
|
|
4046
|
-
text-shadow: 0 2px 12px rgba(0,0,0,.85); padding: 0 60px;
|
|
4047
|
-
}
|
|
4048
|
-
.card {
|
|
4049
|
-
background: rgba(0,0,0,.92); display: flex; align-items: center; justify-content: center;
|
|
4050
|
-
color: #fff; font: 700 72px/1.2 -apple-system, "Segoe UI", Arial, sans-serif;
|
|
4051
|
-
text-align: center; padding: 0 90px;
|
|
4052
|
-
}
|
|
4053
|
-
.otext {
|
|
4054
|
-
inset: auto; transform: translate(-50%, -50%); width: max-content; max-width: 90%;
|
|
4055
|
-
color: #fff; font: 600 48px/1.25 -apple-system, "Segoe UI", Arial, sans-serif;
|
|
4056
|
-
text-shadow: 0 2px 12px rgba(0,0,0,.85); text-align: center;
|
|
4057
|
-
}
|
|
4058
|
-
${extraCss}</style>
|
|
4059
|
-
</head>
|
|
4060
|
-
<body>
|
|
4061
|
-
<div id="root" data-composition-id="root" ${noTimeline}data-start="0" data-duration="${num(totalDuration2)}" data-width="${COMPOSE_WIDTH}" data-height="${COMPOSE_HEIGHT}">
|
|
4062
|
-
${parts.join("\n")}
|
|
4063
|
-
</div>${script}
|
|
4064
|
-
</body>
|
|
4065
|
-
</html>
|
|
4066
|
-
`;
|
|
4067
|
-
}
|
|
4068
|
-
function isRamp(op) {
|
|
4069
|
-
return op.toRect !== void 0 || op.toScale !== void 0;
|
|
4070
|
-
}
|
|
4071
|
-
function rampEase(ease) {
|
|
4072
|
-
const i = ease?.in ?? 0;
|
|
4073
|
-
const o = ease?.out ?? 0;
|
|
4074
|
-
if (i > 0 && o > 0) return "power2.inOut";
|
|
4075
|
-
if (i > 0) return "power2.in";
|
|
4076
|
-
if (o > 0) return "power2.out";
|
|
4077
|
-
return "none";
|
|
4078
|
-
}
|
|
4079
|
-
function gsapFromTo(target, from, to, at) {
|
|
4080
|
-
return `tl.fromTo(${JSON.stringify(target)}, ${tweenVars(from)}, ${tweenVars(to)}, ${num(at)});`;
|
|
4081
|
-
}
|
|
4082
|
-
function tweenVars(v) {
|
|
4083
|
-
const inner = Object.entries(v).map(([k, val]) => `${k}: ${typeof val === "number" ? num(val) : JSON.stringify(val)}`).join(", ");
|
|
4084
|
-
return `{ ${inner} }`;
|
|
4085
|
-
}
|
|
4086
|
-
function fxVars(preset, phase) {
|
|
4087
|
-
const P = {
|
|
4088
|
-
"fade:enter": [{ opacity: 0 }, { opacity: 1, ease: "power1.out" }],
|
|
4089
|
-
"fade:exit": [{ opacity: 1 }, { opacity: 0, ease: "power1.in" }],
|
|
4090
|
-
"pop:enter": [{ scale: 0.2, opacity: 0 }, { scale: 1, opacity: 1, ease: "back.out(2)" }],
|
|
4091
|
-
"pop:exit": [{ scale: 1, opacity: 1 }, { scale: 0.2, opacity: 0, ease: "back.in(2)" }],
|
|
4092
|
-
"slide-up:enter": [{ y: 60, opacity: 0 }, { y: 0, opacity: 1, ease: "power2.out" }],
|
|
4093
|
-
"slide-up:exit": [{ y: 0, opacity: 1 }, { y: -60, opacity: 0, ease: "power2.in" }],
|
|
4094
|
-
"slide-down:enter": [{ y: -60, opacity: 0 }, { y: 0, opacity: 1, ease: "power2.out" }],
|
|
4095
|
-
"slide-down:exit": [{ y: 0, opacity: 1 }, { y: 60, opacity: 0, ease: "power2.in" }],
|
|
4096
|
-
"scale-in:enter": [{ scale: 0.6, opacity: 0 }, { scale: 1, opacity: 1, ease: "power2.out" }],
|
|
4097
|
-
"scale-in:exit": [{ scale: 1, opacity: 1 }, { scale: 0.6, opacity: 0, ease: "power2.in" }]
|
|
4098
|
-
};
|
|
4099
|
-
const [from, to] = P[`${preset}:${phase}`];
|
|
4100
|
-
return [{ ...from }, { ...to }];
|
|
4101
|
-
}
|
|
4102
|
-
function num(x) {
|
|
4103
|
-
return String(Number(x.toFixed(3)));
|
|
4104
|
-
}
|
|
4105
|
-
function escHtml(s) {
|
|
4106
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
4107
|
-
}
|
|
4108
|
-
function escAttr(s) {
|
|
4109
|
-
return escHtml(s).replace(/"/g, """);
|
|
4110
|
-
}
|
|
4111
|
-
function escSquote(s) {
|
|
4112
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/'/g, "'");
|
|
4113
|
-
}
|
|
4114
|
-
|
|
4115
|
-
// src/timeline-v2/io.ts
|
|
4116
|
-
import { createHash as createHash2 } from "crypto";
|
|
4117
|
-
import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync10, readdirSync as readdirSync4, writeFileSync as writeFileSync7 } from "fs";
|
|
4118
|
-
import { join as join11, resolve as resolve3 } from "path";
|
|
4119
|
-
function timelineJsonPath(jobDir) {
|
|
4120
|
-
return join11(jobDir, "timeline.json");
|
|
4121
|
-
}
|
|
4122
|
-
function planPath(jobDir) {
|
|
4123
|
-
return join11(jobDir, "resolved", "plan.json");
|
|
4124
|
-
}
|
|
4125
|
-
function diagnosticsPath(jobDir) {
|
|
4126
|
-
return join11(jobDir, "resolved", "diagnostics.json");
|
|
4127
|
-
}
|
|
4128
|
-
function loadTimeline(jobDir) {
|
|
4129
|
-
const path = timelineJsonPath(jobDir);
|
|
4130
|
-
if (!existsSync8(path)) {
|
|
4131
|
-
return {
|
|
4132
|
-
doc: null,
|
|
4133
|
-
raw: null,
|
|
4134
|
-
errors: [
|
|
4135
|
-
{
|
|
4136
|
-
path: "(file)",
|
|
4137
|
-
code: "missing-file",
|
|
4138
|
-
message: `timeline not found: ${path}`,
|
|
4139
|
-
suggestion: "author timeline.json, or lift an existing edl.json with timelineFromEdl"
|
|
4140
|
-
}
|
|
4141
|
-
]
|
|
4142
|
-
};
|
|
4143
|
-
}
|
|
4144
|
-
let raw;
|
|
4145
|
-
try {
|
|
4146
|
-
raw = JSON.parse(readFileSync10(path, "utf8"));
|
|
4147
|
-
} catch (err) {
|
|
4148
|
-
return {
|
|
4149
|
-
doc: null,
|
|
4150
|
-
raw: null,
|
|
4151
|
-
errors: [
|
|
4152
|
-
{
|
|
4153
|
-
path: "(file)",
|
|
4154
|
-
code: "invalid-json",
|
|
4155
|
-
message: `timeline.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
4156
|
-
}
|
|
4157
|
-
]
|
|
4158
|
-
};
|
|
4159
|
-
}
|
|
4160
|
-
const parsed = parseTimeline(raw);
|
|
4161
|
-
return { doc: parsed.doc, errors: parsed.errors, raw };
|
|
4162
|
-
}
|
|
4163
|
-
function transcriptFileForSource(jobDir, name) {
|
|
4164
|
-
const dir = resolve3(jobDir, "transcripts");
|
|
4165
|
-
const direct = join11(dir, `${name}.json`);
|
|
4166
|
-
if (existsSync8(direct)) return direct;
|
|
4167
|
-
const allJson = existsSync8(dir) ? readdirSync4(dir).filter((f) => f.endsWith(".json")) : [];
|
|
4168
|
-
if (allJson.length === 1) return join11(dir, allJson[0]);
|
|
4169
|
-
return null;
|
|
4170
|
-
}
|
|
4171
|
-
function sha256File(path) {
|
|
4172
|
-
return "sha256:" + createHash2("sha256").update(readFileSync10(path)).digest("hex");
|
|
4173
|
-
}
|
|
4174
|
-
async function gatherCompileCtx(jobDir, doc, opts = {}) {
|
|
4175
|
-
const transcripts = {};
|
|
4176
|
-
const transcriptHashes = {};
|
|
4177
|
-
for (const name of Object.keys(doc.sources)) {
|
|
4178
|
-
const file = transcriptFileForSource(jobDir, name);
|
|
4179
|
-
if (!file) continue;
|
|
4180
|
-
try {
|
|
4181
|
-
transcripts[name] = JSON.parse(readFileSync10(file, "utf8"));
|
|
4182
|
-
transcriptHashes[name] = sha256File(file);
|
|
4183
|
-
} catch {
|
|
4184
|
-
transcripts[name] = void 0;
|
|
4185
|
-
}
|
|
4186
|
-
}
|
|
4187
|
-
const ctx = { transcripts, transcriptHashes };
|
|
4188
|
-
const assetsPresent = {};
|
|
4189
|
-
const statRef = (ref) => {
|
|
4190
|
-
if (ref !== void 0 && !(ref in assetsPresent)) {
|
|
4191
|
-
assetsPresent[ref] = existsSync8(resolvePath(ref, jobDir));
|
|
4192
|
-
}
|
|
4193
|
-
};
|
|
4194
|
-
for (const ov of doc.overlays) statRef(ov.asset?.ref);
|
|
4195
|
-
for (const a of doc.audio) statRef(a.asset.ref);
|
|
4196
|
-
if (Object.keys(assetsPresent).length > 0) ctx.assetsPresent = assetsPresent;
|
|
4197
|
-
if (!opts.noDetect) {
|
|
4198
|
-
const dopts = { ...DEFAULT_DESILENCE, ...doc.settings.desilence ?? {} };
|
|
4199
|
-
const silencesBySource = {};
|
|
4200
|
-
for (const [name, src] of Object.entries(doc.sources)) {
|
|
4201
|
-
const srcPath = resolvePath(src.path, jobDir);
|
|
4202
|
-
const dur2 = src.durationS ?? await mediaDuration(srcPath);
|
|
4203
|
-
silencesBySource[name] = await detectSilences(srcPath, dopts.noiseDb, dopts.minSilence, dur2);
|
|
4204
|
-
}
|
|
4205
|
-
ctx.silencesBySource = silencesBySource;
|
|
4206
|
-
}
|
|
4207
|
-
return ctx;
|
|
4208
|
-
}
|
|
4209
|
-
function writeCompileArtifacts(jobDir, result) {
|
|
4210
|
-
const dir = join11(jobDir, "resolved");
|
|
4211
|
-
mkdirSync4(dir, { recursive: true });
|
|
4212
|
-
const written = { diagnostics: diagnosticsPath(jobDir) };
|
|
4213
|
-
writeFileSync7(written.diagnostics, JSON.stringify(result.diagnostics, null, 2) + "\n", "utf8");
|
|
4214
|
-
if (result.ok && result.plan) {
|
|
4215
|
-
written.plan = planPath(jobDir);
|
|
4216
|
-
writeFileSync7(written.plan, JSON.stringify(result.plan, null, 2) + "\n", "utf8");
|
|
4217
|
-
}
|
|
4218
|
-
if (result.ok && result.legacyEdl) {
|
|
4219
|
-
written.edl = join11(jobDir, "edl.json");
|
|
4220
|
-
writeFileSync7(written.edl, JSON.stringify(result.legacyEdl, null, 2) + "\n", "utf8");
|
|
4221
|
-
}
|
|
4222
|
-
return written;
|
|
4223
|
-
}
|
|
4224
|
-
function updateCaptionsBlock(jobDir, preset) {
|
|
4225
|
-
const path = timelineJsonPath(jobDir);
|
|
4226
|
-
if (!existsSync8(path)) throw new Error(`timeline not found: ${path}`);
|
|
4227
|
-
const raw = JSON.parse(readFileSync10(path, "utf8"));
|
|
4228
|
-
const captions = raw.captions !== null && typeof raw.captions === "object" ? raw.captions : {};
|
|
4229
|
-
captions.enabled = true;
|
|
4230
|
-
captions.preset = preset;
|
|
4231
|
-
raw.captions = captions;
|
|
4232
|
-
writeFileSync7(path, JSON.stringify(raw, null, 2) + "\n", "utf8");
|
|
4233
|
-
return { raw };
|
|
4234
|
-
}
|
|
4235
|
-
function formatDiagnostics(d) {
|
|
4236
|
-
const lines = [];
|
|
4237
|
-
for (const e of d.errors) lines.push(` [error] ${e.path}: ${e.message}${e.suggestion ? ` (${e.suggestion})` : ""}`);
|
|
4238
|
-
for (const w of d.warnings) lines.push(` [warn] ${w.path}: ${w.message}`);
|
|
4239
|
-
if (d.dropped.length > 0) lines.push(` dropped: ${d.dropped.join(", ")}`);
|
|
4240
|
-
return lines;
|
|
4241
|
-
}
|
|
4242
|
-
|
|
4243
|
-
export {
|
|
4244
|
-
configDir,
|
|
4245
|
-
configEnvPath,
|
|
4246
|
-
loadApiKey,
|
|
4247
|
-
ensureVerticalMaster,
|
|
4248
|
-
transcribeOne,
|
|
4249
|
-
screenTakes,
|
|
4250
|
-
packTranscripts,
|
|
4251
|
-
PRESETS,
|
|
4252
|
-
getPreset,
|
|
4253
|
-
decideGradeFilter,
|
|
4254
|
-
autoGradeForClip,
|
|
4255
|
-
render,
|
|
4256
|
-
computeEnvelope,
|
|
4257
|
-
timelineView,
|
|
4258
|
-
loadWords,
|
|
4259
|
-
writeWordDump,
|
|
4260
|
-
writeCoverage,
|
|
4261
|
-
writeTranscriptArtifacts,
|
|
4262
|
-
ensureTranscribed,
|
|
4263
|
-
edlPath,
|
|
4264
|
-
readEdl,
|
|
4265
|
-
cutBoundaries,
|
|
4266
|
-
writeBoundaries,
|
|
4267
|
-
generateQcImages,
|
|
4268
|
-
writeWarningsFromReview,
|
|
4269
|
-
artifactPaths,
|
|
4270
|
-
writeAgentManifest,
|
|
4271
|
-
statusPayload,
|
|
4272
|
-
duplicatedSpans,
|
|
4273
|
-
duplicatedPhrases,
|
|
4274
|
-
duplicatedPhrase,
|
|
4275
|
-
straddlingGap,
|
|
4276
|
-
verify,
|
|
4277
|
-
DEFAULT_DESILENCE,
|
|
4278
|
-
silenceDetectArgs,
|
|
4279
|
-
parseSilenceDetect,
|
|
4280
|
-
tightenRange,
|
|
4281
|
-
tightenRanges,
|
|
4282
|
-
loadWordsBySource,
|
|
4283
|
-
detectSilences,
|
|
4284
|
-
desilenceEdl,
|
|
4285
|
-
learnedEditingGuardrails,
|
|
4286
|
-
agentizePrompt,
|
|
4287
|
-
writeVideoEditingPrompt,
|
|
4288
|
-
writeSilencePrompt,
|
|
4289
|
-
timelineV2Schema,
|
|
4290
|
-
parseTimeline,
|
|
4291
|
-
validateTimeline,
|
|
4292
|
-
CAPABILITIES,
|
|
4293
|
-
usesFromDoc,
|
|
4294
|
-
checkCapabilities,
|
|
4295
|
-
normalizeToken,
|
|
4296
|
-
normalizePhrase,
|
|
4297
|
-
wordStream,
|
|
4298
|
-
findPhrase,
|
|
4299
|
-
fuzzySuggestions,
|
|
4300
|
-
resolvePhrase,
|
|
4301
|
-
timelineHash,
|
|
4302
|
-
compileTimeline,
|
|
4303
|
-
timelineFromEdl,
|
|
4304
|
-
COMPOSE_WIDTH,
|
|
4305
|
-
COMPOSE_HEIGHT,
|
|
4306
|
-
COMPOSE_ENTRY,
|
|
4307
|
-
COMPOSE_VIDEO_SRC,
|
|
4308
|
-
COMPOSE_AUDIO_SRC,
|
|
4309
|
-
composeCounts,
|
|
4310
|
-
composeHtml,
|
|
4311
|
-
timelineJsonPath,
|
|
4312
|
-
planPath,
|
|
4313
|
-
diagnosticsPath,
|
|
4314
|
-
loadTimeline,
|
|
4315
|
-
transcriptFileForSource,
|
|
4316
|
-
sha256File,
|
|
4317
|
-
gatherCompileCtx,
|
|
4318
|
-
writeCompileArtifacts,
|
|
4319
|
-
updateCaptionsBlock,
|
|
4320
|
-
formatDiagnostics
|
|
4321
|
-
};
|