@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/README.md +19 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +46 -18
- package/dist/index.js.map +1 -1
- package/dist/jobs/store.d.ts +12 -0
- package/dist/jobs/store.d.ts.map +1 -1
- package/dist/jobs/store.js +23 -2
- package/dist/jobs/store.js.map +1 -1
- package/dist/remotion.d.ts +51 -0
- package/dist/remotion.d.ts.map +1 -0
- package/dist/remotion.js +547 -0
- package/dist/remotion.js.map +1 -0
- package/dist/render.d.ts +24 -3
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +488 -23
- package/dist/render.js.map +1 -1
- package/dist/text-layer.d.ts.map +1 -1
- package/dist/text-layer.js +43 -10
- package/dist/text-layer.js.map +1 -1
- package/dist/timeline-render.d.ts +2 -0
- package/dist/timeline-render.d.ts.map +1 -1
- package/dist/timeline-render.js +105 -53
- package/dist/timeline-render.js.map +1 -1
- package/dist/timeline.d.ts.map +1 -1
- package/dist/timeline.js +3 -0
- package/dist/timeline.js.map +1 -1
- package/dist/tts/edge-tts.d.ts +5 -0
- package/dist/tts/edge-tts.d.ts.map +1 -1
- package/dist/tts/edge-tts.js +28 -3
- package/dist/tts/edge-tts.js.map +1 -1
- package/package.json +8 -7
- package/preview.png +0 -0
- package/skills/video-gen/SKILL.md +30 -30
- package/skills/video-gen/references/remotion-handoff.md +21 -0
- package/skills/video-gen/references/video-render-workflow.md +23 -0
package/dist/render.js
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
160
|
-
if (wantsLastFrame.length > 0 && !caps
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
485
|
-
|
|
812
|
+
let finalVideoPath = join(jobDir, 'final_video.mp4');
|
|
813
|
+
let remotionShotTimes;
|
|
814
|
+
opts.onUpdate?.(`Assembling ${inputs.length} clips…`);
|
|
486
815
|
try {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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 {
|
|
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();
|