@amaster.ai/pi-video-gen 0.1.2-beta.48

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.
Files changed (85) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +196 -0
  3. package/dist/compose.d.ts +43 -0
  4. package/dist/compose.d.ts.map +1 -0
  5. package/dist/compose.js +308 -0
  6. package/dist/compose.js.map +1 -0
  7. package/dist/config.d.ts +29 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +305 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/errors.d.ts +50 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +99 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/ffmpeg.d.ts +64 -0
  16. package/dist/ffmpeg.d.ts.map +1 -0
  17. package/dist/ffmpeg.js +292 -0
  18. package/dist/ffmpeg.js.map +1 -0
  19. package/dist/index.d.ts +3 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +837 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/jobs/store.d.ts +124 -0
  24. package/dist/jobs/store.d.ts.map +1 -0
  25. package/dist/jobs/store.js +386 -0
  26. package/dist/jobs/store.js.map +1 -0
  27. package/dist/providers/ark.d.ts +26 -0
  28. package/dist/providers/ark.d.ts.map +1 -0
  29. package/dist/providers/ark.js +178 -0
  30. package/dist/providers/ark.js.map +1 -0
  31. package/dist/providers/dashscope.d.ts +28 -0
  32. package/dist/providers/dashscope.d.ts.map +1 -0
  33. package/dist/providers/dashscope.js +200 -0
  34. package/dist/providers/dashscope.js.map +1 -0
  35. package/dist/providers/kling.d.ts +46 -0
  36. package/dist/providers/kling.d.ts.map +1 -0
  37. package/dist/providers/kling.js +245 -0
  38. package/dist/providers/kling.js.map +1 -0
  39. package/dist/providers/models.d.ts +16 -0
  40. package/dist/providers/models.d.ts.map +1 -0
  41. package/dist/providers/models.js +152 -0
  42. package/dist/providers/models.js.map +1 -0
  43. package/dist/providers/newapi.d.ts +3 -0
  44. package/dist/providers/newapi.d.ts.map +1 -0
  45. package/dist/providers/newapi.js +203 -0
  46. package/dist/providers/newapi.js.map +1 -0
  47. package/dist/providers/openrouter.d.ts +21 -0
  48. package/dist/providers/openrouter.d.ts.map +1 -0
  49. package/dist/providers/openrouter.js +169 -0
  50. package/dist/providers/openrouter.js.map +1 -0
  51. package/dist/providers/request.d.ts +4 -0
  52. package/dist/providers/request.d.ts.map +1 -0
  53. package/dist/providers/request.js +20 -0
  54. package/dist/providers/request.js.map +1 -0
  55. package/dist/providers/task.d.ts +53 -0
  56. package/dist/providers/task.d.ts.map +1 -0
  57. package/dist/providers/task.js +209 -0
  58. package/dist/providers/task.js.map +1 -0
  59. package/dist/render.d.ts +53 -0
  60. package/dist/render.d.ts.map +1 -0
  61. package/dist/render.js +480 -0
  62. package/dist/render.js.map +1 -0
  63. package/dist/text-layer.d.ts +18 -0
  64. package/dist/text-layer.d.ts.map +1 -0
  65. package/dist/text-layer.js +168 -0
  66. package/dist/text-layer.js.map +1 -0
  67. package/dist/timeline-render.d.ts +33 -0
  68. package/dist/timeline-render.d.ts.map +1 -0
  69. package/dist/timeline-render.js +1051 -0
  70. package/dist/timeline-render.js.map +1 -0
  71. package/dist/timeline.d.ts +64 -0
  72. package/dist/timeline.d.ts.map +1 -0
  73. package/dist/timeline.js +246 -0
  74. package/dist/timeline.js.map +1 -0
  75. package/dist/tts/edge-tts.d.ts +22 -0
  76. package/dist/tts/edge-tts.d.ts.map +1 -0
  77. package/dist/tts/edge-tts.js +55 -0
  78. package/dist/tts/edge-tts.js.map +1 -0
  79. package/dist/types.d.ts +161 -0
  80. package/dist/types.d.ts.map +1 -0
  81. package/dist/types.js +13 -0
  82. package/dist/types.js.map +1 -0
  83. package/package.json +93 -0
  84. package/preview.png +0 -0
  85. package/skills/video-gen/SKILL.md +284 -0
@@ -0,0 +1,1051 @@
1
+ import { COPYFILE_EXCL } from 'node:constants';
2
+ import { createHash } from 'node:crypto';
3
+ import { copyFile, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile, } from 'node:fs/promises';
4
+ import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
5
+ import { resolveOutputDir } from './config.js';
6
+ import { safeBasename, VideoGenError } from './errors.js';
7
+ import { probeDuration, probeStreams, probeVideoDuration, resolveFfmpeg, resolveFfprobe, resolveGplFfmpeg, runFfmpegCommand, } from './ffmpeg.js';
8
+ import { assertSafeId, hashFileSha256, loadTimelineJob, pathIsMissing, saveTimelineJob, } from './jobs/store.js';
9
+ import { renderBurnedSubtitle, renderTextOverlay } from './text-layer.js';
10
+ import { assertMediaReadable, parseTimelineSpec, timelineSourcePath, } from './timeline.js';
11
+ import { edgeTtsProvider, parseVoiceRef } from './tts/edge-tts.js';
12
+ const AUTO_PAD_SEC = 0.6;
13
+ /**
14
+ * Write ffmpeg output to a TEMP path first, then atomic rename into place.
15
+ * A cancelled run leaves a temp file, never a corrupt "final" — resume only
16
+ * trusts a path that was renamed into existence.
17
+ */
18
+ async function writeAtomic(destPath, run) {
19
+ const tmpDir = await mkdtemp(join(dirname(destPath), `.${basename(destPath)}.tmp-`));
20
+ const tmp = join(tmpDir, basename(destPath));
21
+ try {
22
+ await run(tmp);
23
+ await rename(tmp, destPath);
24
+ }
25
+ finally {
26
+ await rm(tmpDir, { recursive: true, force: true });
27
+ }
28
+ }
29
+ async function hasSafeCachedArtifact(path, realJob) {
30
+ const stat = await lstat(path).catch((error) => {
31
+ if (error.code === 'ENOENT')
32
+ return undefined;
33
+ throw error;
34
+ });
35
+ if (!stat)
36
+ return false;
37
+ if (!stat.isFile() || stat.isSymbolicLink()) {
38
+ throw new VideoGenError(`Cached artifact "${safeBasename(path)}" is not a regular file (symlink?).`, 'timeline: cached artifact invalid');
39
+ }
40
+ const real = await realpath(path);
41
+ if (!real.startsWith(`${realJob}${sep}`)) {
42
+ throw new VideoGenError(`Cached artifact "${safeBasename(path)}" resolves outside the job directory.`, 'timeline: cached artifact escapes');
43
+ }
44
+ return true;
45
+ }
46
+ function artifactKey(jobDir, path) {
47
+ return relative(jobDir, path).split(sep).join('/');
48
+ }
49
+ async function canReuseArtifact(opts) {
50
+ const key = artifactKey(opts.jobDir, opts.path);
51
+ const expected = opts.manifest.artifactHashes[key];
52
+ if (!expected && opts.manifest.state === 'done') {
53
+ throw new VideoGenError(`The completed manifest is missing the artifact hash for "${safeBasename(opts.path)}". Start a NEW job directory or restore the manifest.`, 'timeline: completed artifact hash missing');
54
+ }
55
+ if (!expected) {
56
+ let invalidated = false;
57
+ for (const artifact of Object.keys(opts.manifest.artifactHashes)) {
58
+ if (!opts.validatedArtifactKeys.has(artifact)) {
59
+ delete opts.manifest.artifactHashes[artifact];
60
+ invalidated = true;
61
+ }
62
+ }
63
+ if (invalidated)
64
+ saveTimelineJob(opts.jobDir, opts.manifest);
65
+ }
66
+ if (!(await hasSafeCachedArtifact(opts.path, opts.realJob))) {
67
+ if (expected) {
68
+ throw new VideoGenError(`Cached artifact "${safeBasename(opts.path)}" is missing.`, 'timeline: cached artifact missing');
69
+ }
70
+ return false;
71
+ }
72
+ if (!expected) {
73
+ await rm(opts.path);
74
+ return false;
75
+ }
76
+ if ((await hashFileSha256(opts.path, opts.signal)) !== expected) {
77
+ throw new VideoGenError(`Cached artifact "${safeBasename(opts.path)}" changed since it was created.`, 'timeline: cached artifact changed');
78
+ }
79
+ opts.validatedArtifactKeys.add(key);
80
+ return true;
81
+ }
82
+ async function recordArtifact(opts) {
83
+ if (!(await hasSafeCachedArtifact(opts.path, opts.realJob))) {
84
+ throw new VideoGenError(`Expected cached artifact "${safeBasename(opts.path)}" was not created.`, 'timeline: cached artifact missing');
85
+ }
86
+ const key = artifactKey(opts.jobDir, opts.path);
87
+ opts.manifest.artifactHashes[key] = await hashFileSha256(opts.path, opts.signal);
88
+ opts.validatedArtifactKeys.add(key);
89
+ saveTimelineJob(opts.jobDir, opts.manifest);
90
+ }
91
+ function sha256hex(content) {
92
+ return createHash('sha256').update(content).digest('hex');
93
+ }
94
+ function specFingerprint(opts) {
95
+ return sha256hex(JSON.stringify(opts));
96
+ }
97
+ function motionFilter(motion, frames, width, height, fps, fit = 'contain') {
98
+ const m = motion ?? 'static';
99
+ if (m === 'static') {
100
+ return fit === 'cover'
101
+ ? `scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height},setsar=1`
102
+ : `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1`;
103
+ }
104
+ // zoompan truncates crop origins to chroma-aligned integers. Oversampling
105
+ // keeps those source-pixel jumps subpixel-sized after the final downscale;
106
+ // cap the long edge so extreme aspect ratios cannot create huge frames.
107
+ const scale = Math.max(1.5, Math.min(2880 / Math.min(width, height), 8192 / Math.max(width, height)));
108
+ const bigW = 2 * Math.ceil((width * scale) / 2);
109
+ const bigH = 2 * Math.ceil((height * scale) / 2);
110
+ const pre = `scale=${bigW}:${bigH}:force_original_aspect_ratio=increase,crop=${bigW}:${bigH}`;
111
+ switch (m) {
112
+ case 'kenburns-in':
113
+ return `${pre},zoompan=z='min(1+0.06*on/${frames},1.12)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=${frames}:s=${width}x${height}:fps=${fps}`;
114
+ case 'kenburns-out':
115
+ return `${pre},zoompan=z='max(1.12-0.06*on/${frames},1.0)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=${frames}:s=${width}x${height}:fps=${fps}`;
116
+ case 'zoom-in':
117
+ return `${pre},zoompan=z='min(1+0.10*on/${frames},1.2)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=${frames}:s=${width}x${height}:fps=${fps}`;
118
+ case 'zoom-out':
119
+ return `${pre},zoompan=z='max(1.2-0.10*on/${frames},1.0)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=${frames}:s=${width}x${height}:fps=${fps}`;
120
+ case 'pan-left':
121
+ return `${pre},zoompan=z='1.15':x='(iw-iw/zoom)*on/${frames}':y='(ih-ih/zoom)/2':d=${frames}:s=${width}x${height}:fps=${fps}`;
122
+ case 'pan-right':
123
+ return `${pre},zoompan=z='1.15':x='(iw-iw/zoom)*(1-on/${frames})':y='(ih-ih/zoom)/2':d=${frames}:s=${width}x${height}:fps=${fps}`;
124
+ }
125
+ }
126
+ function srtTimestamp(totalSec) {
127
+ const ms = Math.round(totalSec * 1000);
128
+ const h = Math.floor(ms / 3_600_000);
129
+ const m = Math.floor((ms % 3_600_000) / 60_000);
130
+ const s = Math.floor((ms % 60_000) / 1000);
131
+ const rest = ms % 1000;
132
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')},${String(rest).padStart(3, '0')}`;
133
+ }
134
+ function splitCueText(text, maxLen = 24) {
135
+ const t = text.trim();
136
+ if (t.length <= maxLen)
137
+ return t;
138
+ const mid = Math.floor(t.length / 2);
139
+ let cut = mid;
140
+ for (let i = mid; i < Math.min(mid + 6, t.length); i++) {
141
+ if (',。、;:!?,.!?;: '.includes(t[i])) {
142
+ cut = i + 1;
143
+ break;
144
+ }
145
+ }
146
+ return `${t.slice(0, cut).trim()}\n${t.slice(cut).trim()}`;
147
+ }
148
+ function transitionOverlap(segment) {
149
+ return segment.transitionTo?.durationSec ?? 0;
150
+ }
151
+ function isSourceAudioEnabled(segment) {
152
+ return Boolean(segment.video &&
153
+ segment.sourceHasAudio &&
154
+ !segment.sourceAudio?.muted &&
155
+ (segment.sourceAudio?.volume ?? 1) > 0);
156
+ }
157
+ function segmentSnapshotName(segment) {
158
+ const source = timelineSourcePath(segment);
159
+ return `segment-${segment.id}${extname(source) || (segment.video ? '.mp4' : '.png')}`;
160
+ }
161
+ export async function runTimeline(opts) {
162
+ const { settings, cwd } = opts;
163
+ const outputDir = resolveOutputDir(settings, cwd);
164
+ // ── 1. spec + job identity (realpath first) ─────────────────────────────
165
+ const absSpecPath = resolve(cwd, opts.timelineSpecPath);
166
+ const realSpecPath = await realpath(absSpecPath).catch(() => {
167
+ throw new VideoGenError('Timeline spec not readable. Expected <jobDir>/timeline-input.json under the video-gen output directory (.video-gen).', 'timeline: spec unreadable');
168
+ });
169
+ const realOutput = await realpath(outputDir).catch(() => resolve(outputDir));
170
+ const jobDir = dirname(realSpecPath);
171
+ if (jobDir !== realOutput && !jobDir.startsWith(`${realOutput}${sep}`)) {
172
+ throw new VideoGenError('The timeline job directory must live under the video-gen output directory (.video-gen).', 'timeline: job outside outputDir');
173
+ }
174
+ const jobId = basename(jobDir);
175
+ assertSafeId(jobId, 'job');
176
+ if (basename(realSpecPath) !== 'timeline-input.json') {
177
+ throw new VideoGenError('The timeline spec file must be named timeline-input.json.', 'timeline: spec name');
178
+ }
179
+ const specRaw = await readFile(realSpecPath);
180
+ const spec = parseTimelineSpec(specRaw.toString('utf-8'));
181
+ const width = Number((spec.output?.resolution ?? '1920x1080').split('x')[0]);
182
+ const height = Number((spec.output?.resolution ?? '1920x1080').split('x')[1]);
183
+ const fps = spec.output?.fps ?? 25;
184
+ const tts = opts.tts ?? edgeTtsProvider;
185
+ const voice = parseVoiceRef(spec.voice);
186
+ const bgmSource = spec.bgm;
187
+ const release = opts.activeJobs.acquire(jobDir);
188
+ let manifestForFailure;
189
+ try {
190
+ // Load any existing job BEFORE touching snapshots: a foreign manifest
191
+ // (non-timeline kind) refuses immediately; a timeline manifest is only
192
+ // accepted if its fingerprint still matches. Snapshotting uses a NEW
193
+ // staging directory per run — a drifted spec never touches the old job's
194
+ // frozen assets until the fingerprint matches.
195
+ const existing = loadTimelineJob(jobDir);
196
+ if (existing?.state === 'done' && existing.finalVideoPath !== join(jobDir, 'final_video.mp4')) {
197
+ throw new VideoGenError('The completed manifest final video path is invalid. Restore final_video.mp4 inside the job directory or start a NEW job.', 'timeline: final path invalid');
198
+ }
199
+ if (!existing) {
200
+ for (const name of [
201
+ 'assets',
202
+ 'overlays',
203
+ 'audio',
204
+ 'segments',
205
+ 'qc',
206
+ 'video_track.mp4',
207
+ 'audio_track.mp4',
208
+ '.audio-list.txt',
209
+ 'subtitles.srt',
210
+ 'final_video.mp4',
211
+ ]) {
212
+ const stat = await lstat(join(jobDir, name)).catch((error) => {
213
+ if (error.code === 'ENOENT')
214
+ return undefined;
215
+ throw error;
216
+ });
217
+ if (stat) {
218
+ throw new VideoGenError(`${name} already exists in this fresh timeline job. Move it aside or choose a new job directory; refusing to delete existing files.`, 'timeline: output already exists');
219
+ }
220
+ }
221
+ }
222
+ // ── 2. stage media (and BGM) into a FRESH staging dir, verify fingerprint
223
+ // against the manifest, then swap in as the job's frozen assets. The old
224
+ // job's assets/ is NEVER modified before the fingerprint check passes.
225
+ opts.onUpdate?.('Freezing segment media…');
226
+ await assertMediaReadable(spec, (p) => resolve(cwd, p));
227
+ const assetsDir = join(jobDir, 'assets');
228
+ const stagingDir = await mkdtemp(join(jobDir, '.assets-staging-'));
229
+ // Directory-realpath guard on the JOB dir itself (staging sits inside it).
230
+ const realJob = await realpath(jobDir);
231
+ const mediaHashes = {};
232
+ const mediaSnaps = {};
233
+ let bgmSnapshotPath = '';
234
+ let manifest;
235
+ let snapCounter = 0;
236
+ try {
237
+ for (const seg of spec.segments) {
238
+ const source = timelineSourcePath(seg);
239
+ const absMedia = resolve(cwd, source);
240
+ const destPath = join(stagingDir, segmentSnapshotName(seg));
241
+ let tmp = '';
242
+ for (let i = 0;; i++) {
243
+ const candidate = `${destPath}.tmp-${process.pid}-${snapCounter++}-${i}`;
244
+ try {
245
+ await copyFile(absMedia, candidate, COPYFILE_EXCL);
246
+ tmp = candidate;
247
+ break;
248
+ }
249
+ catch (error) {
250
+ if (error.code === 'EEXIST')
251
+ continue;
252
+ if (error.code === 'ENOENT' &&
253
+ (await pathIsMissing(absMedia))) {
254
+ throw new VideoGenError(`Segment "${seg.id}" media source is not readable: ${safeBasename(source)}.`, 'timeline: media unreadable');
255
+ }
256
+ throw error;
257
+ }
258
+ }
259
+ await rename(tmp, destPath);
260
+ mediaSnaps[seg.id] = destPath;
261
+ mediaHashes[seg.id] = await hashFileSha256(destPath, opts.signal);
262
+ }
263
+ // Freeze the BGM too (path alone doesn't pin content): snapshot + hash,
264
+ // use the snapshot for mixing. Absent BGM → empty string, not included.
265
+ let bgmHash = '';
266
+ if (bgmSource) {
267
+ const absBgm = resolve(cwd, bgmSource);
268
+ const st = await lstat(absBgm).catch(() => null);
269
+ if (!st?.isFile()) {
270
+ throw new VideoGenError(`BGM is not readable: ${safeBasename(bgmSource)}.`, 'timeline: bgm unreadable');
271
+ }
272
+ const bgmDest = join(stagingDir, `bgm${extname(absBgm) || '.mp3'}`);
273
+ let tmp = '';
274
+ for (let i = 0;; i++) {
275
+ const candidate = `${bgmDest}.tmp-${process.pid}-${i}`;
276
+ try {
277
+ await copyFile(absBgm, candidate, COPYFILE_EXCL);
278
+ tmp = candidate;
279
+ break;
280
+ }
281
+ catch (error) {
282
+ if (error.code === 'EEXIST')
283
+ continue;
284
+ if (error.code === 'ENOENT' &&
285
+ (await pathIsMissing(absBgm))) {
286
+ throw new VideoGenError(`BGM is not readable: ${safeBasename(bgmSource)}.`, 'timeline: bgm unreadable');
287
+ }
288
+ throw error;
289
+ }
290
+ }
291
+ await rename(tmp, bgmDest);
292
+ bgmSnapshotPath = bgmDest;
293
+ bgmHash = await hashFileSha256(bgmDest, opts.signal);
294
+ }
295
+ const fingerprint = specFingerprint({
296
+ specRaw: specRaw.toString('utf-8'),
297
+ imageHashes: mediaHashes,
298
+ voice,
299
+ resolution: `${width}x${height}`,
300
+ fps,
301
+ codec: spec.output?.codec,
302
+ bgm: bgmSource ?? null,
303
+ bgmHash,
304
+ });
305
+ if (existing && existing.fingerprint !== fingerprint) {
306
+ throw new VideoGenError('timeline-input.json or a segment media source changed since this job was created. Revisions require a NEW job directory.', 'timeline: input drift');
307
+ }
308
+ manifest = existing ?? {
309
+ jobId,
310
+ kind: 'timeline',
311
+ state: 'working',
312
+ fingerprint,
313
+ imageHashes: mediaHashes,
314
+ artifactHashes: {},
315
+ segments: {},
316
+ updatedAt: new Date().toISOString(),
317
+ };
318
+ const assetsStat = await lstat(assetsDir).catch((error) => {
319
+ if (error.code === 'ENOENT')
320
+ return undefined;
321
+ throw error;
322
+ });
323
+ if (assetsStat && (!assetsStat.isDirectory() || assetsStat.isSymbolicLink())) {
324
+ throw new VideoGenError('assets/ is not a regular directory (symlink?).', 'timeline: assets invalid');
325
+ }
326
+ if (assetsStat) {
327
+ const realAssets = await realpath(assetsDir);
328
+ if (!realAssets.startsWith(`${realJob}${sep}`)) {
329
+ throw new VideoGenError('assets/ resolves outside the job directory (symlink?).', 'timeline: assets escape');
330
+ }
331
+ }
332
+ // A matching manifest already owns an immutable snapshot; do not replace
333
+ // it. New/recovery jobs publish the staged directory with one rename.
334
+ if (existing && !assetsStat) {
335
+ throw new VideoGenError('Frozen timeline assets are missing. Start a NEW job directory.', 'timeline: frozen assets missing');
336
+ }
337
+ if (existing) {
338
+ for (const seg of spec.segments) {
339
+ const frozenPath = join(assetsDir, segmentSnapshotName(seg));
340
+ const frozenStat = await lstat(frozenPath).catch(() => null);
341
+ if (!frozenStat?.isFile() ||
342
+ (await hashFileSha256(frozenPath, opts.signal)) !== mediaHashes[seg.id]) {
343
+ throw new VideoGenError(`Frozen media snapshot "${seg.id}" changed or is missing. Start a NEW job directory.`, 'timeline: frozen media invalid');
344
+ }
345
+ }
346
+ if (bgmSnapshotPath) {
347
+ const frozenBgm = join(assetsDir, basename(bgmSnapshotPath));
348
+ const frozenStat = await lstat(frozenBgm).catch(() => null);
349
+ if (!frozenStat?.isFile() || (await hashFileSha256(frozenBgm, opts.signal)) !== bgmHash) {
350
+ throw new VideoGenError('Frozen BGM snapshot changed or is missing. Start a NEW job directory.', 'timeline: frozen bgm invalid');
351
+ }
352
+ }
353
+ }
354
+ else {
355
+ if (assetsStat) {
356
+ throw new VideoGenError('assets/ already exists in this fresh timeline job. Move it aside or choose a new job directory; refusing to delete existing files.', 'timeline: assets already exist');
357
+ }
358
+ await rename(stagingDir, assetsDir);
359
+ }
360
+ manifestForFailure = manifest;
361
+ }
362
+ finally {
363
+ await rm(stagingDir, { recursive: true, force: true });
364
+ }
365
+ // Repoint media references at the final assets/ paths (staging paths
366
+ // were only for the pre-check fingerprint).
367
+ for (const seg of spec.segments) {
368
+ mediaSnaps[seg.id] = join(assetsDir, segmentSnapshotName(seg));
369
+ }
370
+ if (bgmSnapshotPath) {
371
+ bgmSnapshotPath = join(assetsDir, basename(bgmSnapshotPath));
372
+ }
373
+ const validatedArtifactKeys = new Set();
374
+ const canReuse = (path) => canReuseArtifact({
375
+ path,
376
+ jobDir,
377
+ realJob,
378
+ manifest,
379
+ validatedArtifactKeys,
380
+ signal: opts.signal,
381
+ });
382
+ const record = (path) => recordArtifact({
383
+ path,
384
+ jobDir,
385
+ realJob,
386
+ manifest,
387
+ validatedArtifactKeys,
388
+ signal: opts.signal,
389
+ });
390
+ // ── 3. per-segment: overlay (sharp) + narration (TTS) + duration ──────
391
+ const overlaysDir = join(jobDir, 'overlays');
392
+ const audioDir = join(jobDir, 'audio');
393
+ await mkdir(overlaysDir, { recursive: true });
394
+ await mkdir(audioDir, { recursive: true });
395
+ for (const dir of [overlaysDir, audioDir]) {
396
+ const real = await realpath(dir);
397
+ if (!real.startsWith(`${realJob}${sep}`)) {
398
+ throw new VideoGenError(`${safeBasename(dir)}/ resolves outside the job directory (symlink?).`, 'timeline: dir escapes');
399
+ }
400
+ }
401
+ const resolved = [];
402
+ const mediaProbe = resolveFfprobe(settings.ffmpegPath);
403
+ for (const seg of spec.segments) {
404
+ const out = { ...seg, resolvedDurationSec: 0 };
405
+ if (seg.video) {
406
+ const sourcePath = mediaSnaps[seg.id];
407
+ const [sourceDurationSec, streams] = await Promise.all([
408
+ probeVideoDuration(mediaProbe.path, sourcePath, opts.signal),
409
+ probeStreams(mediaProbe.path, sourcePath, opts.signal),
410
+ ]);
411
+ out.sourceDurationSec = sourceDurationSec;
412
+ out.sourceHasAudio = streams.audioLayout !== 'none';
413
+ }
414
+ // text overlay
415
+ if (seg.overlay && (seg.overlay.title || seg.overlay.subtitle)) {
416
+ const overlayPath = join(overlaysDir, `text-${seg.id}.png`);
417
+ if (!(await canReuse(overlayPath))) {
418
+ opts.onUpdate?.(`Rendering text overlay for ${seg.id}…`);
419
+ await writeAtomic(overlayPath, async (tmp) => {
420
+ await renderTextOverlay({ overlay: seg.overlay, width, height, outPath: tmp });
421
+ });
422
+ await record(overlayPath);
423
+ }
424
+ out.overlayPath = overlayPath;
425
+ }
426
+ if (seg.narration && spec.subtitles?.mode === 'burn') {
427
+ const narration = seg.narration;
428
+ const burnedSubtitlePath = join(overlaysDir, `subtitle-${seg.id}.png`);
429
+ if (!(await canReuse(burnedSubtitlePath))) {
430
+ opts.onUpdate?.(`Rendering burned subtitle for ${seg.id}…`);
431
+ await writeAtomic(burnedSubtitlePath, async (tmp) => {
432
+ await renderBurnedSubtitle({
433
+ text: narration,
434
+ style: spec.subtitles,
435
+ width,
436
+ height,
437
+ outPath: tmp,
438
+ });
439
+ });
440
+ await record(burnedSubtitlePath);
441
+ }
442
+ out.burnedSubtitlePath = burnedSubtitlePath;
443
+ }
444
+ // narration via TTS
445
+ if (seg.narration) {
446
+ const narrationPath = join(audioDir, `${seg.id}.mp3`);
447
+ const alreadyDegraded = manifest.segments[seg.id]?.narrationDegraded === true;
448
+ let hasNarrationArtifact = alreadyDegraded ? false : await canReuse(narrationPath);
449
+ let narrationNeedsCommit = false;
450
+ if (alreadyDegraded) {
451
+ opts.onUpdate?.(`Keeping the accepted silent-audio degradation for ${seg.id}.`);
452
+ }
453
+ else if (!hasNarrationArtifact) {
454
+ try {
455
+ opts.onUpdate?.(`Synthesizing narration for ${seg.id} (${voice})…`);
456
+ await tts.synthesize({
457
+ text: seg.narration,
458
+ voice,
459
+ outPath: narrationPath,
460
+ signal: opts.signal,
461
+ });
462
+ hasNarrationArtifact = true;
463
+ narrationNeedsCommit = true;
464
+ }
465
+ catch (error) {
466
+ if (opts.signal?.aborted || spec.ttsFailureMode !== 'silent-subtitles')
467
+ throw error;
468
+ await rm(narrationPath, { force: true });
469
+ delete manifest.artifactHashes[artifactKey(jobDir, narrationPath)];
470
+ hasNarrationArtifact = false;
471
+ manifest.segments[seg.id] = {
472
+ ...manifest.segments[seg.id],
473
+ narrationDurationSec: undefined,
474
+ narrationDegraded: true,
475
+ };
476
+ opts.onUpdate?.(`Narration unavailable for ${seg.id}; using silent audio with subtitles as requested.`);
477
+ }
478
+ }
479
+ if (!alreadyDegraded && hasNarrationArtifact) {
480
+ if (!(await hasSafeCachedArtifact(narrationPath, realJob))) {
481
+ throw new VideoGenError(`Expected cached artifact "${safeBasename(narrationPath)}" was not created.`, 'timeline: cached artifact missing');
482
+ }
483
+ // The encoded file is authoritative; provider metadata may omit
484
+ // trailing silence. Probe failures are infrastructure/media errors,
485
+ // not an accepted TTS degradation.
486
+ const durationSec = await probeDuration(mediaProbe.path, narrationPath, opts.signal);
487
+ out.narrationPath = narrationPath;
488
+ out.narrationDurationSec = durationSec;
489
+ manifest.segments[seg.id] = {
490
+ ...manifest.segments[seg.id],
491
+ narrationDurationSec: durationSec,
492
+ narrationDegraded: false,
493
+ };
494
+ if (narrationNeedsCommit)
495
+ await record(narrationPath);
496
+ }
497
+ }
498
+ // duration resolution
499
+ const outgoingOverlap = transitionOverlap(seg);
500
+ const audioFloor = out.narrationDurationSec
501
+ ? out.narrationDurationSec + AUTO_PAD_SEC + outgoingOverlap
502
+ : 0;
503
+ const narrationWindow = (out.narrationDurationSec ?? 0) + outgoingOverlap;
504
+ if (seg.video && typeof seg.durationSec === 'number' && narrationWindow > seg.durationSec) {
505
+ throw new VideoGenError(`Segment "${seg.id}" narration (${out.narrationDurationSec?.toFixed(2)}s) does not fit the fixed ${seg.durationSec.toFixed(2)}s video duration. Shorten the narration or increase durationSec.`, 'timeline: narration exceeds video duration');
506
+ }
507
+ if (seg.durationSec === 'auto') {
508
+ out.resolvedDurationSec = Math.max(1, audioFloor || 3);
509
+ }
510
+ else if (seg.video) {
511
+ out.resolvedDurationSec = seg.durationSec;
512
+ }
513
+ else {
514
+ out.resolvedDurationSec = Math.max(seg.durationSec, audioFloor);
515
+ }
516
+ if (seg.video &&
517
+ (seg.trimStartSec ?? 0) + out.resolvedDurationSec > (out.sourceDurationSec ?? 0) + 0.05) {
518
+ throw new VideoGenError(`Segment "${seg.id}" requests ${(seg.trimStartSec ?? 0).toFixed(2)}s + ${out.resolvedDurationSec.toFixed(2)}s, beyond the ${out.sourceDurationSec?.toFixed(2)}s source video.`, 'timeline: video trim exceeds source');
519
+ }
520
+ // Post-resolution revalidation: a transition longer than the resolved
521
+ // duration would swallow the segment (and the LAST segment must not
522
+ // have one at all — there is nothing after it).
523
+ const isLast = resolved.length === spec.segments.length - 1;
524
+ if (seg.transitionTo && (isLast || seg.transitionTo.durationSec >= out.resolvedDurationSec)) {
525
+ throw new VideoGenError(isLast
526
+ ? `Segment "${seg.id}": the LAST segment must not have a transitionTo — there is nothing after it.`
527
+ : `Segment "${seg.id}": transitionTo.durationSec (${seg.transitionTo.durationSec}s) must be shorter than the resolved duration (${out.resolvedDurationSec.toFixed(1)}s).`, 'timeline: transition invalid');
528
+ }
529
+ resolved.push(out);
530
+ manifest.segments[seg.id] = {
531
+ ...manifest.segments[seg.id],
532
+ resolvedDurationSec: out.resolvedDurationSec,
533
+ };
534
+ saveTimelineJob(jobDir, manifest);
535
+ }
536
+ // ── 4. per-segment mp4 render ──────────────────────────────────────────
537
+ const wantsH264 = spec.output?.codec === 'h264';
538
+ const ffmpeg = wantsH264
539
+ ? resolveGplFfmpeg(settings.ffmpegPath)
540
+ : resolveFfmpeg(settings.ffmpegPath);
541
+ if (wantsH264 && !ffmpeg.runnable) {
542
+ throw new VideoGenError('h264 output needs an ffmpeg with the libx264 encoder. Reinstall pi-video-gen, set pi-video-gen.ffmpegPath to a compatible binary, or use output.codec "mpeg4".', 'timeline: gpl ffmpeg missing');
543
+ }
544
+ if (!ffmpeg.runnable) {
545
+ throw new VideoGenError(`ffmpeg is not runnable (source tried: ${ffmpeg.source}). Install or set pi-video-gen.ffmpegPath.`, 'timeline: ffmpeg missing');
546
+ }
547
+ const segmentsDir = join(jobDir, 'segments');
548
+ await mkdir(segmentsDir, { recursive: true });
549
+ const realSegments = await realpath(segmentsDir);
550
+ if (!realSegments.startsWith(`${realJob}${sep}`)) {
551
+ throw new VideoGenError('segments/ resolves outside the job directory (symlink?).', 'timeline: dir escapes');
552
+ }
553
+ for (const seg of resolved) {
554
+ const segPath = join(segmentsDir, `${seg.id}.mp4`);
555
+ if (await canReuse(segPath))
556
+ continue;
557
+ opts.onUpdate?.(`Rendering segment ${seg.id} (${seg.resolvedDurationSec.toFixed(1)}s)…`);
558
+ const frames = Math.round(seg.resolvedDurationSec * fps);
559
+ const motion = motionFilter(seg.motion, frames, width, height, fps, seg.fit);
560
+ const inputs = seg.video
561
+ ? [
562
+ '-ss',
563
+ (seg.trimStartSec ?? 0).toFixed(2),
564
+ '-t',
565
+ seg.resolvedDurationSec.toFixed(2),
566
+ '-i',
567
+ mediaSnaps[seg.id],
568
+ ]
569
+ : [
570
+ '-loop',
571
+ '1',
572
+ '-framerate',
573
+ String(fps),
574
+ '-t',
575
+ seg.resolvedDurationSec.toFixed(2),
576
+ '-i',
577
+ mediaSnaps[seg.id],
578
+ ];
579
+ const graphParts = [
580
+ `[0:v]${motion}${seg.video ? `,fps=${fps}` : ''},format=yuv420p[base]`,
581
+ ];
582
+ let currentLayer = 'base';
583
+ for (const [index, overlayPath] of [seg.overlayPath, seg.burnedSubtitlePath]
584
+ .filter((path) => Boolean(path))
585
+ .entries()) {
586
+ const inputIndex = inputs.filter((arg) => arg === '-i').length;
587
+ inputs.push('-i', overlayPath);
588
+ const enable = overlayPath === seg.burnedSubtitlePath
589
+ ? `:enable='lt(t,${Math.min(seg.resolvedDurationSec - transitionOverlap(seg), seg.narrationDurationSec ?? Number.POSITIVE_INFINITY).toFixed(3)})'`
590
+ : '';
591
+ graphParts.push(`[${inputIndex}:v]format=rgba[ovr${index}]`, `[${currentLayer}][ovr${index}]overlay=0:0:format=auto${enable}[layer${index}]`);
592
+ currentLayer = `layer${index}`;
593
+ }
594
+ if (currentLayer === 'base') {
595
+ graphParts.push(`[base]copy[vout]`);
596
+ }
597
+ else {
598
+ graphParts.push(`[${currentLayer}]copy[vout]`);
599
+ }
600
+ await writeAtomic(segPath, async (tmp) => {
601
+ if (seg.narrationPath) {
602
+ inputs.push('-i', seg.narrationPath);
603
+ }
604
+ else {
605
+ inputs.push('-f', 'lavfi', '-i', `anullsrc=r=48000:cl=stereo`);
606
+ }
607
+ const audioInputIndex = inputs.filter((a) => a === '-i').length - 1;
608
+ await runFfmpegCommand(ffmpeg.path, [
609
+ ...inputs,
610
+ '-filter_complex',
611
+ graphParts.join(';'),
612
+ '-map',
613
+ '[vout]',
614
+ '-map',
615
+ `${audioInputIndex}:a`,
616
+ '-t',
617
+ seg.resolvedDurationSec.toFixed(2),
618
+ '-c:v',
619
+ ...(wantsH264 ? ['libx264', '-crf', '20'] : ['mpeg4', '-q:v', '4']),
620
+ '-c:a',
621
+ 'aac',
622
+ '-b:a',
623
+ '96k',
624
+ '-r',
625
+ String(fps),
626
+ '-y',
627
+ tmp,
628
+ ], opts.signal);
629
+ });
630
+ await record(segPath);
631
+ }
632
+ // ── 5. video chain: xfade for transitions, concat for cuts ─────────────
633
+ opts.onUpdate?.('Joining segments…');
634
+ const videoOnlyPath = join(jobDir, 'video_track.mp4');
635
+ if (!(await canReuse(videoOnlyPath))) {
636
+ await writeAtomic(videoOnlyPath, async (tmp) => {
637
+ const segInputs = resolved.flatMap((s) => ['-i', join(segmentsDir, `${s.id}.mp4`)]);
638
+ const chains = resolved.map((_s, i) => `[${i}:v]settb=AVTB,fps=${fps}[v${i}]`);
639
+ let current = 'v0';
640
+ let offsetBase = 0;
641
+ for (let i = 0; i < resolved.length - 1; i++) {
642
+ const seg = resolved[i];
643
+ const t = transitionOverlap(seg);
644
+ offsetBase += seg.resolvedDurationSec;
645
+ if (t > 0) {
646
+ const offset = Math.max(0.1, offsetBase - t);
647
+ chains.push(`[${current}][v${i + 1}]xfade=transition=${seg.transitionTo.style}:duration=${t.toFixed(2)}:offset=${offset.toFixed(2)}[x${i}]`);
648
+ current = `x${i}`;
649
+ offsetBase -= t;
650
+ }
651
+ else {
652
+ chains.push(`[${current}][v${i + 1}]concat=n=2:v=1:a=0[x${i}]`);
653
+ current = `x${i}`;
654
+ }
655
+ }
656
+ await runFfmpegCommand(ffmpeg.path, [
657
+ ...segInputs,
658
+ '-filter_complex',
659
+ `${chains.join(';')};[${current}]format=yuv420p[v]`,
660
+ '-map',
661
+ '[v]',
662
+ '-c:v',
663
+ ...(wantsH264 ? ['libx264', '-crf', '20'] : ['mpeg4', '-q:v', '4']),
664
+ '-r',
665
+ String(fps),
666
+ '-y',
667
+ tmp,
668
+ ], opts.signal);
669
+ });
670
+ await record(videoOnlyPath);
671
+ }
672
+ // ── 6. audio track: concat per-segment audio, optional BGM ducking ─────
673
+ const audioTrackPath = join(jobDir, 'audio_track.mp4');
674
+ for (const seg of resolved) {
675
+ const sourceAudioEnabled = isSourceAudioEnabled(seg);
676
+ if (seg.narrationPath)
677
+ await canReuse(join(audioDir, `${seg.id}_padded.mp4`));
678
+ if (sourceAudioEnabled)
679
+ await canReuse(join(audioDir, `${seg.id}_source.mp4`));
680
+ if (seg.narrationPath && sourceAudioEnabled) {
681
+ await canReuse(join(audioDir, `${seg.id}_mixed.mp4`));
682
+ }
683
+ if (!seg.narrationPath && !sourceAudioEnabled) {
684
+ await canReuse(join(audioDir, `${seg.id}_silence.mp4`));
685
+ }
686
+ }
687
+ if (!(await canReuse(audioTrackPath))) {
688
+ opts.onUpdate?.('Mixing audio track…');
689
+ const listPath = join(jobDir, '.audio-list.txt');
690
+ const lines = [];
691
+ // Audio follows the effective VIDEO timeline. Auto duration includes an
692
+ // outgoing xfade, so subtracting that overlap here still leaves the full
693
+ // narration plus padding before the next segment starts.
694
+ for (let i = 0; i < resolved.length; i++) {
695
+ const seg = resolved[i];
696
+ const overlap = transitionOverlap(seg);
697
+ const audioSpan = Math.max(0.2, seg.resolvedDurationSec - overlap);
698
+ let narrationAudioPath;
699
+ if (seg.narrationPath) {
700
+ // Narration length IS its own audio; the segment spans it. No
701
+ // trimming (long transitions must never cut narration) and no fake
702
+ // MP3→MP4 padding — narration stays whole, silence fills the rest.
703
+ const narrationSpan = seg.narrationDurationSec ?? audioSpan;
704
+ const narrationClipped = Math.min(narrationSpan, audioSpan);
705
+ const paddedPath = join(audioDir, `${seg.id}_padded.mp4`);
706
+ if (!(await canReuse(paddedPath))) {
707
+ const gapSec = Math.max(0, audioSpan - narrationClipped);
708
+ await writeAtomic(paddedPath, async (tmp) => {
709
+ await runFfmpegCommand(ffmpeg.path, [
710
+ '-i',
711
+ seg.narrationPath,
712
+ '-f',
713
+ 'lavfi',
714
+ '-i',
715
+ 'anullsrc=r=48000:cl=stereo',
716
+ '-filter_complex',
717
+ `[0:a]atrim=0:${narrationClipped.toFixed(2)}[na];[1:a]atrim=0:${gapSec.toFixed(2)}[sil];[na][sil]concat=n=2:v=0:a=1[out]`,
718
+ '-map',
719
+ '[out]',
720
+ '-t',
721
+ audioSpan.toFixed(2),
722
+ '-c:a',
723
+ 'aac',
724
+ '-b:a',
725
+ '96k',
726
+ '-y',
727
+ tmp,
728
+ ], opts.signal);
729
+ });
730
+ await record(paddedPath);
731
+ }
732
+ narrationAudioPath = paddedPath;
733
+ }
734
+ const sourceAudioEnabled = isSourceAudioEnabled(seg);
735
+ let sourceAudioPath;
736
+ if (sourceAudioEnabled) {
737
+ sourceAudioPath = join(audioDir, `${seg.id}_source.mp4`);
738
+ if (!(await canReuse(sourceAudioPath))) {
739
+ await writeAtomic(sourceAudioPath, async (tmp) => {
740
+ await runFfmpegCommand(ffmpeg.path, [
741
+ '-ss',
742
+ (seg.trimStartSec ?? 0).toFixed(2),
743
+ '-t',
744
+ audioSpan.toFixed(2),
745
+ '-i',
746
+ mediaSnaps[seg.id],
747
+ '-f',
748
+ 'lavfi',
749
+ '-i',
750
+ 'anullsrc=r=48000:cl=stereo',
751
+ '-filter_complex',
752
+ `[0:a]volume=${(seg.sourceAudio?.volume ?? 1).toFixed(3)}[src];[src][1:a]amix=inputs=2:duration=longest:normalize=0,alimiter=limit=0.95:level=false[out]`,
753
+ '-map',
754
+ '[out]',
755
+ '-t',
756
+ audioSpan.toFixed(2),
757
+ '-ar',
758
+ '48000',
759
+ '-ac',
760
+ '2',
761
+ '-c:a',
762
+ 'aac',
763
+ '-b:a',
764
+ '96k',
765
+ '-y',
766
+ tmp,
767
+ ], opts.signal);
768
+ });
769
+ await record(sourceAudioPath);
770
+ }
771
+ }
772
+ let segmentAudioPath = narrationAudioPath ?? sourceAudioPath;
773
+ if (narrationAudioPath && sourceAudioPath) {
774
+ const mixedPath = join(audioDir, `${seg.id}_mixed.mp4`);
775
+ if (!(await canReuse(mixedPath))) {
776
+ await writeAtomic(mixedPath, async (tmp) => {
777
+ await runFfmpegCommand(ffmpeg.path, [
778
+ '-i',
779
+ narrationAudioPath,
780
+ '-i',
781
+ sourceAudioPath,
782
+ '-filter_complex',
783
+ '[0:a][1:a]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.95:level=false[out]',
784
+ '-map',
785
+ '[out]',
786
+ '-c:a',
787
+ 'aac',
788
+ '-b:a',
789
+ '96k',
790
+ '-y',
791
+ tmp,
792
+ ], opts.signal);
793
+ });
794
+ await record(mixedPath);
795
+ }
796
+ segmentAudioPath = mixedPath;
797
+ }
798
+ if (!segmentAudioPath) {
799
+ // silence: reuse anullsrc through a named filter is not possible in
800
+ // concat demuxer — record the silent gap via a generated silent file
801
+ const silencePath = join(audioDir, `${seg.id}_silence.mp4`);
802
+ if (!(await canReuse(silencePath))) {
803
+ await writeAtomic(silencePath, async (tmp) => {
804
+ await runFfmpegCommand(ffmpeg.path, [
805
+ '-f',
806
+ 'lavfi',
807
+ '-i',
808
+ 'anullsrc=r=48000:cl=stereo',
809
+ '-t',
810
+ audioSpan.toFixed(2),
811
+ '-c:a',
812
+ 'aac',
813
+ '-b:a',
814
+ '96k',
815
+ '-y',
816
+ tmp,
817
+ ], opts.signal);
818
+ });
819
+ await record(silencePath);
820
+ }
821
+ segmentAudioPath = silencePath;
822
+ }
823
+ lines.push(`file '${segmentAudioPath.replace(/'/g, `'\\''`)}'`);
824
+ }
825
+ await writeAtomic(listPath, async (tmp) => {
826
+ await writeFile(tmp, lines.join('\n'), 'utf-8');
827
+ });
828
+ if (bgmSource) {
829
+ await writeAtomic(audioTrackPath, async (tmp) => {
830
+ await runFfmpegCommand(ffmpeg.path, [
831
+ '-f',
832
+ 'concat',
833
+ '-safe',
834
+ '0',
835
+ '-i',
836
+ listPath,
837
+ '-i',
838
+ bgmSnapshotPath,
839
+ '-filter_complex',
840
+ '[0:a]volume=1.0[a1];[1:a]volume=0.18[a2];[a1][a2]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.95:level=false[aout]',
841
+ '-map',
842
+ '[aout]',
843
+ '-c:a',
844
+ 'aac',
845
+ '-b:a',
846
+ '128k',
847
+ '-shortest',
848
+ '-y',
849
+ tmp,
850
+ ], opts.signal);
851
+ });
852
+ }
853
+ else {
854
+ await writeAtomic(audioTrackPath, async (tmp) => {
855
+ await runFfmpegCommand(ffmpeg.path, [
856
+ '-f',
857
+ 'concat',
858
+ '-safe',
859
+ '0',
860
+ '-i',
861
+ listPath,
862
+ '-c:a',
863
+ 'aac',
864
+ '-b:a',
865
+ '128k',
866
+ '-y',
867
+ tmp,
868
+ ], opts.signal);
869
+ });
870
+ }
871
+ await record(audioTrackPath);
872
+ }
873
+ // ── 7. mux video+audio, mov_text subtitles ─────────────────────────────
874
+ const finalVideoPath = join(jobDir, 'final_video.mp4');
875
+ if (manifest.state !== 'done') {
876
+ let invalidated = false;
877
+ for (const key of Object.keys(manifest.artifactHashes)) {
878
+ if (key.startsWith('qc/')) {
879
+ delete manifest.artifactHashes[key];
880
+ invalidated = true;
881
+ }
882
+ }
883
+ if (manifest.finalVideoPath !== undefined || manifest.finalVideoHash !== undefined) {
884
+ delete manifest.finalVideoPath;
885
+ delete manifest.finalVideoHash;
886
+ invalidated = true;
887
+ }
888
+ if (invalidated)
889
+ saveTimelineJob(jobDir, manifest);
890
+ }
891
+ const finalStat = await lstat(finalVideoPath).catch(() => null);
892
+ if (manifest.state === 'done' && !finalStat) {
893
+ throw new VideoGenError('The completed final video is missing. Restore final_video.mp4 or start a NEW job directory.', 'timeline: final artifact missing');
894
+ }
895
+ if (finalStat && (!finalStat.isFile() || finalStat.isSymbolicLink())) {
896
+ throw new VideoGenError('The cached final video is invalid (not a regular file or is a symlink).', 'timeline: final artifact invalid');
897
+ }
898
+ let hasFinalVideo = Boolean(finalStat);
899
+ if (hasFinalVideo && manifest.state === 'done') {
900
+ const actualHash = await hashFileSha256(finalVideoPath, opts.signal);
901
+ if (actualHash !== manifest.finalVideoHash) {
902
+ throw new VideoGenError('The cached final video changed since this job completed.', 'timeline: final artifact changed');
903
+ }
904
+ }
905
+ else if (hasFinalVideo) {
906
+ // The process may have stopped after rename but before the done manifest.
907
+ // Without a persisted hash the artifact has no recoverable identity.
908
+ await rm(finalVideoPath);
909
+ hasFinalVideo = false;
910
+ }
911
+ const narrated = resolved.filter((segment) => Boolean(segment.narration));
912
+ const hasNarration = narrated.length > 0;
913
+ const hasSoftSubtitles = hasNarration && spec.subtitles?.mode !== 'burn';
914
+ const subtitlePath = hasNarration ? join(jobDir, 'subtitles.srt') : undefined;
915
+ let expectedSrt = '';
916
+ if (subtitlePath) {
917
+ const cues = [];
918
+ // Cue times follow the VIDEO timeline: each xfade overlap shifts later
919
+ // segments earlier by the transition duration.
920
+ let videoCursor = 0;
921
+ let idx = 1;
922
+ for (const seg of resolved) {
923
+ if (seg.narration) {
924
+ const start = videoCursor;
925
+ const visibleSpan = seg.resolvedDurationSec - transitionOverlap(seg);
926
+ const end = Math.min(videoCursor + visibleSpan, start + (seg.narrationDurationSec ?? visibleSpan));
927
+ cues.push(`${idx++}
928
+ ${srtTimestamp(start)} --> ${srtTimestamp(end)}
929
+ ${splitCueText(seg.narration)}
930
+ `);
931
+ }
932
+ videoCursor += seg.resolvedDurationSec - transitionOverlap(seg);
933
+ }
934
+ expectedSrt = cues.join('\n');
935
+ if (!(await canReuse(subtitlePath))) {
936
+ await writeAtomic(subtitlePath, async (tmp) => {
937
+ await writeFile(tmp, expectedSrt, 'utf-8');
938
+ });
939
+ await record(subtitlePath);
940
+ }
941
+ }
942
+ if (!hasFinalVideo) {
943
+ const subArgs = hasSoftSubtitles
944
+ ? ['-i', subtitlePath, '-c:s', 'mov_text', '-metadata:s:s:0', 'language=chi']
945
+ : [];
946
+ opts.onUpdate?.('Muxing final video…');
947
+ await writeAtomic(finalVideoPath, async (tmp) => {
948
+ await runFfmpegCommand(ffmpeg.path, [
949
+ '-i',
950
+ videoOnlyPath,
951
+ '-i',
952
+ audioTrackPath,
953
+ ...subArgs,
954
+ '-map',
955
+ '0:v',
956
+ '-map',
957
+ '1:a',
958
+ ...(hasSoftSubtitles ? ['-map', '2:s'] : []),
959
+ '-c:v',
960
+ 'copy',
961
+ '-c:a',
962
+ 'copy',
963
+ '-movflags',
964
+ '+faststart',
965
+ // NO -shortest: with a subtitle track present it truncates the
966
+ // container to the shortest STREAM (the last cue), not the video.
967
+ '-y',
968
+ tmp,
969
+ ], opts.signal);
970
+ });
971
+ }
972
+ const finalVideoHash = await hashFileSha256(finalVideoPath, opts.signal);
973
+ // ── 8. QC: duration probe + frame extraction ───────────────────────────
974
+ const ffprobe = resolveFfprobe(settings.ffmpegPath);
975
+ const qcDir = join(jobDir, 'qc');
976
+ await mkdir(qcDir, { recursive: true });
977
+ const realQc = await realpath(qcDir);
978
+ if (!realQc.startsWith(`${realJob}${sep}`)) {
979
+ throw new VideoGenError('qc/ resolves outside the job directory (symlink?).', 'timeline: dir escapes');
980
+ }
981
+ // Expected = Σ segment durations − Σ xfade overlaps (each transition
982
+ // shortens the video by its duration). Compare against the final probe.
983
+ const totalOverlap = resolved.reduce((n, segment) => n + transitionOverlap(segment), 0);
984
+ const expectedSec = resolved.reduce((n, s) => n + s.resolvedDurationSec, 0) - totalOverlap;
985
+ const actualSec = await probeDuration(ffprobe.path, finalVideoPath, opts.signal);
986
+ if (Math.abs(actualSec - expectedSec) > 1) {
987
+ throw new VideoGenError(`QC: final duration ${actualSec.toFixed(1)}s differs from planned ${expectedSec.toFixed(1)}s by more than 1s.`, 'timeline: duration qc');
988
+ }
989
+ const streams = await probeStreams(ffprobe.path, finalVideoPath, opts.signal);
990
+ if (streams.audioLayout === 'none') {
991
+ throw new VideoGenError('QC: the final video is missing its audio stream.', 'timeline: audio qc');
992
+ }
993
+ if (hasSoftSubtitles && streams.subtitleCodec !== 'mov_text') {
994
+ throw new VideoGenError('QC: the final video is missing its mov_text subtitle track.', 'timeline: subtitle qc');
995
+ }
996
+ if (hasNarration && !hasSoftSubtitles && streams.subtitleCodec !== 'none') {
997
+ throw new VideoGenError('QC: burned-subtitle output unexpectedly contains a soft subtitle track.', 'timeline: burned subtitle qc');
998
+ }
999
+ if (subtitlePath && (await readFile(subtitlePath, 'utf-8')) !== expectedSrt) {
1000
+ throw new VideoGenError('QC: the SRT timeline does not match the resolved narration timeline.', 'timeline: srt timeline qc');
1001
+ }
1002
+ const qcFrames = [];
1003
+ const qcNames = ['qc_first.png', 'qc_mid.png', 'qc_last.png'];
1004
+ const qcReady = (await Promise.all(qcNames.map((name) => canReuse(join(qcDir, name))))).every(Boolean);
1005
+ if (!qcReady) {
1006
+ opts.onUpdate?.('Extracting QC frames…');
1007
+ const edgeOffset = Math.min(0.5, actualSec / 4);
1008
+ const shots = [
1009
+ ['qc_first.png', edgeOffset],
1010
+ ['qc_mid.png', actualSec / 2],
1011
+ ['qc_last.png', actualSec - edgeOffset],
1012
+ ];
1013
+ for (const [name, t] of shots) {
1014
+ const out = join(qcDir, name);
1015
+ await writeAtomic(out, async (tmp) => {
1016
+ await runFfmpegCommand(ffmpeg.path, ['-ss', t.toFixed(2), '-i', finalVideoPath, '-frames:v', '1', '-y', tmp], opts.signal);
1017
+ });
1018
+ await record(out);
1019
+ }
1020
+ }
1021
+ for (const name of qcNames)
1022
+ qcFrames.push(join(qcDir, name));
1023
+ manifest.state = 'done';
1024
+ manifest.finalVideoPath = finalVideoPath;
1025
+ manifest.finalVideoHash = finalVideoHash;
1026
+ saveTimelineJob(jobDir, manifest);
1027
+ opts.onUpdate?.(`Done: ${finalVideoPath}`);
1028
+ return {
1029
+ jobId,
1030
+ finalVideoPath,
1031
+ segments: resolved.length,
1032
+ durationSec: expectedSec,
1033
+ subtitlePath,
1034
+ qcFrames,
1035
+ };
1036
+ }
1037
+ catch (error) {
1038
+ if (manifestForFailure && manifestForFailure.state !== 'done') {
1039
+ manifestForFailure.state = opts.signal?.aborted ? 'cancelled' : 'failed';
1040
+ manifestForFailure.error = opts.signal?.aborted
1041
+ ? 'Timeline render cancelled.'
1042
+ : 'Timeline render failed. Retry the same job to resume.';
1043
+ saveTimelineJob(jobDir, manifestForFailure);
1044
+ }
1045
+ throw error;
1046
+ }
1047
+ finally {
1048
+ release();
1049
+ }
1050
+ }
1051
+ //# sourceMappingURL=timeline-render.js.map