@telepat/rilo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/index.js +1 -0
  4. package/models/black-forest-labs__flux-2-pro.json +78 -0
  5. package/models/black-forest-labs__flux-schnell.json +95 -0
  6. package/models/bytedance__seedream-4.json +71 -0
  7. package/models/deepseek-ai__deepseek-v3.json +61 -0
  8. package/models/google__nano-banana-pro.json +92 -0
  9. package/models/google__veo-3.1-fast.json +93 -0
  10. package/models/google__veo-3.1.json +93 -0
  11. package/models/jaaari__kokoro-82m.json +86 -0
  12. package/models/kwaivgi__kling-v3-video.json +101 -0
  13. package/models/minimax__speech-02-turbo.json +141 -0
  14. package/models/pixverse__pixverse-v5.6.json +113 -0
  15. package/models/prunaai__z-image-turbo.json +107 -0
  16. package/models/resemble-ai__chatterbox-turbo.json +102 -0
  17. package/models/wan-video__wan-2.2-i2v-fast.json +139 -0
  18. package/package.json +67 -0
  19. package/src/api/firebaseFunction.js +46 -0
  20. package/src/api/middleware/auth.js +70 -0
  21. package/src/api/openapi/generateOpenApi.js +21 -0
  22. package/src/api/openapi/spec.js +831 -0
  23. package/src/api/routes/jobs.js +45 -0
  24. package/src/api/routes/projectAssets.js +63 -0
  25. package/src/api/routes/projects.js +647 -0
  26. package/src/api/routes/webhooks.js +13 -0
  27. package/src/api/server.js +88 -0
  28. package/src/backends/firebaseClient.js +57 -0
  29. package/src/backends/outputBackend.js +186 -0
  30. package/src/backends/projectMetadataBackend.js +550 -0
  31. package/src/cli/commands/openHome.js +70 -0
  32. package/src/cli/commands/settingsFlow.js +196 -0
  33. package/src/cli/index.js +192 -0
  34. package/src/config/env.js +158 -0
  35. package/src/config/keystore.js +175 -0
  36. package/src/config/models.js +281 -0
  37. package/src/config/settingsSchema.js +214 -0
  38. package/src/media/ffmpeg.js +144 -0
  39. package/src/media/files.js +77 -0
  40. package/src/media/subtitles.js +444 -0
  41. package/src/observability/apiTrace.js +17 -0
  42. package/src/observability/logger.js +7 -0
  43. package/src/observability/metrics.js +10 -0
  44. package/src/pipeline/inputSanitizer.js +6 -0
  45. package/src/pipeline/orchestrator.js +1669 -0
  46. package/src/policy/contentGuardrails.js +30 -0
  47. package/src/providers/predictions.js +188 -0
  48. package/src/providers/replicateClient.js +12 -0
  49. package/src/steps/alignSubtitles.js +156 -0
  50. package/src/steps/burnInSubtitles.js +22 -0
  51. package/src/steps/composeFinalVideo.js +57 -0
  52. package/src/steps/generateKeyframes.js +70 -0
  53. package/src/steps/generateVideoSegments.js +95 -0
  54. package/src/steps/generateVoiceover.js +128 -0
  55. package/src/steps/imageToVideoAdapters.js +100 -0
  56. package/src/steps/script.js +177 -0
  57. package/src/steps/textToImageAdapters.js +87 -0
  58. package/src/store/assetStore.js +5 -0
  59. package/src/store/jobStore.js +102 -0
  60. package/src/store/projectAnalyticsStore.js +625 -0
  61. package/src/store/projectStore.js +684 -0
  62. package/src/store/settingsStore.js +155 -0
  63. package/src/store/staleAssetStore.js +63 -0
  64. package/src/types/job.js +28 -0
  65. package/src/types/media.js +28 -0
  66. package/src/worker/processor.js +24 -0
@@ -0,0 +1,1669 @@
1
+ import { preprocessStory } from './inputSanitizer.js';
2
+ import { generateScript, generateShots } from '../steps/script.js';
3
+ import {
4
+ buildFixedTimeline,
5
+ generateVoiceover,
6
+ persistVoiceover,
7
+ resolveSegmentCountFromAudioDuration
8
+ } from '../steps/generateVoiceover.js';
9
+ import { generateKeyframe, persistKeyframe, persistKeyframes } from '../steps/generateKeyframes.js';
10
+ import {
11
+ generateVideoSegmentAtIndex,
12
+ persistSegment,
13
+ persistSegments
14
+ } from '../steps/generateVideoSegments.js';
15
+ import { composeFinalVideo } from '../steps/composeFinalVideo.js';
16
+ import { alignSubtitlesToVideo } from '../steps/alignSubtitles.js';
17
+ import { burnInSubtitles } from '../steps/burnInSubtitles.js';
18
+ import { JobStatus, JobStep, emptyStepState } from '../types/job.js';
19
+ import { logError, logInfo } from '../observability/logger.js';
20
+ import { persistArtifacts } from '../store/assetStore.js';
21
+ import {
22
+ acquireProjectRunLock,
23
+ getJob,
24
+ getProjectRunLockOwner,
25
+ releaseProjectRunLock,
26
+ updateJob
27
+ } from '../store/jobStore.js';
28
+ import {
29
+ DEFAULT_VIDEO_CONFIG,
30
+ MODEL_CATEGORIES,
31
+ resolveKeyframeSize,
32
+ resolveProjectModelOptions,
33
+ resolveProjectModelSelections,
34
+ resolveShotCount,
35
+ resolveTargetDurationSec
36
+ } from '../config/models.js';
37
+ import fs from 'node:fs/promises';
38
+ import crypto from 'node:crypto';
39
+ import path from 'node:path';
40
+ import { ensureDir, writeJson } from '../media/files.js';
41
+ import { probeMediaDurationSeconds } from '../media/ffmpeg.js';
42
+ import {
43
+ ensureProject,
44
+ readProjectConfig,
45
+ readProjectStory,
46
+ getProjectDir,
47
+ readProjectRunState,
48
+ resolveProjectName,
49
+ writeProjectRunState
50
+ } from '../store/projectStore.js';
51
+ import { archiveProjectAssets } from '../store/staleAssetStore.js';
52
+ import { syncProjectSnapshot } from '../backends/outputBackend.js';
53
+ import {
54
+ collectRunPredictions,
55
+ createRunId,
56
+ createRunRecord,
57
+ finalizeRunRecord,
58
+ markStageFinished,
59
+ markStageReused,
60
+ markStageStarted,
61
+ writeRunRecord
62
+ } from '../store/projectAnalyticsStore.js';
63
+
64
+ const DEFAULT_ORCHESTRATOR_DEPS = {
65
+ preprocessStory,
66
+ generateScript,
67
+ generateShots,
68
+ buildFixedTimeline,
69
+ generateVoiceover,
70
+ persistVoiceover,
71
+ resolveSegmentCountFromAudioDuration,
72
+ generateKeyframe,
73
+ persistKeyframe,
74
+ persistKeyframes,
75
+ generateVideoSegmentAtIndex,
76
+ persistSegment,
77
+ persistSegments,
78
+ composeFinalVideo,
79
+ alignSubtitlesToVideo,
80
+ burnInSubtitles,
81
+ persistArtifacts,
82
+ probeMediaDurationSeconds,
83
+ ensureProject,
84
+ readProjectConfig,
85
+ readProjectStory,
86
+ getProjectDir,
87
+ readProjectRunState,
88
+ resolveProjectName,
89
+ writeProjectRunState,
90
+ archiveProjectAssets,
91
+ syncProjectSnapshot,
92
+ collectRunPredictions,
93
+ createRunId,
94
+ createRunRecord,
95
+ finalizeRunRecord,
96
+ markStageFinished,
97
+ markStageReused,
98
+ markStageStarted,
99
+ writeRunRecord
100
+ };
101
+
102
+ async function persistCheckpoint(job, projectDir = null, deps = DEFAULT_ORCHESTRATOR_DEPS) {
103
+ const resolvedProjectDir = projectDir || getProjectDir(job.payload.project);
104
+
105
+ await deps.persistArtifacts(job.payload.project, job.artifacts);
106
+ await deps.writeProjectRunState(job.payload.project, {
107
+ status: job.status,
108
+ error: job.error,
109
+ steps: job.steps,
110
+ artifacts: job.artifacts,
111
+ updatedAt: job.updatedAt
112
+ });
113
+
114
+ await deps.syncProjectSnapshot({
115
+ project: job.payload.project,
116
+ projectDir: resolvedProjectDir
117
+ });
118
+ }
119
+
120
+ async function persistTextAssets(projectDir, script, shots, tone) {
121
+ const textDir = path.join(projectDir, 'assets', 'text');
122
+ await ensureDir(textDir);
123
+ await writeJson(path.join(textDir, 'script.json'), {
124
+ script,
125
+ shots,
126
+ tone
127
+ });
128
+ }
129
+
130
+ function hashText(value) {
131
+ return crypto.createHash('sha256').update(String(value || '')).digest('hex');
132
+ }
133
+
134
+ function hashShots(shots) {
135
+ return (shots || []).map((shot) => hashText(shot));
136
+ }
137
+
138
+ function findChangedShotIndexes(previousHashes, nextHashes) {
139
+ const changed = [];
140
+ const maxLength = Math.max(previousHashes.length, nextHashes.length);
141
+ for (let i = 0; i < maxLength; i += 1) {
142
+ if ((previousHashes[i] || '') !== (nextHashes[i] || '')) {
143
+ changed.push(i);
144
+ }
145
+ }
146
+ return changed;
147
+ }
148
+
149
+ function collectAffectedSegmentIndexes(changedShotIndexes, totalKeyframes) {
150
+ const affected = new Set();
151
+ for (const shotIndex of changedShotIndexes) {
152
+ if (shotIndex > 0) {
153
+ affected.add(shotIndex - 1);
154
+ }
155
+ if (shotIndex < totalKeyframes - 1) {
156
+ affected.add(shotIndex);
157
+ }
158
+ }
159
+ return affected;
160
+ }
161
+
162
+ function clearSubtitleArtifacts(artifacts) {
163
+ return {
164
+ ...artifacts,
165
+ subtitleSeedPath: '',
166
+ subtitleAlignedSrtPath: '',
167
+ subtitleAssPath: '',
168
+ finalCaptionedVideoPath: '',
169
+ subtitlesUrl: ''
170
+ };
171
+ }
172
+
173
+ async function fileExists(targetPath) {
174
+ try {
175
+ await fs.access(targetPath);
176
+ return true;
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ async function readScriptAsset(projectDir) {
183
+ try {
184
+ const filePath = path.join(projectDir, 'assets', 'text', 'script.json');
185
+ const raw = await fs.readFile(filePath, 'utf8');
186
+ const parsed = JSON.parse(raw);
187
+ if (!parsed || typeof parsed !== 'object') {
188
+ return null;
189
+ }
190
+ if (!parsed.script || typeof parsed.script !== 'string') {
191
+ return null;
192
+ }
193
+ return {
194
+ ...parsed,
195
+ shots: Array.isArray(parsed.shots) ? parsed.shots : []
196
+ };
197
+ } catch (error) {
198
+ if (error.code === 'ENOENT') {
199
+ return null;
200
+ }
201
+ throw error;
202
+ }
203
+ }
204
+
205
+ export async function regenerateProjectAsset(projectName, target, options = {}) {
206
+ const deps = {
207
+ ...DEFAULT_ORCHESTRATOR_DEPS,
208
+ ...(options.deps || {})
209
+ };
210
+
211
+ const project = deps.resolveProjectName(projectName);
212
+ const targetType = String(target?.targetType || '').trim().toLowerCase();
213
+ const hasIndex = target?.index !== undefined;
214
+ const index = hasIndex ? Number(target?.index) : null;
215
+
216
+ if (!['script', 'voiceover', 'keyframe', 'segment', 'align', 'burnin'].includes(targetType)) {
217
+ throw new Error('targetType must be one of script, voiceover, keyframe, segment, align, burnin');
218
+ }
219
+
220
+ if (['script', 'voiceover', 'align', 'burnin'].includes(targetType) && hasIndex) {
221
+ throw new Error('index is not supported for script/voiceover/align/burnin regeneration');
222
+ }
223
+
224
+ if (['keyframe', 'segment'].includes(targetType) && (!Number.isInteger(index) || index < 0)) {
225
+ throw new Error('index must be a non-negative integer');
226
+ }
227
+
228
+ await deps.ensureProject(project);
229
+ const projectConfig = await deps.readProjectConfig(project);
230
+ const modelSelections = resolveProjectModelSelections(projectConfig.models);
231
+ const modelOptions = resolveProjectModelOptions(projectConfig.modelOptions, modelSelections);
232
+ const projectDir = deps.getProjectDir(project);
233
+ const runState = await deps.readProjectRunState(project);
234
+
235
+ if (!runState?.artifacts) {
236
+ throw new Error('project has no prior artifacts to regenerate from');
237
+ }
238
+
239
+ const artifacts = {
240
+ ...runState.artifacts,
241
+ modelSelections,
242
+ modelOptions,
243
+ subtitleOptions: projectConfig.subtitleOptions,
244
+ keyframeUrls: [...(runState.artifacts.keyframeUrls || [])],
245
+ keyframePaths: [...(runState.artifacts.keyframePaths || [])],
246
+ segmentUrls: [...(runState.artifacts.segmentUrls || [])],
247
+ segmentPaths: [...(runState.artifacts.segmentPaths || [])]
248
+ };
249
+
250
+ if (
251
+ ['voiceover', 'keyframe', 'segment'].includes(targetType)
252
+ && (!Array.isArray(artifacts.shots) || artifacts.shots.length === 0)
253
+ ) {
254
+ const scriptAsset = await readScriptAsset(projectDir);
255
+ if (Array.isArray(scriptAsset?.shots) && scriptAsset.shots.length > 0) {
256
+ artifacts.shots = [...scriptAsset.shots];
257
+ artifacts.shotHashes = hashShots(artifacts.shots);
258
+ }
259
+ }
260
+
261
+ const steps = {
262
+ ...emptyStepState(),
263
+ ...(runState.steps || {})
264
+ };
265
+ const keyframeCount = Array.isArray(artifacts.shots) ? artifacts.shots.length : 0;
266
+ const segmentCount = Math.max(0, keyframeCount - 1);
267
+
268
+ if (['keyframe', 'segment'].includes(targetType)) {
269
+ if (!Array.isArray(artifacts.shots) || artifacts.shots.length === 0) {
270
+ throw new Error('project has no shots to regenerate');
271
+ }
272
+
273
+ if (targetType === 'keyframe' && index >= keyframeCount) {
274
+ throw new Error(`index out of range for project keyframes (${keyframeCount})`);
275
+ }
276
+
277
+ if (targetType === 'segment' && index >= segmentCount) {
278
+ throw new Error(`index out of range for project segments (${segmentCount})`);
279
+ }
280
+ }
281
+
282
+ const traceBase = {
283
+ project,
284
+ projectDir,
285
+ reason: 'targeted_asset_regeneration',
286
+ targetType,
287
+ ...((targetType === 'keyframe' || targetType === 'segment') ? { index } : {})
288
+ };
289
+
290
+ if (targetType === 'script') {
291
+ const story = await deps.readProjectStory(project);
292
+ const safeStory = deps.preprocessStory(story);
293
+ const scriptResult = await deps.generateScript(
294
+ safeStory,
295
+ {
296
+ targetDurationSec: resolveTargetDurationSec(projectConfig),
297
+ modelId: modelSelections[MODEL_CATEGORIES.textToText],
298
+ modelOptions: modelOptions[MODEL_CATEGORIES.textToText]
299
+ },
300
+ traceBase
301
+ );
302
+
303
+ artifacts.script = scriptResult.script;
304
+ artifacts.tone = scriptResult.tone || artifacts.tone || 'neutral';
305
+ artifacts.scriptWordCount = scriptResult.scriptWordCount || null;
306
+ artifacts.targetWordCount = scriptResult.targetWordCount || null;
307
+ artifacts.scriptHash = hashText(scriptResult.script);
308
+ artifacts.scriptSourceStoryHash = hashText(safeStory);
309
+ artifacts.shots = [];
310
+ artifacts.shotHashes = [];
311
+ artifacts.timeline = [];
312
+ artifacts.voiceoverUrl = '';
313
+ artifacts.voiceoverPath = '';
314
+ artifacts.keyframeUrls = [];
315
+ artifacts.keyframePaths = [];
316
+ artifacts.segmentUrls = [];
317
+ artifacts.segmentPaths = [];
318
+ artifacts.finalBaseVideoPath = '';
319
+ artifacts.finalVideoPath = '';
320
+ Object.assign(artifacts, clearSubtitleArtifacts(artifacts));
321
+
322
+ steps[JobStep.SCRIPT] = true;
323
+ steps[JobStep.VOICE] = false;
324
+ steps[JobStep.KEYFRAMES] = false;
325
+ steps[JobStep.SEGMENTS] = false;
326
+ steps[JobStep.COMPOSE] = false;
327
+ steps[JobStep.ALIGN] = false;
328
+ steps[JobStep.BURNIN] = false;
329
+
330
+ await persistTextAssets(projectDir, artifacts.script, artifacts.shots, artifacts.tone || 'neutral');
331
+ }
332
+
333
+ if (targetType === 'voiceover') {
334
+ if (!artifacts.script) {
335
+ throw new Error('project has no script to regenerate voiceover from');
336
+ }
337
+
338
+ const fallbackSegmentCount = Array.isArray(artifacts.timeline)
339
+ ? artifacts.timeline.length
340
+ : Math.max(
341
+ 0,
342
+ (Array.isArray(artifacts.keyframeUrls) ? artifacts.keyframeUrls.length : 0) - 1,
343
+ Array.isArray(artifacts.segmentUrls) ? artifacts.segmentUrls.length : 0
344
+ );
345
+ const estimatedShotsCount = Array.isArray(artifacts.shots) && artifacts.shots.length > 0
346
+ ? artifacts.shots.length
347
+ : Math.max(2, fallbackSegmentCount + 1);
348
+
349
+ const previousAudioDurationSec = Number.isFinite(artifacts.audioDurationSec)
350
+ ? artifacts.audioDurationSec
351
+ : null;
352
+ const previousSegmentCount = Array.isArray(artifacts.timeline)
353
+ ? artifacts.timeline.length
354
+ : Math.max(0, estimatedShotsCount - 1);
355
+
356
+ const voiceResult = await deps.generateVoiceover(
357
+ artifacts.script,
358
+ {
359
+ shotsCount: Math.max(1, estimatedShotsCount - 1),
360
+ segmentDurationSec: DEFAULT_VIDEO_CONFIG.segmentDurationSec,
361
+ targetDurationSec: resolveTargetDurationSec(projectConfig),
362
+ modelId: modelSelections[MODEL_CATEGORIES.textToSpeech],
363
+ modelOptions: modelOptions[MODEL_CATEGORIES.textToSpeech]
364
+ },
365
+ traceBase
366
+ );
367
+
368
+ artifacts.voiceoverUrl = voiceResult.voiceoverUrl;
369
+ artifacts.voiceoverPath = await deps.persistVoiceover(projectDir, voiceResult.voiceoverUrl);
370
+ artifacts.ttsPlan = voiceResult.ttsPlan;
371
+
372
+ const audioDurationSec = await deps.probeMediaDurationSeconds(artifacts.voiceoverPath);
373
+ const requiredSegments = deps.resolveSegmentCountFromAudioDuration(
374
+ audioDurationSec,
375
+ DEFAULT_VIDEO_CONFIG.segmentDurationSec
376
+ );
377
+ const requiredKeyframes = Math.max(2, requiredSegments + 1);
378
+ const timeline = deps.buildFixedTimeline(requiredSegments, DEFAULT_VIDEO_CONFIG.segmentDurationSec);
379
+ artifacts.audioDurationSec = audioDurationSec;
380
+ artifacts.plannedShots = requiredSegments;
381
+ artifacts.segmentDurationSec = DEFAULT_VIDEO_CONFIG.segmentDurationSec;
382
+ artifacts.timeline = timeline;
383
+
384
+ const sameAudioDuration =
385
+ previousAudioDurationSec !== null
386
+ && Math.abs(previousAudioDurationSec - audioDurationSec) <= 0.05;
387
+ const sameVisualLength = requiredSegments === previousSegmentCount;
388
+
389
+ if (!Array.isArray(artifacts.shots) || artifacts.shots.length !== requiredKeyframes) {
390
+ const shotsResult = await deps.generateShots(
391
+ artifacts.script,
392
+ {
393
+ shotCount: requiredKeyframes,
394
+ tone: artifacts.tone || 'neutral',
395
+ modelId: modelSelections[MODEL_CATEGORIES.textToText],
396
+ modelOptions: modelOptions[MODEL_CATEGORIES.textToText]
397
+ },
398
+ traceBase
399
+ );
400
+ artifacts.shots = shotsResult.shots;
401
+ artifacts.shotHashes = hashShots(shotsResult.shots);
402
+ artifacts.keyframeUrls = [];
403
+ artifacts.keyframePaths = [];
404
+ artifacts.segmentUrls = [];
405
+ artifacts.segmentPaths = [];
406
+ await persistTextAssets(projectDir, artifacts.script, artifacts.shots, artifacts.tone || 'neutral');
407
+ steps[JobStep.KEYFRAMES] = false;
408
+ steps[JobStep.SEGMENTS] = false;
409
+ steps[JobStep.COMPOSE] = false;
410
+ steps[JobStep.ALIGN] = false;
411
+ steps[JobStep.BURNIN] = false;
412
+ artifacts.finalBaseVideoPath = '';
413
+ artifacts.finalVideoPath = '';
414
+ Object.assign(artifacts, clearSubtitleArtifacts(artifacts));
415
+ }
416
+
417
+ steps[JobStep.VOICE] = true;
418
+
419
+ const canComposeWithExistingVisuals =
420
+ Array.isArray(artifacts.segmentUrls)
421
+ && artifacts.segmentUrls.length === requiredSegments
422
+ && artifacts.segmentUrls.every((url) => Boolean(url));
423
+
424
+ if (sameAudioDuration && sameVisualLength && canComposeWithExistingVisuals) {
425
+ const composed = await deps.composeFinalVideo({
426
+ projectDir,
427
+ segmentUrls: artifacts.segmentUrls,
428
+ segmentPaths: artifacts.segmentPaths,
429
+ voiceoverPath: artifacts.voiceoverPath,
430
+ voiceoverUrl: artifacts.voiceoverUrl,
431
+ keyframePaths: artifacts.keyframePaths,
432
+ finalDurationMode: projectConfig.finalDurationMode
433
+ });
434
+
435
+ artifacts.finalVideoPath = composed.finalVideoPath;
436
+ artifacts.finalBaseVideoPath = composed.finalVideoPath;
437
+ artifacts.voiceoverPath = composed.voiceoverPath;
438
+ artifacts.keyframePaths = composed.keyframePaths;
439
+ artifacts.segmentPaths = composed.segmentPaths;
440
+ steps[JobStep.COMPOSE] = true;
441
+ steps[JobStep.ALIGN] = false;
442
+ steps[JobStep.BURNIN] = false;
443
+ Object.assign(artifacts, clearSubtitleArtifacts(artifacts));
444
+ } else {
445
+ steps[JobStep.COMPOSE] = false;
446
+ steps[JobStep.ALIGN] = false;
447
+ steps[JobStep.BURNIN] = false;
448
+ artifacts.finalBaseVideoPath = '';
449
+ artifacts.finalVideoPath = '';
450
+ Object.assign(artifacts, clearSubtitleArtifacts(artifacts));
451
+ }
452
+ }
453
+
454
+ if (targetType === 'keyframe') {
455
+ const keyframeSize = resolveKeyframeSize(projectConfig);
456
+ const keyframeUrl = await deps.generateKeyframe(
457
+ artifacts.shots[index],
458
+ artifacts.tone || 'neutral',
459
+ projectConfig.aspectRatio,
460
+ index,
461
+ traceBase,
462
+ keyframeSize,
463
+ {
464
+ modelId: modelSelections[MODEL_CATEGORIES.textToImage],
465
+ modelOptions: modelOptions[MODEL_CATEGORIES.textToImage]
466
+ }
467
+ );
468
+ artifacts.keyframeUrls[index] = keyframeUrl;
469
+ artifacts.keyframePaths[index] = await deps.persistKeyframe(projectDir, keyframeUrl, index);
470
+
471
+ const affectedSegmentIndexes = collectAffectedSegmentIndexes([index], artifacts.shots.length);
472
+ const maxSegmentIndex = Math.max(0, artifacts.shots.length - 1);
473
+ for (const segmentIndex of affectedSegmentIndexes) {
474
+ if (segmentIndex < 0 || segmentIndex >= maxSegmentIndex) {
475
+ continue;
476
+ }
477
+ artifacts.segmentUrls[segmentIndex] = '';
478
+ artifacts.segmentPaths[segmentIndex] = '';
479
+ }
480
+
481
+ steps[JobStep.SEGMENTS] = false;
482
+ steps[JobStep.COMPOSE] = false;
483
+ steps[JobStep.ALIGN] = false;
484
+ steps[JobStep.BURNIN] = false;
485
+ artifacts.finalBaseVideoPath = '';
486
+ artifacts.finalVideoPath = '';
487
+ Object.assign(artifacts, clearSubtitleArtifacts(artifacts));
488
+ }
489
+
490
+ if (targetType === 'segment') {
491
+ if (!Array.isArray(artifacts.timeline) || artifacts.timeline.length === 0) {
492
+ throw new Error('project has no timeline to regenerate segment from');
493
+ }
494
+
495
+ if (!artifacts.keyframeUrls[index]) {
496
+ throw new Error('missing keyframe URL required for segment regeneration');
497
+ }
498
+
499
+ const segmentUrl = await deps.generateVideoSegmentAtIndex(
500
+ index,
501
+ artifacts.keyframeUrls,
502
+ artifacts.timeline,
503
+ artifacts.shots,
504
+ projectConfig.aspectRatio,
505
+ traceBase,
506
+ {
507
+ modelId: modelSelections[MODEL_CATEGORIES.imageTextToVideo],
508
+ modelOptions: modelOptions[MODEL_CATEGORIES.imageTextToVideo]
509
+ }
510
+ );
511
+ artifacts.segmentUrls[index] = segmentUrl;
512
+ artifacts.segmentPaths[index] = await deps.persistSegment(projectDir, segmentUrl, index);
513
+
514
+ steps[JobStep.COMPOSE] = false;
515
+ steps[JobStep.ALIGN] = false;
516
+ steps[JobStep.BURNIN] = false;
517
+ artifacts.finalBaseVideoPath = '';
518
+ artifacts.finalVideoPath = '';
519
+ Object.assign(artifacts, clearSubtitleArtifacts(artifacts));
520
+ }
521
+
522
+ if (targetType === 'align') {
523
+ const baseVideoPath = artifacts.finalBaseVideoPath || artifacts.finalVideoPath;
524
+ if (!baseVideoPath) {
525
+ throw new Error('project has no composed video to align subtitles against');
526
+ }
527
+
528
+ if (!artifacts.script || !String(artifacts.script).trim()) {
529
+ throw new Error('project has no script to align subtitles from');
530
+ }
531
+
532
+ const aligned = await deps.alignSubtitlesToVideo({
533
+ projectDir,
534
+ videoPath: baseVideoPath,
535
+ script: artifacts.script,
536
+ totalDurationSec: artifacts.audioDurationSec || resolveTargetDurationSec(projectConfig),
537
+ subtitleOptions: projectConfig.subtitleOptions
538
+ });
539
+
540
+ artifacts.subtitleSeedPath = aligned.subtitleSeedPath;
541
+ artifacts.subtitleAlignedSrtPath = aligned.subtitleAlignedSrtPath;
542
+ artifacts.subtitleAssPath = aligned.subtitleAssPath;
543
+ artifacts.finalBaseVideoPath = baseVideoPath;
544
+ artifacts.finalCaptionedVideoPath = '';
545
+ artifacts.finalVideoPath = baseVideoPath;
546
+ steps[JobStep.ALIGN] = true;
547
+ steps[JobStep.BURNIN] = false;
548
+ }
549
+
550
+ if (targetType === 'burnin') {
551
+ const baseVideoPath = artifacts.finalBaseVideoPath || artifacts.finalVideoPath;
552
+ if (!baseVideoPath) {
553
+ throw new Error('project has no composed video to burn subtitles into');
554
+ }
555
+
556
+ if (!artifacts.subtitleAssPath) {
557
+ throw new Error('project has no aligned subtitle file; run align first');
558
+ }
559
+
560
+ const burned = await deps.burnInSubtitles({
561
+ projectDir,
562
+ videoPath: baseVideoPath,
563
+ subtitleAssPath: artifacts.subtitleAssPath
564
+ });
565
+
566
+ artifacts.finalBaseVideoPath = baseVideoPath;
567
+ artifacts.finalCaptionedVideoPath = burned.finalCaptionedVideoPath;
568
+ artifacts.finalVideoPath = burned.finalCaptionedVideoPath;
569
+ steps[JobStep.BURNIN] = true;
570
+ steps[JobStep.ALIGN] = true;
571
+ }
572
+
573
+ const updatedAt = new Date().toISOString();
574
+ await deps.persistArtifacts(project, artifacts);
575
+ await deps.writeProjectRunState(project, {
576
+ status: runState.status || JobStatus.COMPLETED,
577
+ error: runState.error || null,
578
+ steps,
579
+ artifacts,
580
+ updatedAt
581
+ });
582
+
583
+ await deps.syncProjectSnapshot({ project, projectDir });
584
+
585
+ return {
586
+ project,
587
+ targetType,
588
+ ...((targetType === 'keyframe' || targetType === 'segment') ? { index } : {}),
589
+ artifacts,
590
+ steps,
591
+ updatedAt
592
+ };
593
+ }
594
+
595
+ export async function runPipeline(jobId, options = {}) {
596
+ const deps = {
597
+ ...DEFAULT_ORCHESTRATOR_DEPS,
598
+ ...(options.deps || {})
599
+ };
600
+
601
+ const forceRestart = Boolean(options.forceRestart);
602
+ const job = getJob(jobId);
603
+ if (!job) {
604
+ throw new Error(`Job not found: ${jobId}`);
605
+ }
606
+
607
+ let analyticsProject = null;
608
+ let analyticsProjectDir = null;
609
+ let analyticsRun = null;
610
+ let lockedProject = null;
611
+
612
+ try {
613
+ const project = deps.resolveProjectName(job.payload.project || `job-${jobId}`);
614
+ const lockAcquired = acquireProjectRunLock(project, jobId);
615
+ if (!lockAcquired) {
616
+ const ownerJobId = getProjectRunLockOwner(project);
617
+ const ownerSuffix = ownerJobId ? ` (owner job: ${ownerJobId})` : '';
618
+ const lockError = new Error(`Project ${project} is already running${ownerSuffix}`);
619
+ lockError.nonRetryable = true;
620
+ throw lockError;
621
+ }
622
+
623
+ lockedProject = project;
624
+ analyticsProject = project;
625
+ await deps.ensureProject(project);
626
+ const projectConfig = await deps.readProjectConfig(project);
627
+ const modelSelections = resolveProjectModelSelections(projectConfig.models);
628
+ const modelOptions = resolveProjectModelOptions(projectConfig.modelOptions, modelSelections);
629
+ const projectDir = deps.getProjectDir(project);
630
+ const targetDurationSec = resolveTargetDurationSec(projectConfig);
631
+ const plannedShots = resolveShotCount(projectConfig);
632
+ const safeStory = deps.preprocessStory(job.payload.story);
633
+ analyticsProjectDir = projectDir;
634
+ const analyticsRunId = deps.createRunId();
635
+ analyticsRun = deps.createRunRecord({
636
+ runId: analyticsRunId,
637
+ project,
638
+ jobId,
639
+ forceRestart
640
+ });
641
+ await deps.writeRunRecord(project, analyticsRun);
642
+
643
+ const saveAnalytics = async () => {
644
+ /* c8 ignore next 3 */
645
+ if (!analyticsRun || !analyticsProject) {
646
+ return;
647
+ }
648
+ await deps.writeRunRecord(analyticsProject, analyticsRun);
649
+ };
650
+
651
+ const startStageAnalytics = async (stage, details = null) => {
652
+ analyticsRun = deps.markStageStarted(analyticsRun, stage, details);
653
+ await saveAnalytics();
654
+ };
655
+
656
+ const finishStageAnalytics = async (stage, payload = {}) => {
657
+ analyticsRun = deps.markStageFinished(analyticsRun, stage, payload);
658
+ await saveAnalytics();
659
+ };
660
+
661
+ const reuseStageAnalytics = async (stage, details = null) => {
662
+ if (analyticsRun?.stages?.[stage]?.executed) {
663
+ return;
664
+ }
665
+ analyticsRun = deps.markStageReused(analyticsRun, stage, details);
666
+ await saveAnalytics();
667
+ };
668
+
669
+ const keyframeSize = resolveKeyframeSize(projectConfig);
670
+
671
+ const previous = forceRestart ? null : await deps.readProjectRunState(project);
672
+ const resumeState = previous || null;
673
+
674
+ const seedArtifacts = resumeState?.artifacts || job.artifacts;
675
+ const seedSteps = resumeState?.steps || emptyStepState();
676
+ const traceBase = {
677
+ project,
678
+ projectDir,
679
+ jobId,
680
+ runId: analyticsRunId
681
+ };
682
+
683
+ const alignVisualPlanToAudioDuration = async () => {
684
+ let alignedJob = getJob(jobId);
685
+ const segmentDurationSec = DEFAULT_VIDEO_CONFIG.segmentDurationSec;
686
+
687
+ if (!alignedJob?.artifacts?.voiceoverPath) {
688
+ return alignedJob;
689
+ }
690
+
691
+ let audioDurationSec;
692
+ try {
693
+ audioDurationSec = await deps.probeMediaDurationSeconds(alignedJob.artifacts.voiceoverPath);
694
+ } catch (error) {
695
+ logInfo('voiceover_duration_probe_failed', {
696
+ jobId,
697
+ project,
698
+ voiceoverPath: alignedJob.artifacts.voiceoverPath,
699
+ error: error.message
700
+ });
701
+ return alignedJob;
702
+ }
703
+
704
+ const requiredSegments = deps.resolveSegmentCountFromAudioDuration(audioDurationSec, segmentDurationSec);
705
+ const requiredKeyframes = Math.max(2, requiredSegments + 1);
706
+ let alignedShots = Array.isArray(alignedJob.artifacts.shots) ? alignedJob.artifacts.shots : [];
707
+ const alignedTimeline = deps.buildFixedTimeline(requiredSegments, segmentDurationSec);
708
+
709
+ if (alignedShots.length !== requiredKeyframes) {
710
+ const shotsResult = await deps.generateShots(
711
+ alignedJob.artifacts.script,
712
+ {
713
+ shotCount: requiredKeyframes,
714
+ tone: alignedJob.artifacts.tone || 'neutral',
715
+ modelId: modelSelections[MODEL_CATEGORIES.textToText],
716
+ modelOptions: modelOptions[MODEL_CATEGORIES.textToText]
717
+ },
718
+ traceBase
719
+ );
720
+ alignedShots = shotsResult.shots;
721
+ }
722
+
723
+ const shotsChanged = JSON.stringify(alignedShots) !== JSON.stringify(alignedJob.artifacts.shots || []);
724
+ const timelineChanged = JSON.stringify(alignedTimeline) !== JSON.stringify(alignedJob.artifacts.timeline || []);
725
+ const durationChanged =
726
+ !Number.isFinite(alignedJob.artifacts.audioDurationSec) ||
727
+ Math.abs(alignedJob.artifacts.audioDurationSec - audioDurationSec) > 0.01;
728
+
729
+ if (!shotsChanged && !timelineChanged && !durationChanged) {
730
+ return alignedJob;
731
+ }
732
+
733
+ const nextArtifacts = {
734
+ ...alignedJob.artifacts,
735
+ audioDurationSec,
736
+ plannedShots: requiredSegments,
737
+ segmentDurationSec,
738
+ shots: alignedShots,
739
+ shotHashes: hashShots(alignedShots),
740
+ timeline: alignedTimeline
741
+ };
742
+
743
+ const nextSteps = {
744
+ ...alignedJob.steps
745
+ };
746
+
747
+ if (shotsChanged) {
748
+ nextSteps[JobStep.KEYFRAMES] = false;
749
+ nextSteps[JobStep.SEGMENTS] = false;
750
+ nextSteps[JobStep.COMPOSE] = false;
751
+ nextSteps[JobStep.ALIGN] = false;
752
+ nextSteps[JobStep.BURNIN] = false;
753
+ nextArtifacts.keyframeUrls = [];
754
+ nextArtifacts.keyframePaths = [];
755
+ nextArtifacts.segmentUrls = [];
756
+ nextArtifacts.segmentPaths = [];
757
+ nextArtifacts.finalBaseVideoPath = '';
758
+ nextArtifacts.finalVideoPath = '';
759
+ Object.assign(nextArtifacts, clearSubtitleArtifacts(nextArtifacts));
760
+ } else if (timelineChanged) {
761
+ nextSteps[JobStep.SEGMENTS] = false;
762
+ nextSteps[JobStep.COMPOSE] = false;
763
+ nextSteps[JobStep.ALIGN] = false;
764
+ nextSteps[JobStep.BURNIN] = false;
765
+ nextArtifacts.segmentUrls = [];
766
+ nextArtifacts.segmentPaths = [];
767
+ nextArtifacts.finalBaseVideoPath = '';
768
+ nextArtifacts.finalVideoPath = '';
769
+ Object.assign(nextArtifacts, clearSubtitleArtifacts(nextArtifacts));
770
+ }
771
+
772
+ if (shotsChanged) {
773
+ await persistTextAssets(
774
+ projectDir,
775
+ nextArtifacts.script || '',
776
+ alignedShots,
777
+ nextArtifacts.tone || 'neutral'
778
+ );
779
+ }
780
+
781
+ updateJob(jobId, {
782
+ artifacts: nextArtifacts,
783
+ steps: nextSteps
784
+ });
785
+ await persistCheckpoint(getJob(jobId), null, deps);
786
+ alignedJob = getJob(jobId);
787
+ return alignedJob;
788
+ };
789
+
790
+ const storyHash = hashText(safeStory);
791
+ const scriptAsset = await readScriptAsset(projectDir);
792
+ const scriptSourceStoryHash = seedArtifacts.scriptSourceStoryHash || '';
793
+ const hasLegacyState = !seedArtifacts.scriptSourceStoryHash && !!seedArtifacts.script;
794
+
795
+ if (hasLegacyState || (scriptSourceStoryHash && scriptSourceStoryHash !== storyHash)) {
796
+ seedSteps[JobStep.SCRIPT] = false;
797
+ seedSteps[JobStep.VOICE] = false;
798
+ seedSteps[JobStep.KEYFRAMES] = false;
799
+ seedSteps[JobStep.SEGMENTS] = false;
800
+ seedSteps[JobStep.COMPOSE] = false;
801
+ seedSteps[JobStep.ALIGN] = false;
802
+ seedSteps[JobStep.BURNIN] = false;
803
+ seedArtifacts.script = '';
804
+ seedArtifacts.tone = '';
805
+ seedArtifacts.shots = [];
806
+ seedArtifacts.timeline = [];
807
+ seedArtifacts.voiceoverUrl = '';
808
+ seedArtifacts.voiceoverPath = '';
809
+ seedArtifacts.keyframeUrls = [];
810
+ seedArtifacts.keyframePaths = [];
811
+ seedArtifacts.segmentUrls = [];
812
+ seedArtifacts.segmentPaths = [];
813
+ seedArtifacts.finalBaseVideoPath = '';
814
+ seedArtifacts.finalVideoPath = '';
815
+ Object.assign(seedArtifacts, clearSubtitleArtifacts(seedArtifacts));
816
+ seedArtifacts.scriptHash = '';
817
+ seedArtifacts.shotHashes = [];
818
+ seedArtifacts.scriptSourceStoryHash = '';
819
+ }
820
+
821
+ if (
822
+ scriptAsset &&
823
+ seedArtifacts.scriptHash &&
824
+ hashText(scriptAsset.script) !== seedArtifacts.scriptHash
825
+ ) {
826
+ seedArtifacts.script = scriptAsset.script;
827
+ seedArtifacts.scriptHash = hashText(scriptAsset.script);
828
+ seedSteps[JobStep.VOICE] = false;
829
+ seedSteps[JobStep.KEYFRAMES] = false;
830
+ seedSteps[JobStep.SEGMENTS] = false;
831
+ seedSteps[JobStep.COMPOSE] = false;
832
+ seedSteps[JobStep.ALIGN] = false;
833
+ seedSteps[JobStep.BURNIN] = false;
834
+ seedArtifacts.shots = [];
835
+ seedArtifacts.shotHashes = [];
836
+ seedArtifacts.timeline = [];
837
+ seedArtifacts.voiceoverUrl = '';
838
+ seedArtifacts.voiceoverPath = '';
839
+ seedArtifacts.keyframeUrls = [];
840
+ seedArtifacts.keyframePaths = [];
841
+ seedArtifacts.segmentUrls = [];
842
+ seedArtifacts.segmentPaths = [];
843
+ seedArtifacts.finalBaseVideoPath = '';
844
+ seedArtifacts.finalVideoPath = '';
845
+ Object.assign(seedArtifacts, clearSubtitleArtifacts(seedArtifacts));
846
+ }
847
+
848
+ const shotHashesFromAsset = scriptAsset ? hashShots(scriptAsset.shots) : [];
849
+ const changedShotIndexes =
850
+ scriptAsset && seedArtifacts.shotHashes?.length
851
+ ? findChangedShotIndexes(seedArtifacts.shotHashes, shotHashesFromAsset)
852
+ : [];
853
+
854
+ if (scriptAsset && changedShotIndexes.length > 0) {
855
+ seedArtifacts.shots = scriptAsset.shots;
856
+ seedArtifacts.shotHashes = shotHashesFromAsset;
857
+ seedSteps[JobStep.COMPOSE] = false;
858
+ seedSteps[JobStep.ALIGN] = false;
859
+ seedSteps[JobStep.BURNIN] = false;
860
+ seedArtifacts.finalBaseVideoPath = '';
861
+ seedArtifacts.finalVideoPath = '';
862
+ Object.assign(seedArtifacts, clearSubtitleArtifacts(seedArtifacts));
863
+ }
864
+
865
+ if (seedArtifacts.aspectRatio && seedArtifacts.aspectRatio !== projectConfig.aspectRatio) {
866
+ seedSteps[JobStep.KEYFRAMES] = false;
867
+ seedSteps[JobStep.SEGMENTS] = false;
868
+ seedSteps[JobStep.COMPOSE] = false;
869
+ seedSteps[JobStep.ALIGN] = false;
870
+ seedSteps[JobStep.BURNIN] = false;
871
+ seedArtifacts.keyframeUrls = [];
872
+ seedArtifacts.keyframePaths = [];
873
+ seedArtifacts.segmentUrls = [];
874
+ seedArtifacts.segmentPaths = [];
875
+ seedArtifacts.finalBaseVideoPath = '';
876
+ seedArtifacts.finalVideoPath = '';
877
+ Object.assign(seedArtifacts, clearSubtitleArtifacts(seedArtifacts));
878
+ }
879
+
880
+ if ((seedArtifacts.finalDurationMode || 'match_audio') !== projectConfig.finalDurationMode) {
881
+ seedSteps[JobStep.COMPOSE] = false;
882
+ seedSteps[JobStep.ALIGN] = false;
883
+ seedSteps[JobStep.BURNIN] = false;
884
+ seedArtifacts.finalBaseVideoPath = '';
885
+ seedArtifacts.finalVideoPath = '';
886
+ Object.assign(seedArtifacts, clearSubtitleArtifacts(seedArtifacts));
887
+ }
888
+
889
+ const previousTargetDuration = Number.isInteger(seedArtifacts.targetDurationSec)
890
+ ? seedArtifacts.targetDurationSec
891
+ : DEFAULT_VIDEO_CONFIG.durationSec;
892
+ const previousModelSelections = resolveProjectModelSelections(seedArtifacts.modelSelections);
893
+ const previousModelOptions = resolveProjectModelOptions(seedArtifacts.modelOptions, previousModelSelections);
894
+ const changedModelOptionCategories = Object.values(MODEL_CATEGORIES).filter(
895
+ (category) => JSON.stringify(previousModelOptions[category]) !== JSON.stringify(modelOptions[category])
896
+ );
897
+
898
+ const resetFromScript = () => {
899
+ seedSteps[JobStep.SCRIPT] = false;
900
+ seedSteps[JobStep.VOICE] = false;
901
+ seedSteps[JobStep.KEYFRAMES] = false;
902
+ seedSteps[JobStep.SEGMENTS] = false;
903
+ seedSteps[JobStep.COMPOSE] = false;
904
+ seedSteps[JobStep.ALIGN] = false;
905
+ seedSteps[JobStep.BURNIN] = false;
906
+ seedArtifacts.script = '';
907
+ seedArtifacts.tone = '';
908
+ seedArtifacts.shots = [];
909
+ seedArtifacts.timeline = [];
910
+ seedArtifacts.voiceoverUrl = '';
911
+ seedArtifacts.voiceoverPath = '';
912
+ seedArtifacts.keyframeUrls = [];
913
+ seedArtifacts.keyframePaths = [];
914
+ seedArtifacts.segmentUrls = [];
915
+ seedArtifacts.segmentPaths = [];
916
+ seedArtifacts.finalBaseVideoPath = '';
917
+ seedArtifacts.finalVideoPath = '';
918
+ Object.assign(seedArtifacts, clearSubtitleArtifacts(seedArtifacts));
919
+ seedArtifacts.scriptHash = '';
920
+ seedArtifacts.shotHashes = [];
921
+ seedArtifacts.scriptSourceStoryHash = '';
922
+ };
923
+
924
+ const resetFromVoice = () => {
925
+ seedSteps[JobStep.VOICE] = false;
926
+ seedSteps[JobStep.KEYFRAMES] = false;
927
+ seedSteps[JobStep.SEGMENTS] = false;
928
+ seedSteps[JobStep.COMPOSE] = false;
929
+ seedSteps[JobStep.ALIGN] = false;
930
+ seedSteps[JobStep.BURNIN] = false;
931
+ seedArtifacts.timeline = [];
932
+ seedArtifacts.voiceoverUrl = '';
933
+ seedArtifacts.voiceoverPath = '';
934
+ seedArtifacts.keyframeUrls = [];
935
+ seedArtifacts.keyframePaths = [];
936
+ seedArtifacts.segmentUrls = [];
937
+ seedArtifacts.segmentPaths = [];
938
+ seedArtifacts.finalBaseVideoPath = '';
939
+ seedArtifacts.finalVideoPath = '';
940
+ Object.assign(seedArtifacts, clearSubtitleArtifacts(seedArtifacts));
941
+ };
942
+
943
+ const resetFromKeyframes = () => {
944
+ seedSteps[JobStep.KEYFRAMES] = false;
945
+ seedSteps[JobStep.SEGMENTS] = false;
946
+ seedSteps[JobStep.COMPOSE] = false;
947
+ seedSteps[JobStep.ALIGN] = false;
948
+ seedSteps[JobStep.BURNIN] = false;
949
+ seedArtifacts.keyframeUrls = [];
950
+ seedArtifacts.keyframePaths = [];
951
+ seedArtifacts.segmentUrls = [];
952
+ seedArtifacts.segmentPaths = [];
953
+ seedArtifacts.finalBaseVideoPath = '';
954
+ seedArtifacts.finalVideoPath = '';
955
+ Object.assign(seedArtifacts, clearSubtitleArtifacts(seedArtifacts));
956
+ };
957
+
958
+ const resetFromSegments = () => {
959
+ seedSteps[JobStep.SEGMENTS] = false;
960
+ seedSteps[JobStep.COMPOSE] = false;
961
+ seedSteps[JobStep.ALIGN] = false;
962
+ seedSteps[JobStep.BURNIN] = false;
963
+ seedArtifacts.segmentUrls = [];
964
+ seedArtifacts.segmentPaths = [];
965
+ seedArtifacts.finalBaseVideoPath = '';
966
+ seedArtifacts.finalVideoPath = '';
967
+ Object.assign(seedArtifacts, clearSubtitleArtifacts(seedArtifacts));
968
+ };
969
+
970
+ if (previousTargetDuration !== targetDurationSec) {
971
+ resetFromScript();
972
+ }
973
+
974
+ if (JSON.stringify(previousModelSelections) !== JSON.stringify(modelSelections)) {
975
+ resetFromScript();
976
+ }
977
+
978
+ if (changedModelOptionCategories.length > 1) {
979
+ resetFromScript();
980
+ } else if (changedModelOptionCategories[0] === MODEL_CATEGORIES.textToText) {
981
+ resetFromScript();
982
+ } else if (changedModelOptionCategories[0] === MODEL_CATEGORIES.textToSpeech) {
983
+ resetFromVoice();
984
+ } else if (changedModelOptionCategories[0] === MODEL_CATEGORIES.textToImage) {
985
+ resetFromKeyframes();
986
+ } else if (changedModelOptionCategories[0] === MODEL_CATEGORIES.imageTextToVideo) {
987
+ resetFromSegments();
988
+ }
989
+
990
+ if (
991
+ (seedArtifacts.keyframeSizeKey && seedArtifacts.keyframeSizeKey !== keyframeSize.key) ||
992
+ (!seedArtifacts.keyframeSizeKey && Number.isInteger(projectConfig.keyframeWidth) && Number.isInteger(projectConfig.keyframeHeight))
993
+ ) {
994
+ seedSteps[JobStep.KEYFRAMES] = false;
995
+ seedSteps[JobStep.SEGMENTS] = false;
996
+ seedSteps[JobStep.COMPOSE] = false;
997
+ seedSteps[JobStep.ALIGN] = false;
998
+ seedSteps[JobStep.BURNIN] = false;
999
+ seedArtifacts.keyframeUrls = [];
1000
+ seedArtifacts.keyframePaths = [];
1001
+ seedArtifacts.segmentUrls = [];
1002
+ seedArtifacts.segmentPaths = [];
1003
+ seedArtifacts.finalBaseVideoPath = '';
1004
+ seedArtifacts.finalVideoPath = '';
1005
+ Object.assign(seedArtifacts, clearSubtitleArtifacts(seedArtifacts));
1006
+ }
1007
+
1008
+ if ((seedArtifacts.renderSpecVersion || 0) !== DEFAULT_VIDEO_CONFIG.renderSpecVersion) {
1009
+ seedSteps[JobStep.KEYFRAMES] = false;
1010
+ seedSteps[JobStep.SEGMENTS] = false;
1011
+ seedSteps[JobStep.COMPOSE] = false;
1012
+ seedSteps[JobStep.ALIGN] = false;
1013
+ seedSteps[JobStep.BURNIN] = false;
1014
+ seedArtifacts.keyframeUrls = [];
1015
+ seedArtifacts.keyframePaths = [];
1016
+ seedArtifacts.segmentUrls = [];
1017
+ seedArtifacts.segmentPaths = [];
1018
+ seedArtifacts.finalBaseVideoPath = '';
1019
+ seedArtifacts.finalVideoPath = '';
1020
+ Object.assign(seedArtifacts, clearSubtitleArtifacts(seedArtifacts));
1021
+ }
1022
+
1023
+ if (JSON.stringify(seedArtifacts.subtitleOptions || {}) !== JSON.stringify(projectConfig.subtitleOptions || {})) {
1024
+ seedSteps[JobStep.ALIGN] = false;
1025
+ seedSteps[JobStep.BURNIN] = false;
1026
+ Object.assign(seedArtifacts, clearSubtitleArtifacts(seedArtifacts));
1027
+ }
1028
+
1029
+ const shouldArchiveCurrentAssets =
1030
+ forceRestart ||
1031
+ !seedSteps[JobStep.SCRIPT] ||
1032
+ !seedSteps[JobStep.VOICE] ||
1033
+ !seedSteps[JobStep.KEYFRAMES] ||
1034
+ !seedSteps[JobStep.SEGMENTS];
1035
+
1036
+ if (shouldArchiveCurrentAssets) {
1037
+ const archivedSnapshot = await deps.archiveProjectAssets(projectDir);
1038
+ if (archivedSnapshot) {
1039
+ seedArtifacts.voiceoverPath = '';
1040
+ seedArtifacts.keyframePaths = [];
1041
+ seedArtifacts.segmentPaths = [];
1042
+ seedArtifacts.finalBaseVideoPath = '';
1043
+ seedArtifacts.finalVideoPath = '';
1044
+ Object.assign(seedArtifacts, clearSubtitleArtifacts(seedArtifacts));
1045
+ logInfo('assets_archived_to_snapshots', { project, archivedSnapshot });
1046
+ }
1047
+ }
1048
+
1049
+ updateJob(jobId, {
1050
+ payload: {
1051
+ ...job.payload,
1052
+ project,
1053
+ projectConfig,
1054
+ changedShotIndexes,
1055
+ activeSegmentIndex: null
1056
+ },
1057
+ status: JobStatus.RUNNING,
1058
+ error: null,
1059
+ artifacts: {
1060
+ ...seedArtifacts,
1061
+ modelSelections,
1062
+ modelOptions,
1063
+ renderSpecVersion: DEFAULT_VIDEO_CONFIG.renderSpecVersion,
1064
+ keyframeSizeKey: keyframeSize.key,
1065
+ aspectRatio: projectConfig.aspectRatio,
1066
+ finalDurationMode: projectConfig.finalDurationMode,
1067
+ subtitleOptions: projectConfig.subtitleOptions,
1068
+ targetDurationSec,
1069
+ segmentDurationSec: DEFAULT_VIDEO_CONFIG.segmentDurationSec,
1070
+ plannedShots
1071
+ },
1072
+ steps: seedSteps
1073
+ });
1074
+
1075
+ await persistCheckpoint(getJob(jobId), null, deps);
1076
+
1077
+ if (resumeState) {
1078
+ logInfo('job_resumed', { jobId, project, steps: resumeState.steps });
1079
+ }
1080
+
1081
+ let currentJob = getJob(jobId);
1082
+
1083
+ if (!currentJob.steps[JobStep.SCRIPT]) {
1084
+ await startStageAnalytics(JobStep.SCRIPT, { mode: 'execute' });
1085
+ const scriptResult = await deps.generateScript(
1086
+ safeStory,
1087
+ {
1088
+ targetDurationSec,
1089
+ modelId: modelSelections[MODEL_CATEGORIES.textToText],
1090
+ modelOptions: modelOptions[MODEL_CATEGORIES.textToText]
1091
+ },
1092
+ traceBase
1093
+ );
1094
+ await persistTextAssets(projectDir, scriptResult.script, [], scriptResult.tone);
1095
+ updateJob(jobId, {
1096
+ artifacts: {
1097
+ ...currentJob.artifacts,
1098
+ script: scriptResult.script,
1099
+ shots: [],
1100
+ tone: scriptResult.tone,
1101
+ scriptWordCount: scriptResult.scriptWordCount || null,
1102
+ targetWordCount: scriptResult.targetWordCount || null,
1103
+ storyHash,
1104
+ scriptSourceStoryHash: storyHash,
1105
+ targetDurationSec,
1106
+ segmentDurationSec: DEFAULT_VIDEO_CONFIG.segmentDurationSec,
1107
+ finalDurationMode: projectConfig.finalDurationMode,
1108
+ plannedShots,
1109
+ scriptHash: hashText(scriptResult.script),
1110
+ shotHashes: []
1111
+ },
1112
+ steps: {
1113
+ ...currentJob.steps,
1114
+ [JobStep.SCRIPT]: true
1115
+ }
1116
+ });
1117
+ await persistCheckpoint(getJob(jobId), null, deps);
1118
+ await finishStageAnalytics(JobStep.SCRIPT, { executed: true, status: 'succeeded' });
1119
+ currentJob = getJob(jobId);
1120
+ } else if (currentJob.artifacts.script) {
1121
+ await persistTextAssets(
1122
+ projectDir,
1123
+ currentJob.artifacts.script,
1124
+ currentJob.artifacts.shots,
1125
+ currentJob.artifacts.tone || 'neutral'
1126
+ );
1127
+
1128
+ const scriptHash = hashText(currentJob.artifacts.script);
1129
+ const shotHashes = hashShots(currentJob.artifacts.shots);
1130
+ if (scriptHash !== currentJob.artifacts.scriptHash || JSON.stringify(shotHashes) !== JSON.stringify(currentJob.artifacts.shotHashes || [])) {
1131
+ updateJob(jobId, {
1132
+ artifacts: {
1133
+ ...currentJob.artifacts,
1134
+ scriptHash,
1135
+ shotHashes
1136
+ }
1137
+ });
1138
+ await persistCheckpoint(getJob(jobId), null, deps);
1139
+ currentJob = getJob(jobId);
1140
+ }
1141
+ await reuseStageAnalytics(JobStep.SCRIPT, { mode: 'reused' });
1142
+ } else {
1143
+ await reuseStageAnalytics(JobStep.SCRIPT, { mode: 'reused' });
1144
+ }
1145
+
1146
+ if (!currentJob.steps[JobStep.VOICE]) {
1147
+ await startStageAnalytics(JobStep.VOICE, { mode: 'execute' });
1148
+ const voiceResult = await deps.generateVoiceover(
1149
+ currentJob.artifacts.script,
1150
+ {
1151
+ shotsCount: Math.max(1, (currentJob.artifacts.shots.length || plannedShots) - 1),
1152
+ segmentDurationSec: DEFAULT_VIDEO_CONFIG.segmentDurationSec,
1153
+ targetDurationSec,
1154
+ modelId: modelSelections[MODEL_CATEGORIES.textToSpeech],
1155
+ modelOptions: modelOptions[MODEL_CATEGORIES.textToSpeech]
1156
+ },
1157
+ traceBase
1158
+ );
1159
+ const voiceoverPath = await deps.persistVoiceover(projectDir, voiceResult.voiceoverUrl);
1160
+ updateJob(jobId, {
1161
+ artifacts: {
1162
+ ...currentJob.artifacts,
1163
+ timeline: voiceResult.timeline,
1164
+ voiceoverUrl: voiceResult.voiceoverUrl,
1165
+ voiceoverPath,
1166
+ ttsPlan: voiceResult.ttsPlan || null
1167
+ },
1168
+ steps: {
1169
+ ...currentJob.steps,
1170
+ [JobStep.VOICE]: true
1171
+ }
1172
+ });
1173
+ await persistCheckpoint(getJob(jobId), null, deps);
1174
+ await finishStageAnalytics(JobStep.VOICE, { executed: true, status: 'succeeded' });
1175
+ currentJob = getJob(jobId);
1176
+ } else if (currentJob.artifacts.voiceoverUrl && !currentJob.artifacts.voiceoverPath) {
1177
+ const voiceoverPath = await deps.persistVoiceover(projectDir, currentJob.artifacts.voiceoverUrl);
1178
+ updateJob(jobId, {
1179
+ artifacts: {
1180
+ ...currentJob.artifacts,
1181
+ voiceoverPath
1182
+ }
1183
+ });
1184
+ await persistCheckpoint(getJob(jobId), null, deps);
1185
+ currentJob = getJob(jobId);
1186
+ await reuseStageAnalytics(JobStep.VOICE, { mode: 'reused_with_repair' });
1187
+ } else {
1188
+ await reuseStageAnalytics(JobStep.VOICE, { mode: 'reused' });
1189
+ }
1190
+
1191
+ currentJob = await alignVisualPlanToAudioDuration();
1192
+
1193
+ if (currentJob.payload.changedShotIndexes?.length > 0 && currentJob.artifacts.keyframeUrls.length > 0) {
1194
+ await startStageAnalytics(JobStep.KEYFRAMES, { mode: 'partial_regen' });
1195
+ await startStageAnalytics(JobStep.SEGMENTS, { mode: 'partial_regen' });
1196
+ const changed = currentJob.payload.changedShotIndexes;
1197
+ const keyframeUrls = [...currentJob.artifacts.keyframeUrls];
1198
+ const keyframePaths = [...(currentJob.artifacts.keyframePaths || [])];
1199
+
1200
+ updateJob(jobId, {
1201
+ payload: {
1202
+ ...currentJob.payload,
1203
+ activeStep: JobStep.KEYFRAMES,
1204
+ activeSegmentIndex: null
1205
+ }
1206
+ });
1207
+ currentJob = getJob(jobId);
1208
+
1209
+ for (const shotIndex of changed) {
1210
+ if (shotIndex < 0 || shotIndex >= currentJob.artifacts.shots.length) {
1211
+ continue;
1212
+ }
1213
+ const keyframeUrl = await deps.generateKeyframe(
1214
+ currentJob.artifacts.shots[shotIndex],
1215
+ currentJob.artifacts.tone || 'neutral',
1216
+ projectConfig.aspectRatio,
1217
+ shotIndex,
1218
+ traceBase,
1219
+ keyframeSize,
1220
+ {
1221
+ modelId: modelSelections[MODEL_CATEGORIES.textToImage],
1222
+ modelOptions: modelOptions[MODEL_CATEGORIES.textToImage]
1223
+ }
1224
+ );
1225
+ keyframeUrls[shotIndex] = keyframeUrl;
1226
+ keyframePaths[shotIndex] = await deps.persistKeyframe(projectDir, keyframeUrl, shotIndex);
1227
+ }
1228
+
1229
+ const totalShots = currentJob.artifacts.shots.length;
1230
+ const totalSegments = Math.max(0, totalShots - 1);
1231
+ const affectedSegmentIndexes = collectAffectedSegmentIndexes(changed, totalShots);
1232
+
1233
+ // If assets were archived before this run, unchanged slots may still have URLs but no local files.
1234
+ for (let shotIndex = 0; shotIndex < totalShots; shotIndex += 1) {
1235
+ if (!keyframeUrls[shotIndex]) {
1236
+ continue;
1237
+ }
1238
+
1239
+ const hasPath = Boolean(keyframePaths[shotIndex]);
1240
+ if (!hasPath) {
1241
+ keyframePaths[shotIndex] = await deps.persistKeyframe(projectDir, keyframeUrls[shotIndex], shotIndex);
1242
+ }
1243
+ }
1244
+
1245
+ const segmentUrls = [...(currentJob.artifacts.segmentUrls || [])];
1246
+ const segmentPaths = [...(currentJob.artifacts.segmentPaths || [])];
1247
+
1248
+ updateJob(jobId, {
1249
+ payload: {
1250
+ ...currentJob.payload,
1251
+ activeStep: JobStep.SEGMENTS,
1252
+ activeSegmentIndex: null
1253
+ }
1254
+ });
1255
+ currentJob = getJob(jobId);
1256
+
1257
+ for (const segmentIndex of affectedSegmentIndexes) {
1258
+ if (segmentIndex < 0 || segmentIndex >= totalSegments) {
1259
+ continue;
1260
+ }
1261
+ updateJob(jobId, {
1262
+ payload: {
1263
+ ...getJob(jobId).payload,
1264
+ activeStep: JobStep.SEGMENTS,
1265
+ activeSegmentIndex: segmentIndex
1266
+ }
1267
+ });
1268
+ currentJob = getJob(jobId);
1269
+ const segmentUrl = await deps.generateVideoSegmentAtIndex(
1270
+ segmentIndex,
1271
+ keyframeUrls,
1272
+ currentJob.artifacts.timeline,
1273
+ currentJob.artifacts.shots,
1274
+ projectConfig.aspectRatio,
1275
+ traceBase,
1276
+ {
1277
+ modelId: modelSelections[MODEL_CATEGORIES.imageTextToVideo],
1278
+ modelOptions: modelOptions[MODEL_CATEGORIES.imageTextToVideo]
1279
+ }
1280
+ );
1281
+ segmentUrls[segmentIndex] = segmentUrl;
1282
+ segmentPaths[segmentIndex] = await deps.persistSegment(projectDir, segmentUrl, segmentIndex);
1283
+ }
1284
+
1285
+ for (let segmentIndex = 0; segmentIndex < totalSegments; segmentIndex += 1) {
1286
+ if (!segmentUrls[segmentIndex]) {
1287
+ continue;
1288
+ }
1289
+
1290
+ const hasPath = Boolean(segmentPaths[segmentIndex]);
1291
+ if (!hasPath) {
1292
+ segmentPaths[segmentIndex] = await deps.persistSegment(projectDir, segmentUrls[segmentIndex], segmentIndex);
1293
+ }
1294
+ }
1295
+
1296
+ updateJob(jobId, {
1297
+ payload: {
1298
+ ...currentJob.payload,
1299
+ changedShotIndexes: [],
1300
+ activeStep: null,
1301
+ activeSegmentIndex: null
1302
+ },
1303
+ artifacts: {
1304
+ ...currentJob.artifacts,
1305
+ keyframeUrls,
1306
+ keyframePaths,
1307
+ segmentUrls,
1308
+ segmentPaths,
1309
+ shotHashes: hashShots(currentJob.artifacts.shots),
1310
+ finalBaseVideoPath: '',
1311
+ finalVideoPath: ''
1312
+ },
1313
+ steps: {
1314
+ ...currentJob.steps,
1315
+ [JobStep.KEYFRAMES]: true,
1316
+ [JobStep.SEGMENTS]: true,
1317
+ [JobStep.COMPOSE]: false,
1318
+ [JobStep.ALIGN]: false,
1319
+ [JobStep.BURNIN]: false
1320
+ }
1321
+ });
1322
+ updateJob(jobId, {
1323
+ artifacts: clearSubtitleArtifacts(getJob(jobId).artifacts)
1324
+ });
1325
+ await persistCheckpoint(getJob(jobId), null, deps);
1326
+ await finishStageAnalytics(JobStep.KEYFRAMES, { executed: true, status: 'succeeded', details: { mode: 'partial_regen', changedShots: changed } });
1327
+ await finishStageAnalytics(JobStep.SEGMENTS, { executed: true, status: 'succeeded', details: { mode: 'partial_regen' } });
1328
+ currentJob = getJob(jobId);
1329
+ }
1330
+
1331
+ if (!currentJob.steps[JobStep.KEYFRAMES]) {
1332
+ await startStageAnalytics(JobStep.KEYFRAMES, { mode: 'execute' });
1333
+ const keyframeUrls = [...(currentJob.artifacts.keyframeUrls || [])];
1334
+ const keyframePaths = [...(currentJob.artifacts.keyframePaths || [])];
1335
+ for (let shotIndex = 0; shotIndex < currentJob.artifacts.shots.length; shotIndex += 1) {
1336
+ const hasUrl = Boolean(keyframeUrls[shotIndex]);
1337
+ let hasPath = Boolean(keyframePaths[shotIndex]);
1338
+
1339
+ if (hasPath && !(await fileExists(keyframePaths[shotIndex]))) {
1340
+ keyframePaths[shotIndex] = '';
1341
+ hasPath = false;
1342
+ }
1343
+
1344
+ if (hasUrl && hasPath) {
1345
+ continue;
1346
+ }
1347
+
1348
+ if (!hasUrl) {
1349
+ keyframeUrls[shotIndex] = await deps.generateKeyframe(
1350
+ currentJob.artifacts.shots[shotIndex],
1351
+ currentJob.artifacts.tone || 'neutral',
1352
+ projectConfig.aspectRatio,
1353
+ shotIndex,
1354
+ traceBase,
1355
+ keyframeSize,
1356
+ {
1357
+ modelId: modelSelections[MODEL_CATEGORIES.textToImage],
1358
+ modelOptions: modelOptions[MODEL_CATEGORIES.textToImage]
1359
+ }
1360
+ );
1361
+ }
1362
+
1363
+ keyframePaths[shotIndex] = await deps.persistKeyframe(projectDir, keyframeUrls[shotIndex], shotIndex);
1364
+
1365
+ updateJob(jobId, {
1366
+ artifacts: {
1367
+ ...currentJob.artifacts,
1368
+ keyframeUrls,
1369
+ keyframePaths
1370
+ }
1371
+ });
1372
+ await persistCheckpoint(getJob(jobId), null, deps);
1373
+ currentJob = getJob(jobId);
1374
+ }
1375
+
1376
+ updateJob(jobId, {
1377
+ artifacts: {
1378
+ ...currentJob.artifacts,
1379
+ keyframeUrls,
1380
+ keyframePaths
1381
+ },
1382
+ steps: {
1383
+ ...currentJob.steps,
1384
+ [JobStep.KEYFRAMES]: true
1385
+ }
1386
+ });
1387
+ await persistCheckpoint(getJob(jobId), null, deps);
1388
+ await finishStageAnalytics(JobStep.KEYFRAMES, { executed: true, status: 'succeeded' });
1389
+ currentJob = getJob(jobId);
1390
+ } else if (
1391
+ currentJob.artifacts.keyframeUrls.length > 0 &&
1392
+ (!currentJob.artifacts.keyframePaths || currentJob.artifacts.keyframePaths.length === 0)
1393
+ ) {
1394
+ const keyframePaths = await deps.persistKeyframes(projectDir, currentJob.artifacts.keyframeUrls);
1395
+ updateJob(jobId, {
1396
+ artifacts: {
1397
+ ...currentJob.artifacts,
1398
+ keyframePaths
1399
+ }
1400
+ });
1401
+ await persistCheckpoint(getJob(jobId), null, deps);
1402
+ currentJob = getJob(jobId);
1403
+ await reuseStageAnalytics(JobStep.KEYFRAMES, { mode: 'reused_with_repair' });
1404
+ } else {
1405
+ await reuseStageAnalytics(JobStep.KEYFRAMES, { mode: 'reused' });
1406
+ }
1407
+
1408
+ if (!currentJob.steps[JobStep.SEGMENTS]) {
1409
+ await startStageAnalytics(JobStep.SEGMENTS, { mode: 'execute' });
1410
+ const segmentUrls = [...(currentJob.artifacts.segmentUrls || [])];
1411
+ const segmentPaths = [...(currentJob.artifacts.segmentPaths || [])];
1412
+ updateJob(jobId, {
1413
+ payload: {
1414
+ ...currentJob.payload,
1415
+ activeStep: JobStep.SEGMENTS,
1416
+ activeSegmentIndex: null
1417
+ }
1418
+ });
1419
+ currentJob = getJob(jobId);
1420
+ for (let segmentIndex = 0; segmentIndex < Math.max(0, currentJob.artifacts.shots.length - 1); segmentIndex += 1) {
1421
+ const hasUrl = Boolean(segmentUrls[segmentIndex]);
1422
+ let hasPath = Boolean(segmentPaths[segmentIndex]);
1423
+
1424
+ if (hasPath && !(await fileExists(segmentPaths[segmentIndex]))) {
1425
+ segmentPaths[segmentIndex] = '';
1426
+ hasPath = false;
1427
+ }
1428
+
1429
+ if (hasUrl && hasPath) {
1430
+ continue;
1431
+ }
1432
+
1433
+ if (!hasUrl) {
1434
+ updateJob(jobId, {
1435
+ payload: {
1436
+ ...getJob(jobId).payload,
1437
+ activeStep: JobStep.SEGMENTS,
1438
+ activeSegmentIndex: segmentIndex
1439
+ }
1440
+ });
1441
+ currentJob = getJob(jobId);
1442
+ segmentUrls[segmentIndex] = await deps.generateVideoSegmentAtIndex(
1443
+ segmentIndex,
1444
+ currentJob.artifacts.keyframeUrls,
1445
+ currentJob.artifacts.timeline,
1446
+ currentJob.artifacts.shots,
1447
+ projectConfig.aspectRatio,
1448
+ traceBase,
1449
+ {
1450
+ modelId: modelSelections[MODEL_CATEGORIES.imageTextToVideo],
1451
+ modelOptions: modelOptions[MODEL_CATEGORIES.imageTextToVideo]
1452
+ }
1453
+ );
1454
+ }
1455
+
1456
+ segmentPaths[segmentIndex] = await deps.persistSegment(projectDir, segmentUrls[segmentIndex], segmentIndex);
1457
+
1458
+ updateJob(jobId, {
1459
+ artifacts: {
1460
+ ...currentJob.artifacts,
1461
+ segmentUrls,
1462
+ segmentPaths
1463
+ }
1464
+ });
1465
+ await persistCheckpoint(getJob(jobId), null, deps);
1466
+ currentJob = getJob(jobId);
1467
+ }
1468
+
1469
+ updateJob(jobId, {
1470
+ payload: {
1471
+ ...currentJob.payload,
1472
+ activeStep: null,
1473
+ activeSegmentIndex: null
1474
+ },
1475
+ artifacts: {
1476
+ ...currentJob.artifacts,
1477
+ segmentUrls,
1478
+ segmentPaths
1479
+ },
1480
+ steps: {
1481
+ ...currentJob.steps,
1482
+ [JobStep.SEGMENTS]: true
1483
+ }
1484
+ });
1485
+ await persistCheckpoint(getJob(jobId), null, deps);
1486
+ await finishStageAnalytics(JobStep.SEGMENTS, { executed: true, status: 'succeeded' });
1487
+ currentJob = getJob(jobId);
1488
+ } else if (
1489
+ currentJob.artifacts.segmentUrls.length > 0 &&
1490
+ (!currentJob.artifacts.segmentPaths || currentJob.artifacts.segmentPaths.length === 0)
1491
+ ) {
1492
+ const segmentPaths = await deps.persistSegments(projectDir, currentJob.artifacts.segmentUrls);
1493
+ updateJob(jobId, {
1494
+ artifacts: {
1495
+ ...currentJob.artifacts,
1496
+ segmentPaths
1497
+ }
1498
+ });
1499
+ await persistCheckpoint(getJob(jobId), null, deps);
1500
+ currentJob = getJob(jobId);
1501
+ await reuseStageAnalytics(JobStep.SEGMENTS, { mode: 'reused_with_repair' });
1502
+ } else {
1503
+ await reuseStageAnalytics(JobStep.SEGMENTS, { mode: 'reused' });
1504
+ }
1505
+
1506
+ if (!currentJob.steps[JobStep.COMPOSE]) {
1507
+ await startStageAnalytics(JobStep.COMPOSE, { mode: 'execute' });
1508
+ const composed = await deps.composeFinalVideo({
1509
+ projectDir,
1510
+ segmentUrls: currentJob.artifacts.segmentUrls,
1511
+ segmentPaths: currentJob.artifacts.segmentPaths,
1512
+ voiceoverPath: currentJob.artifacts.voiceoverPath,
1513
+ voiceoverUrl: currentJob.artifacts.voiceoverUrl,
1514
+ keyframePaths: currentJob.artifacts.keyframePaths,
1515
+ finalDurationMode: projectConfig.finalDurationMode
1516
+ });
1517
+
1518
+ updateJob(jobId, {
1519
+ artifacts: {
1520
+ ...currentJob.artifacts,
1521
+ finalBaseVideoPath: composed.finalVideoPath,
1522
+ finalVideoPath: composed.finalVideoPath,
1523
+ voiceoverPath: composed.voiceoverPath,
1524
+ keyframePaths: composed.keyframePaths,
1525
+ segmentPaths: composed.segmentPaths,
1526
+ scriptHash: hashText(currentJob.artifacts.script),
1527
+ shotHashes: hashShots(currentJob.artifacts.shots)
1528
+ },
1529
+ steps: {
1530
+ ...currentJob.steps,
1531
+ [JobStep.COMPOSE]: true,
1532
+ [JobStep.ALIGN]: false,
1533
+ [JobStep.BURNIN]: false
1534
+ }
1535
+ });
1536
+ updateJob(jobId, {
1537
+ artifacts: clearSubtitleArtifacts(getJob(jobId).artifacts)
1538
+ });
1539
+ await persistCheckpoint(getJob(jobId), null, deps);
1540
+ await finishStageAnalytics(JobStep.COMPOSE, { executed: true, status: 'succeeded' });
1541
+ currentJob = getJob(jobId);
1542
+ } else {
1543
+ if (!currentJob.artifacts.finalBaseVideoPath && currentJob.artifacts.finalVideoPath) {
1544
+ updateJob(jobId, {
1545
+ artifacts: {
1546
+ ...currentJob.artifacts,
1547
+ finalBaseVideoPath: currentJob.artifacts.finalVideoPath
1548
+ }
1549
+ });
1550
+ await persistCheckpoint(getJob(jobId), null, deps);
1551
+ currentJob = getJob(jobId);
1552
+ }
1553
+ await reuseStageAnalytics(JobStep.COMPOSE, { mode: 'reused' });
1554
+ }
1555
+
1556
+ if (projectConfig.subtitleOptions?.enabled) {
1557
+ if (!currentJob.steps[JobStep.ALIGN]) {
1558
+ await startStageAnalytics(JobStep.ALIGN, { mode: 'execute' });
1559
+ const subtitleSourceVideo = currentJob.artifacts.finalBaseVideoPath || currentJob.artifacts.finalVideoPath;
1560
+ const durationForSubtitles = Number.isFinite(currentJob.artifacts.audioDurationSec)
1561
+ ? currentJob.artifacts.audioDurationSec
1562
+ : await deps.probeMediaDurationSeconds(subtitleSourceVideo);
1563
+
1564
+ const aligned = await deps.alignSubtitlesToVideo({
1565
+ projectDir,
1566
+ videoPath: subtitleSourceVideo,
1567
+ script: currentJob.artifacts.script,
1568
+ totalDurationSec: durationForSubtitles,
1569
+ subtitleOptions: projectConfig.subtitleOptions
1570
+ });
1571
+
1572
+ updateJob(jobId, {
1573
+ artifacts: {
1574
+ ...currentJob.artifacts,
1575
+ ...aligned
1576
+ },
1577
+ steps: {
1578
+ ...currentJob.steps,
1579
+ [JobStep.ALIGN]: true
1580
+ }
1581
+ });
1582
+ await persistCheckpoint(getJob(jobId), null, deps);
1583
+ await finishStageAnalytics(JobStep.ALIGN, { executed: true, status: 'succeeded' });
1584
+ currentJob = getJob(jobId);
1585
+ } else {
1586
+ await reuseStageAnalytics(JobStep.ALIGN, { mode: 'reused' });
1587
+ }
1588
+
1589
+ if (!currentJob.steps[JobStep.BURNIN]) {
1590
+ await startStageAnalytics(JobStep.BURNIN, { mode: 'execute' });
1591
+ const burned = await deps.burnInSubtitles({
1592
+ projectDir,
1593
+ videoPath: currentJob.artifacts.finalBaseVideoPath || currentJob.artifacts.finalVideoPath,
1594
+ subtitleAssPath: currentJob.artifacts.subtitleAssPath
1595
+ });
1596
+
1597
+ updateJob(jobId, {
1598
+ artifacts: {
1599
+ ...currentJob.artifacts,
1600
+ ...burned,
1601
+ finalVideoPath: burned.finalCaptionedVideoPath
1602
+ },
1603
+ steps: {
1604
+ ...currentJob.steps,
1605
+ [JobStep.BURNIN]: true
1606
+ }
1607
+ });
1608
+ await persistCheckpoint(getJob(jobId), null, deps);
1609
+ await finishStageAnalytics(JobStep.BURNIN, { executed: true, status: 'succeeded' });
1610
+ currentJob = getJob(jobId);
1611
+ } else {
1612
+ await reuseStageAnalytics(JobStep.BURNIN, { mode: 'reused' });
1613
+ }
1614
+ } else {
1615
+ updateJob(jobId, {
1616
+ artifacts: {
1617
+ ...clearSubtitleArtifacts(currentJob.artifacts),
1618
+ finalVideoPath: currentJob.artifacts.finalBaseVideoPath || currentJob.artifacts.finalVideoPath
1619
+ },
1620
+ steps: {
1621
+ ...currentJob.steps,
1622
+ [JobStep.ALIGN]: true,
1623
+ [JobStep.BURNIN]: true
1624
+ }
1625
+ });
1626
+ await persistCheckpoint(getJob(jobId), null, deps);
1627
+ await reuseStageAnalytics(JobStep.ALIGN, { mode: 'disabled' });
1628
+ await reuseStageAnalytics(JobStep.BURNIN, { mode: 'disabled' });
1629
+ currentJob = getJob(jobId);
1630
+ }
1631
+
1632
+ const completed = updateJob(jobId, {
1633
+ status: JobStatus.COMPLETED,
1634
+ artifacts: getJob(jobId).artifacts
1635
+ });
1636
+
1637
+ await persistCheckpoint(completed, null, deps);
1638
+ const completedPredictions = await deps.collectRunPredictions(projectDir, analyticsRunId);
1639
+ analyticsRun = deps.finalizeRunRecord(analyticsRun, completedPredictions, { status: 'completed' });
1640
+ await saveAnalytics();
1641
+ logInfo('job_completed', { jobId, finalVideoPath: completed.artifacts.finalVideoPath });
1642
+
1643
+ return completed;
1644
+ } catch (error) {
1645
+ logError('job_failed', { jobId, error: error.message });
1646
+ const failed = updateJob(jobId, {
1647
+ status: JobStatus.FAILED,
1648
+ error: error.message
1649
+ });
1650
+ await persistCheckpoint(failed, null, deps);
1651
+
1652
+ if (analyticsRun && analyticsProject) {
1653
+ const failedPredictions = analyticsProjectDir
1654
+ ? await deps.collectRunPredictions(analyticsProjectDir, analyticsRun.runId)
1655
+ : [];
1656
+ analyticsRun = deps.finalizeRunRecord(analyticsRun, failedPredictions, {
1657
+ status: 'failed',
1658
+ error: error.message
1659
+ });
1660
+ await deps.writeRunRecord(analyticsProject, analyticsRun);
1661
+ }
1662
+
1663
+ return failed;
1664
+ } finally {
1665
+ if (lockedProject) {
1666
+ releaseProjectRunLock(lockedProject, jobId);
1667
+ }
1668
+ }
1669
+ }