@amaster.ai/pi-video-gen 0.1.18 → 0.1.19

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/dist/render.js CHANGED
@@ -1,20 +1,27 @@
1
- import { createHash } from 'node:crypto';
1
+ import { COPYFILE_EXCL } from 'node:constants';
2
+ import { createHash, randomUUID } from 'node:crypto';
2
3
  import { existsSync } from 'node:fs';
3
- import { lstat, mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises';
4
+ import { copyFile, lstat, mkdir, readFile, realpath, rename, stat, writeFile, } from 'node:fs/promises';
4
5
  import { basename, dirname, extname, join, resolve, sep } from 'node:path';
5
6
  import { resolveOutputDir } from './config.js';
6
7
  import { AmbiguousSubmitError, errorMessageForUser, RemoteTaskFailedError, RemoteTaskNotFoundError, safeBasename, toLogSummary, VideoGenError, } from './errors.js';
7
- import { concatVideos } from './ffmpeg.js';
8
+ import { concatVideos, probeDuration, probeStreams, resolveFfprobe, resolveGplFfmpeg, runFfmpegCommand, } from './ffmpeg.js';
8
9
  import { readApprovedFrame } from './frame-input.js';
9
- import { assertSafeId, loadRenderJob, readJsonFile, saveRenderJob, writeJsonAtomic, } from './jobs/store.js';
10
+ import { assertSafeId, hashFileSha256, loadRenderJob, loadTimelineJob, readJsonFile, saveRenderJob, writeJsonAtomic, } from './jobs/store.js';
10
11
  import { assemblePrompt, validateFilmPrompt, validateShotPrompt } from './prompt.js';
11
12
  import { normalizeReferenceAssets, referenceAssetPreflightError, requestFingerprint, } from './providers/request.js';
12
13
  import { CancelledError, pollTask } from './providers/task.js';
14
+ import { parseRemotionSpec, preflightRemotion, runRemotion, } from './remotion.js';
15
+ import { hasCjkFont } from './text-layer.js';
16
+ import { parseTimelineSpec } from './timeline.js';
17
+ import { assertNarrationOptIn, runTimeline } from './timeline-render.js';
13
18
  let snapshotCounter = 0;
14
19
  function sha256hex(content) {
15
20
  return createHash('sha256').update(content).digest('hex');
16
21
  }
17
22
  function specFingerprint(specRaw, resolved) {
23
+ if (!resolved)
24
+ return sha256hex(JSON.stringify({ spec: specRaw, mode: 'local' }));
18
25
  return sha256hex(JSON.stringify({
19
26
  spec: specRaw,
20
27
  model: resolved.remoteId,
@@ -46,8 +53,25 @@ function sameKeys(actual, expected) {
46
53
  const wanted = new Set(expected);
47
54
  return actual.every((key) => wanted.has(key));
48
55
  }
56
+ function assemblySources(spec, cwd) {
57
+ if (spec.assembly?.type === 'remotion')
58
+ return Object.fromEntries(Object.entries(spec.assembly.assets ?? {}).map(([id, path]) => [id, resolve(cwd, path)]));
59
+ if (spec.assembly?.type === 'timeline' && spec.assembly.timeline.bgm)
60
+ return { bgm: resolve(cwd, spec.assembly.timeline.bgm) };
61
+ return {};
62
+ }
63
+ function timelineWithShotPaths(timeline, shotsDir) {
64
+ return {
65
+ ...timeline,
66
+ segments: timeline.segments.map((segment) => ({
67
+ ...segment,
68
+ video: join(shotsDir, segment.shotId, 'video.mp4'),
69
+ shotId: undefined,
70
+ })),
71
+ };
72
+ }
49
73
  /** Manual validation with agent-fixable error messages (better than a schema dump). */
50
- function parseRenderSpec(raw) {
74
+ export function parseRenderSpec(raw) {
51
75
  let parsed;
52
76
  try {
53
77
  parsed = JSON.parse(raw);
@@ -83,6 +107,19 @@ function parseRenderSpec(raw) {
83
107
  throw new VideoGenError(`Duplicate shot id "${shot.id}" — ids must be unique.`, 'render: dup shot id');
84
108
  }
85
109
  seen.add(shot.id);
110
+ if (shot.videoPath !== undefined) {
111
+ if (typeof shot.videoPath !== 'string' || shot.videoPath.trim() === '') {
112
+ throw new VideoGenError(`${where}.videoPath must be a non-empty path.`, 'render: bad video path');
113
+ }
114
+ if (shot.prompt !== undefined ||
115
+ shot.firstFramePath !== undefined ||
116
+ shot.lastFramePath !== undefined ||
117
+ shot.referenceAssets !== undefined ||
118
+ shot.durationSec !== undefined) {
119
+ throw new VideoGenError(`${where}.videoPath cannot be combined with generation fields.`, 'render: mixed shot source');
120
+ }
121
+ return;
122
+ }
86
123
  if (shot.firstFramePath !== undefined &&
87
124
  (typeof shot.firstFramePath !== 'string' || shot.firstFramePath.trim() === '')) {
88
125
  throw new VideoGenError(`${where}.firstFramePath must be a path string when present.`, 'render: bad first frame type');
@@ -108,6 +145,31 @@ function parseRenderSpec(raw) {
108
145
  throw new VideoGenError(`${where}.durationSec must be an integer number of seconds.`, 'render: bad duration type');
109
146
  }
110
147
  });
148
+ if (spec.assembly !== undefined) {
149
+ if (!spec.assembly ||
150
+ typeof spec.assembly !== 'object' ||
151
+ !['remotion', 'timeline'].includes(spec.assembly.type))
152
+ throw new VideoGenError('assembly.type must be remotion or timeline.', 'render: assembly');
153
+ if (spec.assembly.type === 'remotion') {
154
+ parseRemotionSpec(JSON.stringify(spec.assembly));
155
+ for (const id of Object.keys(spec.assembly.assets ?? {}))
156
+ if (seen.has(id))
157
+ throw new VideoGenError(`Remotion asset "${id}" conflicts with shot ID.`, 'render: asset collision');
158
+ }
159
+ if (spec.assembly.type === 'timeline') {
160
+ const timeline = spec.assembly.timeline;
161
+ if (!timeline || !Array.isArray(timeline.segments))
162
+ throw new VideoGenError('assembly.timeline must contain segments.', 'render: timeline');
163
+ const shotIds = new Set(spec.shots.map((shot) => shot.id));
164
+ for (const segment of timeline.segments) {
165
+ if (typeof segment.shotId !== 'string' ||
166
+ !shotIds.has(segment.shotId) ||
167
+ segment.video !== undefined ||
168
+ segment.image !== undefined)
169
+ throw new VideoGenError(`Timeline segment ${segment.id ?? '?'} needs a valid shotId and no video/image path.`, 'render: timeline shot ref');
170
+ }
171
+ }
172
+ }
111
173
  return spec;
112
174
  }
113
175
  async function runPool(items, size, worker) {
@@ -130,7 +192,6 @@ async function runPool(items, size, worker) {
130
192
  }
131
193
  export async function runRender(opts) {
132
194
  const { settings, cwd, resolved, adapter } = opts;
133
- const caps = resolved.entry.capabilities;
134
195
  const outputDir = resolveOutputDir(settings, cwd);
135
196
  // 1. spec + job identity
136
197
  const absSpecPath = resolve(cwd, opts.renderSpecPath);
@@ -138,6 +199,11 @@ export async function runRender(opts) {
138
199
  throw new VideoGenError('Render spec not readable. Expected <jobDir>/render-input.json under the video-gen output directory (.video-gen).', 'render: spec unreadable');
139
200
  });
140
201
  const spec = parseRenderSpec(specRaw);
202
+ const generated = spec.shots.filter((shot) => !shot.videoPath);
203
+ if (generated.length && (!resolved || !adapter)) {
204
+ throw new VideoGenError('Generated shots require a configured video model and provider.', 'render: model missing');
205
+ }
206
+ const caps = resolved?.entry.capabilities;
141
207
  // Containment must survive symlinks: compare REAL paths, not lexical
142
208
  // prefixes — a symlinked jobDir would otherwise let writes escape outputDir.
143
209
  const realOutput = await realpath(outputDir).catch(() => resolve(outputDir));
@@ -156,21 +222,21 @@ export async function runRender(opts) {
156
222
  }
157
223
  // 2. capability preflight — fail BEFORE anything paid
158
224
  const degraded = [];
159
- const wantsLastFrame = spec.shots.filter((s) => s.lastFramePath).map((s) => s.id);
160
- if (wantsLastFrame.length > 0 && !caps.supportsFirstLastFrame) {
225
+ const wantsLastFrame = generated.filter((s) => s.lastFramePath).map((s) => s.id);
226
+ if (wantsLastFrame.length > 0 && !caps?.supportsFirstLastFrame) {
161
227
  if (opts.allowDegradations?.includes('first-frame-only')) {
162
228
  for (const shot of spec.shots)
163
229
  shot.lastFramePath = undefined;
164
230
  degraded.push(`first-frame-only (dropped last frames for: ${wantsLastFrame.join(', ')})`);
165
231
  }
166
232
  else {
167
- throw new VideoGenError(`Shots ${wantsLastFrame.join(', ')} request last-frame interpolation, but ${resolved.entry.id} does not support it. Options: switch model (/video-gen models), remove lastFramePath from those shots, or explicitly pass allowDegradations: ["first-frame-only"].`, 'render: flf unsupported');
233
+ throw new VideoGenError(`Shots ${wantsLastFrame.join(', ')} request last-frame interpolation, but ${resolved?.entry.id} does not support it. Options: switch model (/video-gen models), remove lastFramePath from those shots, or explicitly pass allowDegradations: ["first-frame-only"].`, 'render: flf unsupported');
168
234
  }
169
235
  }
170
- if (spec.aspectRatio && !caps.aspectRatios.includes(spec.aspectRatio)) {
171
- throw new VideoGenError(`aspectRatio must be one of ${caps.aspectRatios.join(', ')} for ${resolved.entry.id} (got ${spec.aspectRatio}).`, 'render: bad ratio');
236
+ if (spec.aspectRatio && caps && !caps.aspectRatios.includes(spec.aspectRatio)) {
237
+ throw new VideoGenError(`aspectRatio must be one of ${caps.aspectRatios.join(', ')} for ${resolved?.entry.id} (got ${spec.aspectRatio}).`, 'render: bad ratio');
172
238
  }
173
- for (const shot of spec.shots) {
239
+ for (const shot of generated) {
174
240
  const referenceError = referenceAssetPreflightError({
175
241
  providerStyle: resolved.provider.style,
176
242
  modelId: resolved.entry.id,
@@ -186,6 +252,20 @@ export async function runRender(opts) {
186
252
  throw new VideoGenError(`Shot "${shot.id}" durationSec ${d}s is outside ${caps.durations[0]}-${caps.durations[1]}s for ${resolved.entry.id}.`, 'render: bad duration');
187
253
  }
188
254
  }
255
+ for (const shot of spec.shots) {
256
+ if (!shot.videoPath)
257
+ continue;
258
+ const source = await stat(resolve(cwd, shot.videoPath)).catch(() => null);
259
+ if (!source?.isFile() || source.size === 0)
260
+ throw new VideoGenError(`Local shot "${shot.id}" is missing or empty.`, 'render: local source invalid');
261
+ if (opts.verifyMedia !== false) {
262
+ const ffprobe = resolveFfprobe(settings.ffmpegPath);
263
+ if (!ffprobe.runnable)
264
+ throw new VideoGenError('ffprobe is required to validate existing video before model submission.', 'render: ffprobe missing');
265
+ await probeStreams(ffprobe.path, resolve(cwd, shot.videoPath), opts.signal);
266
+ await probeDuration(ffprobe.path, resolve(cwd, shot.videoPath), opts.signal);
267
+ }
268
+ }
189
269
  // Key the concurrency lock on the REAL path: two lexical aliases of the
190
270
  // same job directory must collide here, not run concurrently.
191
271
  const release = opts.activeJobs.acquire(realJobDir);
@@ -198,7 +278,13 @@ export async function runRender(opts) {
198
278
  if (!realShotsDir.startsWith(`${realJobDir}${sep}`)) {
199
279
  throw new VideoGenError('The shots directory resolves outside the job directory (symlink?). Remove it and retry.', 'render: shots dir escapes');
200
280
  }
201
- const fingerprint = specFingerprint(specRaw, resolved);
281
+ if (spec.assembly?.type === 'timeline') {
282
+ const assemblyDir = join(jobDir, 'assembly');
283
+ await mkdir(assemblyDir, { recursive: true });
284
+ if (!(await realpath(assemblyDir)).startsWith(`${realJobDir}${sep}`))
285
+ throw new VideoGenError('Timeline assembly directory escapes the render job.', 'render: assembly escape');
286
+ }
287
+ const fingerprint = specFingerprint(specRaw, generated.length ? resolved : undefined);
202
288
  let manifest = loadRenderJob(jobDir);
203
289
  if (manifest) {
204
290
  // 3a. resume: verify the whole input fingerprint before trusting anything
@@ -230,6 +316,142 @@ export async function runRender(opts) {
230
316
  throw new VideoGenError(`Frame snapshot ${relSnap} changed on disk — the frozen input is no longer trustworthy. Start a new job.`, 'render: frame drift');
231
317
  }
232
318
  }
319
+ if (manifest.state === 'done') {
320
+ if (!manifest.finalVideoHash || !manifest.finalVideoPath) {
321
+ throw new VideoGenError('Old completed render has no trustworthy final video hash. Compose its saved clips in a new job directory.', 'render: legacy final unverifiable');
322
+ }
323
+ const expectedFinal = join(jobDir, ...(spec.assembly ? ['assembly', 'final_video.mp4'] : ['final_video.mp4']));
324
+ if (manifest.finalVideoPath !== expectedFinal)
325
+ throw new VideoGenError('Completed render points to an unexpected final video path.', 'render: final path drift');
326
+ const finalStat = await lstat(expectedFinal).catch(() => null);
327
+ if (!finalStat?.isFile() ||
328
+ finalStat.isSymbolicLink() ||
329
+ !(await realpath(expectedFinal)).startsWith(`${realJobDir}${sep}`))
330
+ throw new VideoGenError('Completed final video is not a regular file inside the job.', 'render: final file escape');
331
+ const actual = await hashFileSha256(manifest.finalVideoPath).catch(() => null);
332
+ if (!actual || actual !== manifest.finalVideoHash) {
333
+ throw new VideoGenError('Final video is missing or its hash changed. Refusing to reuse or silently rebuild it.', 'render: final video drift');
334
+ }
335
+ for (const [rel, expected] of Object.entries(manifest.qcHashes ?? {})) {
336
+ const qcPath = join(jobDir, rel);
337
+ const qcStat = await lstat(qcPath).catch(() => null);
338
+ if (!qcStat?.isFile() ||
339
+ qcStat.isSymbolicLink() ||
340
+ !(await realpath(qcPath)).startsWith(`${realJobDir}${sep}`))
341
+ throw new VideoGenError(`QC artifact ${rel} is not a regular file inside the job.`, 'render: qc escape');
342
+ const qc = await hashFileSha256(qcPath).catch(() => null);
343
+ if (!qc || qc !== expected)
344
+ throw new VideoGenError(`QC artifact ${rel} changed or is missing.`, 'render: qc drift');
345
+ }
346
+ }
347
+ for (const shot of spec.shots) {
348
+ const state = manifest.shots[shot.id];
349
+ const storedVideo = join(realShotsDir, shot.id, 'video.mp4');
350
+ if (state?.state === 'done') {
351
+ const videoStat = await lstat(storedVideo).catch(() => null);
352
+ if ((!videoStat && (shot.videoPath || manifest.state === 'done')) ||
353
+ (videoStat &&
354
+ (!videoStat.isFile() ||
355
+ videoStat.isSymbolicLink() ||
356
+ !(await realpath(storedVideo)).startsWith(`${realJobDir}${sep}`))))
357
+ throw new VideoGenError(`Shot "${shot.id}" video snapshot is missing or escapes the job.`, 'render: shot video escape');
358
+ }
359
+ if (shot.videoPath) {
360
+ if (state?.source !== 'local')
361
+ throw new VideoGenError(`Shot "${shot.id}" has no verified local source in the manifest.`, 'render: local manifest drift');
362
+ const source = await hashFileSha256(resolve(cwd, shot.videoPath)).catch(() => null);
363
+ const snap = await hashFileSha256(join(realShotsDir, shot.id, 'video.mp4')).catch(() => null);
364
+ if (!source || !snap || source !== state.localSourceHash || snap !== state.videoHash) {
365
+ throw new VideoGenError(`Local shot "${shot.id}" changed or its snapshot is missing. Start a new job.`, 'render: local shot drift');
366
+ }
367
+ }
368
+ else if (state?.source === 'local') {
369
+ throw new VideoGenError(`Generated shot "${shot.id}" cannot use a local-only manifest entry.`, 'render: generated source drift');
370
+ }
371
+ else if (state?.state === 'done' && state.videoHash) {
372
+ const snap = await hashFileSha256(join(realShotsDir, shot.id, 'video.mp4')).catch(() => null);
373
+ if (snap && snap !== state.videoHash)
374
+ throw new VideoGenError(`Generated shot "${shot.id}" changed on disk.`, 'render: generated shot drift');
375
+ }
376
+ }
377
+ const frozenAssemblyAssets = assemblySources(spec, cwd);
378
+ if (spec.assembly &&
379
+ (!manifest.assemblyAssetHashes ||
380
+ !sameKeys(Object.keys(manifest.assemblyAssetHashes), Object.keys(frozenAssemblyAssets))))
381
+ throw new VideoGenError('Assembly asset identities are missing from the render manifest.', 'render: assembly assets missing');
382
+ for (const [id, path] of Object.entries(frozenAssemblyAssets)) {
383
+ if ((await hashFileSha256(path).catch(() => null)) !== manifest.assemblyAssetHashes?.[id])
384
+ throw new VideoGenError(`Assembly asset "${id}" changed since the paid input was frozen.`, 'render: assembly asset drift');
385
+ }
386
+ if (spec.assembly?.type === 'remotion') {
387
+ if (!manifest.assemblyProjectHash || !opts.trusted)
388
+ throw new VideoGenError('Remotion assembly has no frozen trusted project identity.', 'render: assembly identity');
389
+ const childSpec = readJsonFile(join(jobDir, 'assembly', 'remotion-input.json'));
390
+ if (!childSpec || JSON.stringify(childSpec) !== JSON.stringify(spec.assembly))
391
+ throw new VideoGenError('Remotion assembly input changed since the paid script was frozen.', 'render: child spec drift');
392
+ const current = await runRemotion({
393
+ specPath: join(jobDir, 'assembly', 'remotion-input.json'),
394
+ cwd,
395
+ settings,
396
+ activeJobs: opts.activeJobs,
397
+ trusted: true,
398
+ preflight: true,
399
+ ffmpegPath: opts.ffmpegPath,
400
+ });
401
+ if (current.projectHash !== manifest.assemblyProjectHash)
402
+ throw new VideoGenError('Remotion project changed since paid generation; use a new job.', 'render: assembly drift');
403
+ }
404
+ if (manifest.state === 'done') {
405
+ if (spec.assembly?.type === 'remotion') {
406
+ const assets = {
407
+ ...spec.assembly.assets,
408
+ ...Object.fromEntries(spec.shots.map((shot) => [shot.id, join(realShotsDir, shot.id, 'video.mp4')])),
409
+ };
410
+ await runRemotion({
411
+ specPath: join(jobDir, 'assembly', 'remotion-input.json'),
412
+ cwd,
413
+ settings,
414
+ activeJobs: opts.activeJobs,
415
+ trusted: Boolean(opts.trusted),
416
+ frozenProjectHash: manifest.assemblyProjectHash,
417
+ assets,
418
+ ffmpegPath: opts.ffmpegPath,
419
+ verifyOnly: true,
420
+ });
421
+ }
422
+ if (spec.assembly) {
423
+ if (spec.assembly.type === 'timeline')
424
+ await runTimeline({
425
+ timelineSpecPath: join(jobDir, 'assembly', 'timeline-input.json'),
426
+ cwd: await realpath(cwd),
427
+ settings,
428
+ activeJobs: opts.activeJobs,
429
+ signal: opts.signal,
430
+ });
431
+ const childPath = spec.assembly.type === 'remotion'
432
+ ? join(jobDir, 'assembly', 'remotion-manifest.json')
433
+ : join(jobDir, 'assembly', 'manifest.json');
434
+ const child = readJsonFile(childPath);
435
+ if (!child ||
436
+ child.finalVideoPath !== manifest.finalVideoPath ||
437
+ child.finalVideoHash !== manifest.finalVideoHash ||
438
+ (spec.assembly.type === 'timeline' && child.state !== 'done'))
439
+ throw new VideoGenError('Assembly child manifest does not match the completed render. Refusing to reuse.', 'render: child manifest drift');
440
+ }
441
+ return {
442
+ jobId,
443
+ finalVideoPath: manifest.finalVideoPath,
444
+ shotsDone: spec.shots.length,
445
+ degraded,
446
+ qcFrames: Object.keys(manifest.qcHashes ?? {})
447
+ .filter((name) => name.endsWith('.png'))
448
+ .map((name) => join(jobDir, name)),
449
+ qcReportPath: manifest.qcHashes?.[join('qc', 'report.json')]
450
+ ? join(jobDir, 'qc', 'report.json')
451
+ : undefined,
452
+ editableProjectDir: spec.assembly?.type === 'remotion' ? spec.assembly.projectDir : undefined,
453
+ };
454
+ }
233
455
  opts.onUpdate?.(`Resuming job ${jobId} (fingerprint verified).`);
234
456
  }
235
457
  else {
@@ -315,15 +537,98 @@ export async function runRender(opts) {
315
537
  assets: { ...existingAssets, ...assets },
316
538
  updatedAt: new Date().toISOString(),
317
539
  });
540
+ const assemblyAssetHashes = {};
541
+ for (const [id, path] of Object.entries(assemblySources(spec, cwd))) {
542
+ if (!(await stat(path).catch(() => null))?.isFile())
543
+ throw new VideoGenError(`Assembly asset "${id}" is missing.`, 'render: assembly asset missing');
544
+ if (opts.verifyMedia !== false && /\.(mp4|mov|webm|m4v)$/i.test(path)) {
545
+ const ffprobe = resolveFfprobe(settings.ffmpegPath);
546
+ if (!ffprobe.runnable)
547
+ throw new VideoGenError('ffprobe is required to validate existing assembly video.', 'render: ffprobe missing');
548
+ await probeStreams(ffprobe.path, path, opts.signal);
549
+ await probeDuration(ffprobe.path, path, opts.signal);
550
+ }
551
+ assemblyAssetHashes[id] = await hashFileSha256(path);
552
+ }
553
+ let assemblyProjectHash;
554
+ if (spec.assembly?.type === 'remotion') {
555
+ if (!opts.trusted)
556
+ throw new VideoGenError('Remotion assembly requires a trusted project.', 'render: untrusted assembly');
557
+ const assets = {
558
+ ...spec.assembly.assets,
559
+ ...Object.fromEntries(spec.shots.map((shot) => [
560
+ shot.id,
561
+ shot.videoPath
562
+ ? resolve(cwd, shot.videoPath)
563
+ : join(realShotsDir, shot.id, 'video.mp4'),
564
+ ])),
565
+ };
566
+ assemblyProjectHash = await preflightRemotion({
567
+ spec: spec.assembly,
568
+ cwd,
569
+ jobDir,
570
+ outputDir,
571
+ assets,
572
+ shotIds: spec.shots.map((shot) => shot.id),
573
+ ffmpegPath: opts.ffmpegPath,
574
+ signal: opts.signal,
575
+ });
576
+ writeJsonAtomic(join(jobDir, 'assembly', 'remotion-input.json'), spec.assembly);
577
+ }
578
+ if (spec.assembly?.type === 'timeline') {
579
+ const timelineInput = spec.assembly.timeline;
580
+ const mapped = timelineWithShotPaths(timelineInput, realShotsDir);
581
+ assertNarrationOptIn(parseTimelineSpec(JSON.stringify(mapped)));
582
+ if (opts.verifyMedia !== false) {
583
+ if (!resolveFfprobe(settings.ffmpegPath).runnable)
584
+ throw new VideoGenError('Timeline assembly needs ffprobe before model submission.', 'render: timeline ffprobe missing');
585
+ if (timelineInput.output?.codec === 'h264' &&
586
+ !resolveGplFfmpeg(settings.ffmpegPath).runnable)
587
+ throw new VideoGenError('H.264 timeline assembly needs an ffmpeg build with libx264.', 'render: timeline encoder missing');
588
+ const visibleText = timelineInput.segments
589
+ .map((segment) => `${segment.overlay?.title ?? ''}${segment.overlay?.subtitle ?? ''}${timelineInput.subtitles?.mode === 'burn' ? (segment.narration ?? '') : ''}`)
590
+ .join('');
591
+ if (/\p{Script=Han}/u.test(visibleText) && !hasCjkFont())
592
+ throw new VideoGenError('Timeline assembly needs a CJK font for its text overlays.', 'render: timeline CJK font missing');
593
+ }
594
+ }
318
595
  manifest = {
319
596
  jobId,
320
597
  kind: 'render',
321
598
  state: 'rendering',
322
599
  specFingerprint: fingerprint,
323
600
  frameHashes,
601
+ assemblyProjectHash,
602
+ assemblyAssetHashes: spec.assembly ? assemblyAssetHashes : undefined,
324
603
  shots: Object.fromEntries(spec.shots.map((s) => [s.id, { state: 'pending' }])),
325
604
  updatedAt: new Date().toISOString(),
326
605
  };
606
+ for (const shot of spec.shots) {
607
+ if (!shot.videoPath)
608
+ continue;
609
+ const sourcePath = resolve(cwd, shot.videoPath);
610
+ const sourceStat = await stat(sourcePath).catch(() => null);
611
+ if (!sourceStat?.isFile() || sourceStat.size === 0)
612
+ throw new VideoGenError(`Local shot "${shot.id}" is not a readable video file.`, 'render: local source invalid');
613
+ const videoPath = join(realShotsDir, shot.id, 'video.mp4');
614
+ const existing = await lstat(videoPath).catch(() => null);
615
+ if (existing && !existing.isFile())
616
+ throw new VideoGenError(`Local shot "${shot.id}" snapshot path is not a regular file.`, 'render: local destination invalid');
617
+ const temp = `${videoPath}.tmp-${randomUUID()}`;
618
+ await copyFile(sourcePath, temp, COPYFILE_EXCL);
619
+ await rename(temp, videoPath);
620
+ const localSourceHash = await hashFileSha256(sourcePath);
621
+ const videoHash = await hashFileSha256(videoPath);
622
+ if (localSourceHash !== videoHash)
623
+ throw new VideoGenError(`Local shot "${shot.id}" changed while being copied. Start a new job.`, 'render: source race');
624
+ manifest.shots[shot.id] = {
625
+ state: 'done',
626
+ source: 'local',
627
+ videoPath,
628
+ localSourceHash,
629
+ videoHash,
630
+ };
631
+ }
327
632
  saveRenderJob(jobDir, manifest);
328
633
  }
329
634
  // 4. per-shot render loop (resume-aware)
@@ -331,6 +636,8 @@ export async function runRender(opts) {
331
636
  const renderShot = async (shot) => {
332
637
  const shotState = manifest.shots[shot.id];
333
638
  const videoPath = join(realShotsDir, shot.id, 'video.mp4');
639
+ if (shot.videoPath)
640
+ return;
334
641
  if (shotState.state === 'done' && existsSync(videoPath))
335
642
  return;
336
643
  const firstSnap = shot.firstFramePath
@@ -429,12 +736,16 @@ export async function runRender(opts) {
429
736
  });
430
737
  opts.onUpdate?.(`Shot ${shot.id}: downloading…`);
431
738
  await adapter.downloadTo(resolved.provider, handle, succeeded.videoUrl, videoPath, fetch, opts.signal);
739
+ const videoHash = await hashFileSha256(videoPath);
740
+ if (shotState.state === 'done' && shotState.videoHash && videoHash !== shotState.videoHash)
741
+ throw new VideoGenError(`Recovered video for shot "${shot.id}" does not match its previously frozen hash.`, 'render: recovered shot drift');
432
742
  manifest.shots[shot.id] = {
433
743
  state: 'done',
434
744
  attempt: currentAttempt,
435
745
  handle,
436
746
  requestFingerprint: shotState.requestFingerprint ?? activeFingerprint,
437
747
  videoPath,
748
+ videoHash,
438
749
  };
439
750
  saveRenderJob(jobDir, manifest);
440
751
  opts.onUpdate?.(`Shot ${shot.id}: done.`);
@@ -449,7 +760,7 @@ export async function runRender(opts) {
449
760
  if (!shotState.handle || shotState.state === 'done')
450
761
  continue;
451
762
  let cancelled = false;
452
- if (adapter.cancel) {
763
+ if (adapter?.cancel && resolved) {
453
764
  try {
454
765
  cancelled = (await adapter.cancel(resolved.provider, shotState.handle, fetch, AbortSignal.timeout(10_000))).cancelled;
455
766
  }
@@ -478,18 +789,75 @@ export async function runRender(opts) {
478
789
  throw error;
479
790
  }
480
791
  // 5. concat
792
+ const shotMedia = [];
793
+ if (opts.verifyMedia !== false) {
794
+ const ffprobe = resolveFfprobe(settings.ffmpegPath);
795
+ if (!ffprobe.runnable)
796
+ throw new VideoGenError('ffprobe is required to verify downloaded shot media.', 'render: ffprobe missing');
797
+ for (const shot of spec.shots) {
798
+ const path = join(realShotsDir, shot.id, 'video.mp4');
799
+ const streams = await probeStreams(ffprobe.path, path, opts.signal);
800
+ const durationSec = await probeDuration(ffprobe.path, path, opts.signal);
801
+ shotMedia.push({
802
+ id: shot.id,
803
+ durationSec,
804
+ videoCodec: streams.videoCodec,
805
+ audioLayout: streams.audioLayout,
806
+ });
807
+ }
808
+ }
481
809
  manifest.state = 'concatenating';
482
810
  saveRenderJob(jobDir, manifest);
483
811
  const inputs = spec.shots.map((s) => join(realShotsDir, s.id, 'video.mp4'));
484
- const finalVideoPath = join(jobDir, 'final_video.mp4');
485
- opts.onUpdate?.(`Concatenating ${inputs.length} clips…`);
812
+ let finalVideoPath = join(jobDir, 'final_video.mp4');
813
+ let remotionShotTimes;
814
+ opts.onUpdate?.(`Assembling ${inputs.length} clips…`);
486
815
  try {
487
- await concat({
488
- inputs,
489
- outputPath: finalVideoPath,
490
- ffmpegPath: opts.ffmpegPath,
491
- signal: opts.signal,
492
- });
816
+ if (spec.assembly?.type === 'remotion') {
817
+ const assets = {
818
+ ...spec.assembly.assets,
819
+ ...Object.fromEntries(spec.shots.map((shot, index) => [shot.id, inputs[index]])),
820
+ };
821
+ const child = await runRemotion({
822
+ specPath: join(jobDir, 'assembly', 'remotion-input.json'),
823
+ cwd,
824
+ settings,
825
+ activeJobs: opts.activeJobs,
826
+ trusted: Boolean(opts.trusted),
827
+ frozenProjectHash: manifest.assemblyProjectHash,
828
+ assets,
829
+ shotIds: spec.shots.map((shot) => shot.id),
830
+ ffmpegPath: opts.ffmpegPath,
831
+ signal: opts.signal,
832
+ });
833
+ finalVideoPath = child.finalVideoPath;
834
+ remotionShotTimes = child.shotTimesSec;
835
+ }
836
+ else if (spec.assembly?.type === 'timeline') {
837
+ const assemblyDir = join(jobDir, 'assembly');
838
+ await mkdir(assemblyDir, { recursive: true });
839
+ if (!(await realpath(assemblyDir)).startsWith(`${realJobDir}${sep}`))
840
+ throw new VideoGenError('Timeline assembly directory escapes the render job.', 'render: assembly escape');
841
+ const timeline = timelineWithShotPaths(spec.assembly.timeline, realShotsDir);
842
+ const timelinePath = join(assemblyDir, 'timeline-input.json');
843
+ writeJsonAtomic(timelinePath, timeline);
844
+ const child = await runTimeline({
845
+ timelineSpecPath: timelinePath,
846
+ cwd: await realpath(cwd),
847
+ settings,
848
+ activeJobs: opts.activeJobs,
849
+ signal: opts.signal,
850
+ });
851
+ finalVideoPath = child.finalVideoPath;
852
+ }
853
+ else {
854
+ await concat({
855
+ inputs,
856
+ outputPath: finalVideoPath,
857
+ ffmpegPath: opts.ffmpegPath,
858
+ signal: opts.signal,
859
+ });
860
+ }
493
861
  }
494
862
  catch (error) {
495
863
  const cancelled = error instanceof CancelledError || opts.signal?.aborted;
@@ -500,10 +868,107 @@ export async function runRender(opts) {
500
868
  });
501
869
  throw error;
502
870
  }
871
+ if (opts.verifyMedia !== false) {
872
+ const ffprobe = resolveFfprobe(settings.ffmpegPath);
873
+ const finalStreams = await probeStreams(ffprobe.path, finalVideoPath, opts.signal);
874
+ const finalDurationSec = await probeDuration(ffprobe.path, finalVideoPath, opts.signal);
875
+ if (!spec.assembly &&
876
+ Math.abs(finalDurationSec - shotMedia.reduce((sum, item) => sum + item.durationSec, 0)) > 1)
877
+ throw new VideoGenError('QC: final duration differs from the sum of shot durations by more than 1s.', 'render: duration qc');
878
+ if (!spec.assembly &&
879
+ shotMedia.some((item) => item.audioLayout !== 'none') &&
880
+ finalStreams.audioLayout === 'none')
881
+ throw new VideoGenError('QC: final video lost the source audio track.', 'render: audio qc');
882
+ const qcDir = join(jobDir, 'qc');
883
+ await mkdir(qcDir, { recursive: true });
884
+ const realQc = await realpath(qcDir);
885
+ if (!realQc.startsWith(`${realJobDir}${sep}`))
886
+ throw new VideoGenError('QC directory escapes the render job.', 'render: qc escape');
887
+ const qcHashes = {};
888
+ const finalSamples = [];
889
+ if (spec.assembly?.type === 'timeline') {
890
+ const child = loadTimelineJob(join(jobDir, 'assembly'));
891
+ if (!child || child.state !== 'done')
892
+ throw new VideoGenError('Timeline assembly has no completed manifest.', 'render: timeline qc');
893
+ let cursor = 0;
894
+ for (const [index, segment] of spec.assembly.timeline.segments.entries()) {
895
+ const duration = child.segments[segment.id]?.resolvedDurationSec;
896
+ const visible = (duration ?? 0) - (segment.transitionTo?.durationSec ?? 0);
897
+ if (!Number.isFinite(visible) || visible <= 0)
898
+ throw new VideoGenError(`Timeline segment ${segment.id} has no valid duration.`, 'render: timeline qc');
899
+ finalSamples.push({
900
+ timeSec: cursor + visible / 2,
901
+ path: join('qc', `final_part_${index + 1}.png`),
902
+ });
903
+ cursor += visible;
904
+ }
905
+ }
906
+ else if (spec.assembly?.type === 'remotion') {
907
+ for (const [index, shot] of spec.shots.entries()) {
908
+ const timeSec = remotionShotTimes?.[shot.id];
909
+ if (timeSec === undefined ||
910
+ !Number.isFinite(timeSec) ||
911
+ timeSec < 0 ||
912
+ timeSec >= finalDurationSec)
913
+ throw new VideoGenError(`Remotion has no valid timing for shot ${shot.id}.`, 'render: remotion qc');
914
+ finalSamples.push({ timeSec, path: join('qc', `final_part_${index + 1}.png`) });
915
+ }
916
+ }
917
+ else {
918
+ let cursor = 0;
919
+ for (const [index, shot] of shotMedia.entries()) {
920
+ finalSamples.push({
921
+ timeSec: cursor + shot.durationSec / 2,
922
+ path: join('qc', `final_part_${index + 1}.png`),
923
+ });
924
+ cursor += shot.durationSec;
925
+ }
926
+ }
927
+ const samples = [
928
+ ...shotMedia.map((shot) => ({
929
+ timeSec: shot.durationSec / 2,
930
+ path: join('qc', `shot_${shot.id}.png`),
931
+ video: join(realShotsDir, shot.id, 'video.mp4'),
932
+ })),
933
+ ...finalSamples.map((sample) => ({ ...sample, video: finalVideoPath })),
934
+ ];
935
+ for (const { timeSec, path: rel, video } of samples) {
936
+ const dest = join(jobDir, rel);
937
+ const temp = `${dest}.${randomUUID()}.png`;
938
+ await runFfmpegCommand(opts.ffmpegPath, ['-ss', timeSec.toFixed(3), '-i', video, '-frames:v', '1', '-n', temp], opts.signal);
939
+ await rename(temp, dest);
940
+ qcHashes[rel] = await hashFileSha256(dest, opts.signal);
941
+ }
942
+ const reportPath = join(qcDir, 'report.json');
943
+ writeJsonAtomic(reportPath, {
944
+ shots: shotMedia,
945
+ final: {
946
+ durationSec: finalDurationSec,
947
+ videoCodec: finalStreams.videoCodec,
948
+ audioLayout: finalStreams.audioLayout,
949
+ samples: finalSamples,
950
+ },
951
+ });
952
+ qcHashes[join('qc', 'report.json')] = await hashFileSha256(reportPath, opts.signal);
953
+ manifest.qcHashes = qcHashes;
954
+ }
503
955
  manifest.state = 'done';
504
956
  manifest.finalVideoPath = finalVideoPath;
957
+ manifest.finalVideoHash = await hashFileSha256(finalVideoPath);
505
958
  saveRenderJob(jobDir, manifest);
506
- return { jobId, finalVideoPath, shotsDone: spec.shots.length, degraded };
959
+ return {
960
+ jobId,
961
+ finalVideoPath,
962
+ shotsDone: spec.shots.length,
963
+ degraded,
964
+ qcFrames: Object.keys(manifest.qcHashes ?? {})
965
+ .filter((name) => name.endsWith('.png'))
966
+ .map((name) => join(jobDir, name)),
967
+ qcReportPath: manifest.qcHashes?.[join('qc', 'report.json')]
968
+ ? join(jobDir, 'qc', 'report.json')
969
+ : undefined,
970
+ editableProjectDir: spec.assembly?.type === 'remotion' ? spec.assembly.projectDir : undefined,
971
+ };
507
972
  }
508
973
  finally {
509
974
  release();