@skyphusion-labs/vivijure-core 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +87 -0
- package/dist/audio-routing.d.ts +2 -0
- package/dist/audio-routing.d.ts.map +1 -0
- package/dist/audio-routing.js +4 -0
- package/dist/audio-routing.js.map +1 -0
- package/dist/audio-stage.d.ts +4 -0
- package/dist/audio-stage.d.ts.map +1 -0
- package/dist/audio-stage.js +25 -0
- package/dist/audio-stage.js.map +1 -0
- package/dist/beat-analyze.d.ts +22 -0
- package/dist/beat-analyze.d.ts.map +1 -0
- package/dist/beat-analyze.js +111 -0
- package/dist/beat-analyze.js.map +1 -0
- package/dist/beat-sync-types.d.ts +33 -0
- package/dist/beat-sync-types.d.ts.map +1 -0
- package/dist/beat-sync-types.js +4 -0
- package/dist/beat-sync-types.js.map +1 -0
- package/dist/bundle-assembler.d.ts +54 -0
- package/dist/bundle-assembler.d.ts.map +1 -0
- package/dist/bundle-assembler.js +376 -0
- package/dist/bundle-assembler.js.map +1 -0
- package/dist/bundle-durations.d.ts +5 -0
- package/dist/bundle-durations.d.ts.map +1 -0
- package/dist/bundle-durations.js +68 -0
- package/dist/bundle-durations.js.map +1 -0
- package/dist/bundle-storyboard.d.ts +11 -0
- package/dist/bundle-storyboard.d.ts.map +1 -0
- package/dist/bundle-storyboard.js +82 -0
- package/dist/bundle-storyboard.js.map +1 -0
- package/dist/captions.d.ts +25 -0
- package/dist/captions.d.ts.map +1 -0
- package/dist/captions.js +56 -0
- package/dist/captions.js.map +1 -0
- package/dist/cast-db.d.ts +61 -0
- package/dist/cast-db.d.ts.map +1 -0
- package/dist/cast-db.js +324 -0
- package/dist/cast-db.js.map +1 -0
- package/dist/cast-lora-train.d.ts +18 -0
- package/dist/cast-lora-train.d.ts.map +1 -0
- package/dist/cast-lora-train.js +215 -0
- package/dist/cast-lora-train.js.map +1 -0
- package/dist/cast-loras.d.ts +21 -0
- package/dist/cast-loras.d.ts.map +1 -0
- package/dist/cast-loras.js +69 -0
- package/dist/cast-loras.js.map +1 -0
- package/dist/clip-content-validate.d.ts +43 -0
- package/dist/clip-content-validate.d.ts.map +1 -0
- package/dist/clip-content-validate.js +115 -0
- package/dist/clip-content-validate.js.map +1 -0
- package/dist/clip-job-model.d.ts +42 -0
- package/dist/clip-job-model.d.ts.map +1 -0
- package/dist/clip-job-model.js +8 -0
- package/dist/clip-job-model.js.map +1 -0
- package/dist/clip-validate.d.ts +33 -0
- package/dist/clip-validate.d.ts.map +1 -0
- package/dist/clip-validate.js +247 -0
- package/dist/clip-validate.js.map +1 -0
- package/dist/d1-retry.d.ts +20 -0
- package/dist/d1-retry.d.ts.map +1 -0
- package/dist/d1-retry.js +67 -0
- package/dist/d1-retry.js.map +1 -0
- package/dist/db-env.d.ts +6 -0
- package/dist/db-env.d.ts.map +1 -0
- package/dist/db-env.js +2 -0
- package/dist/db-env.js.map +1 -0
- package/dist/dialogue-lines.d.ts +23 -0
- package/dist/dialogue-lines.d.ts.map +1 -0
- package/dist/dialogue-lines.js +84 -0
- package/dist/dialogue-lines.js.map +1 -0
- package/dist/film-advance-lease.d.ts +9 -0
- package/dist/film-advance-lease.d.ts.map +1 -0
- package/dist/film-advance-lease.js +19 -0
- package/dist/film-advance-lease.js.map +1 -0
- package/dist/film-model.d.ts +415 -0
- package/dist/film-model.d.ts.map +1 -0
- package/dist/film-model.js +541 -0
- package/dist/film-model.js.map +1 -0
- package/dist/film-orchestrator.d.ts +216 -0
- package/dist/film-orchestrator.d.ts.map +1 -0
- package/dist/film-orchestrator.js +2204 -0
- package/dist/film-orchestrator.js.map +1 -0
- package/dist/film-render-bridge.d.ts +26 -0
- package/dist/film-render-bridge.d.ts.map +1 -0
- package/dist/film-render-bridge.js +176 -0
- package/dist/film-render-bridge.js.map +1 -0
- package/dist/finish-hash.d.ts +10 -0
- package/dist/finish-hash.d.ts.map +1 -0
- package/dist/finish-hash.js +60 -0
- package/dist/finish-hash.js.map +1 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +45 -0
- package/dist/index.js.map +1 -0
- package/dist/key-safety.d.ts +5 -0
- package/dist/key-safety.d.ts.map +1 -0
- package/dist/key-safety.js +23 -0
- package/dist/key-safety.js.map +1 -0
- package/dist/lora-bundle.d.ts +19 -0
- package/dist/lora-bundle.d.ts.map +1 -0
- package/dist/lora-bundle.js +75 -0
- package/dist/lora-bundle.js.map +1 -0
- package/dist/lora-keys.d.ts +2 -0
- package/dist/lora-keys.d.ts.map +1 -0
- package/dist/lora-keys.js +4 -0
- package/dist/lora-keys.js.map +1 -0
- package/dist/modules/conformance.d.ts +58 -0
- package/dist/modules/conformance.d.ts.map +1 -0
- package/dist/modules/conformance.js +369 -0
- package/dist/modules/conformance.js.map +1 -0
- package/dist/modules/manifest-validate.d.ts +5 -0
- package/dist/modules/manifest-validate.d.ts.map +1 -0
- package/dist/modules/manifest-validate.js +60 -0
- package/dist/modules/manifest-validate.js.map +1 -0
- package/dist/modules/registry.d.ts +184 -0
- package/dist/modules/registry.d.ts.map +1 -0
- package/dist/modules/registry.js +653 -0
- package/dist/modules/registry.js.map +1 -0
- package/dist/modules/render-pipeline.d.ts +38 -0
- package/dist/modules/render-pipeline.d.ts.map +1 -0
- package/dist/modules/render-pipeline.js +40 -0
- package/dist/modules/render-pipeline.js.map +1 -0
- package/dist/modules/types.d.ts +544 -0
- package/dist/modules/types.d.ts.map +1 -0
- package/dist/modules/types.js +57 -0
- package/dist/modules/types.js.map +1 -0
- package/dist/operator-config.d.ts +20 -0
- package/dist/operator-config.d.ts.map +1 -0
- package/dist/operator-config.js +85 -0
- package/dist/operator-config.js.map +1 -0
- package/dist/output-extract.d.ts +8 -0
- package/dist/output-extract.d.ts.map +1 -0
- package/dist/output-extract.js +129 -0
- package/dist/output-extract.js.map +1 -0
- package/dist/planner-prompt.d.ts +12 -0
- package/dist/planner-prompt.d.ts.map +1 -0
- package/dist/planner-prompt.js +337 -0
- package/dist/planner-prompt.js.map +1 -0
- package/dist/planner-yaml.d.ts +15 -0
- package/dist/planner-yaml.d.ts.map +1 -0
- package/dist/planner-yaml.js +157 -0
- package/dist/planner-yaml.js.map +1 -0
- package/dist/platform/fetcher.d.ts +3 -0
- package/dist/platform/fetcher.d.ts.map +1 -0
- package/dist/platform/fetcher.js +7 -0
- package/dist/platform/fetcher.js.map +1 -0
- package/dist/platform/index.d.ts +6 -0
- package/dist/platform/index.d.ts.map +1 -0
- package/dist/platform/index.js +5 -0
- package/dist/platform/index.js.map +1 -0
- package/dist/platform/object-store-r2.d.ts +26 -0
- package/dist/platform/object-store-r2.d.ts.map +1 -0
- package/dist/platform/object-store-r2.js +74 -0
- package/dist/platform/object-store-r2.js.map +1 -0
- package/dist/platform/orchestrator-context.d.ts +20 -0
- package/dist/platform/orchestrator-context.d.ts.map +1 -0
- package/dist/platform/orchestrator-context.js +28 -0
- package/dist/platform/orchestrator-context.js.map +1 -0
- package/dist/platform/r2-types.d.ts +41 -0
- package/dist/platform/r2-types.d.ts.map +1 -0
- package/dist/platform/r2-types.js +3 -0
- package/dist/platform/r2-types.js.map +1 -0
- package/dist/platform/types.d.ts +90 -0
- package/dist/platform/types.d.ts.map +1 -0
- package/dist/platform/types.js +23 -0
- package/dist/platform/types.js.map +1 -0
- package/dist/preflight.d.ts +57 -0
- package/dist/preflight.d.ts.map +1 -0
- package/dist/preflight.js +243 -0
- package/dist/preflight.js.map +1 -0
- package/dist/presign.d.ts +5 -0
- package/dist/presign.d.ts.map +1 -0
- package/dist/presign.js +24 -0
- package/dist/presign.js.map +1 -0
- package/dist/public-id.d.ts +8 -0
- package/dist/public-id.d.ts.map +1 -0
- package/dist/public-id.js +25 -0
- package/dist/public-id.js.map +1 -0
- package/dist/render-adopt.d.ts +3 -0
- package/dist/render-adopt.d.ts.map +1 -0
- package/dist/render-adopt.js +92 -0
- package/dist/render-adopt.js.map +1 -0
- package/dist/render-log.d.ts +25 -0
- package/dist/render-log.d.ts.map +1 -0
- package/dist/render-log.js +139 -0
- package/dist/render-log.js.map +1 -0
- package/dist/render-module-config.d.ts +28 -0
- package/dist/render-module-config.d.ts.map +1 -0
- package/dist/render-module-config.js +124 -0
- package/dist/render-module-config.js.map +1 -0
- package/dist/render-mux.d.ts +18 -0
- package/dist/render-mux.d.ts.map +1 -0
- package/dist/render-mux.js +69 -0
- package/dist/render-mux.js.map +1 -0
- package/dist/render-orchestrator.d.ts +71 -0
- package/dist/render-orchestrator.d.ts.map +1 -0
- package/dist/render-orchestrator.js +376 -0
- package/dist/render-orchestrator.js.map +1 -0
- package/dist/render-sweep.d.ts +5 -0
- package/dist/render-sweep.d.ts.map +1 -0
- package/dist/render-sweep.js +62 -0
- package/dist/render-sweep.js.map +1 -0
- package/dist/renders-db.d.ts +129 -0
- package/dist/renders-db.d.ts.map +1 -0
- package/dist/renders-db.js +752 -0
- package/dist/renders-db.js.map +1 -0
- package/dist/runpod-submit.d.ts +151 -0
- package/dist/runpod-submit.d.ts.map +1 -0
- package/dist/runpod-submit.js +469 -0
- package/dist/runpod-submit.js.map +1 -0
- package/dist/runpod-types.d.ts +2 -0
- package/dist/runpod-types.d.ts.map +1 -0
- package/dist/runpod-types.js +4 -0
- package/dist/runpod-types.js.map +1 -0
- package/dist/scatter-notify.d.ts +4 -0
- package/dist/scatter-notify.d.ts.map +1 -0
- package/dist/scatter-notify.js +43 -0
- package/dist/scatter-notify.js.map +1 -0
- package/dist/scatter-orchestrator-types.d.ts +56 -0
- package/dist/scatter-orchestrator-types.d.ts.map +1 -0
- package/dist/scatter-orchestrator-types.js +3 -0
- package/dist/scatter-orchestrator-types.js.map +1 -0
- package/dist/scatter-orchestrator.d.ts +70 -0
- package/dist/scatter-orchestrator.d.ts.map +1 -0
- package/dist/scatter-orchestrator.js +614 -0
- package/dist/scatter-orchestrator.js.map +1 -0
- package/dist/scatter.d.ts +35 -0
- package/dist/scatter.d.ts.map +1 -0
- package/dist/scatter.js +171 -0
- package/dist/scatter.js.map +1 -0
- package/dist/secret-store.d.ts +5 -0
- package/dist/secret-store.d.ts.map +1 -0
- package/dist/secret-store.js +19 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/shot-durations-parse.d.ts +3 -0
- package/dist/shot-durations-parse.d.ts.map +1 -0
- package/dist/shot-durations-parse.js +43 -0
- package/dist/shot-durations-parse.js.map +1 -0
- package/dist/srt.d.ts +11 -0
- package/dist/srt.d.ts.map +1 -0
- package/dist/srt.js +54 -0
- package/dist/srt.js.map +1 -0
- package/dist/storyboard-ids.d.ts +2 -0
- package/dist/storyboard-ids.d.ts.map +1 -0
- package/dist/storyboard-ids.js +12 -0
- package/dist/storyboard-ids.js.map +1 -0
- package/dist/storyboard-projects-db.d.ts +31 -0
- package/dist/storyboard-projects-db.d.ts.map +1 -0
- package/dist/storyboard-projects-db.js +153 -0
- package/dist/storyboard-projects-db.js.map +1 -0
- package/dist/storyboard-validate.d.ts +72 -0
- package/dist/storyboard-validate.d.ts.map +1 -0
- package/dist/storyboard-validate.js +595 -0
- package/dist/storyboard-validate.js.map +1 -0
- package/dist/structured-events.d.ts +6 -0
- package/dist/structured-events.d.ts.map +1 -0
- package/dist/structured-events.js +15 -0
- package/dist/structured-events.js.map +1 -0
- package/dist/tar.d.ts +9 -0
- package/dist/tar.d.ts.map +1 -0
- package/dist/tar.js +151 -0
- package/dist/tar.js.map +1 -0
- package/dist/voices.d.ts +12 -0
- package/dist/voices.d.ts.map +1 -0
- package/dist/voices.js +37 -0
- package/dist/voices.js.map +1 -0
- package/package.json +71 -0
|
@@ -0,0 +1,2204 @@
|
|
|
1
|
+
// Film orchestrator: the keyframe -> clip handoff. The connective tissue that turns a storyboard
|
|
2
|
+
// into moving clips by sequencing two async stages ACROSS REQUESTS, on the same R2-job-doc +
|
|
3
|
+
// caller-poll pattern the clip orchestrator uses (a Durable Object is the later upgrade for both):
|
|
4
|
+
// phase "keyframe": run the keyframe module (project preview) -> keyframe keys (out-of-request).
|
|
5
|
+
// phase "clips": presign each keyframe key -> keyframe_url, feed {shot_id, keyframe_url, prompt,
|
|
6
|
+
// seconds} into the clip orchestrator (motion.backend, out-of-request).
|
|
7
|
+
// POST /api/render/film starts it; GET /api/render/film/:id advances it; the caller polls to `done`.
|
|
8
|
+
// No Worker ever holds a multi-minute GPU/cloud render.
|
|
9
|
+
import { discoverModules, invokeModule, pollModule, cancelModule, resolveFetcher, servingForHook, validateConfig, dispatchChain, } from "./modules/registry.js";
|
|
10
|
+
import { hookOutputViolation } from "./modules/conformance.js";
|
|
11
|
+
import { emitStructuredEvent } from "./structured-events.js";
|
|
12
|
+
import { filmJobDocKey as filmKey, clipJobDocKey as clipDocKey, orderFinalClips, joinKeyframesToScenes, applyFinishOutput, adoptFinishStepOutput, applySpeechOutput, FINISH_STEP_MAX_ATTEMPTS, classifyFinishFailure, classifyFinishRetry, resolveFinishConfigs, finishShotAdoptableFromR2, reclaimFinishShotsFromR2, finishStepOutputKey, finishStepAppliedTag, finishShotLedgerReconciles, classifyAssembleTransport, MASTER_STEP_MAX_ATTEMPTS, MASTER_STALL_SECONDS, filmSeconds, masteredBedKey, applyMasterOutput, degradeMasterStep, masterChainDone, coerceSceneIds, coerceDialogueLineIds, KEYFRAME_STALL_SECONDS, PHASE_HARD_DEADLINE_SECONDS, POLLABLE_PHASES, phaseAgeSeconds, ceilingAgeSeconds, filmProgressMarker, resolveClipDurationFloor, mapClipDurationsToShots, resolvePlannedSeconds, findClipDurationShortfalls, captionDurations, } from "./film-model.js";
|
|
13
|
+
import { retimeSrt } from "./srt.js";
|
|
14
|
+
import { loadInstallConfig } from "./operator-config.js";
|
|
15
|
+
import { startClipJob, advanceClipJob, cancelInFlightClips, summarizeJob, describeClipFailures, clipFileMatchesShot, finishedClipFileMatchesShot, listClipsByShotId, reclaimClipsFromR2, validateDoneClips, } from "./render-orchestrator.js";
|
|
16
|
+
import { finishStepInputHash } from "./finish-hash.js";
|
|
17
|
+
import { presignR2Get, presignR2Put, FILM_DOWNLOAD_TTL_SECONDS } from "./presign.js";
|
|
18
|
+
import { readShotDurationsFromBundle } from "./bundle-durations.js";
|
|
19
|
+
import { contentValidateDoneClips } from "./clip-content-validate.js";
|
|
20
|
+
import { buildCaptionCues } from "./captions.js";
|
|
21
|
+
import { resolveStagedAudioKey } from "./audio-stage.js";
|
|
22
|
+
import { getCastById, markLoraReady } from "./cast-db.js";
|
|
23
|
+
import { claimFilmAdvance, releaseFilmAdvance } from "./film-advance-lease.js";
|
|
24
|
+
import { withD1Retry } from "./d1-retry.js";
|
|
25
|
+
import { deriveLoraDestKey } from "./lora-keys.js";
|
|
26
|
+
import { asFetcher } from "./platform/fetcher.js";
|
|
27
|
+
export * from "./film-model.js";
|
|
28
|
+
/** Cheap existence check for an R2 object (HEAD, no body). Used to derive assemble
|
|
29
|
+
* completion from R2 presence so a stalled-after-PUT concat self-heals (issue #122). */
|
|
30
|
+
async function r2ObjectExists(env, key) {
|
|
31
|
+
try {
|
|
32
|
+
return (await env.R2_RENDERS.head(key)) !== null;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Collect finished clip keys from a terminal clips_only (or full) film job doc. */
|
|
39
|
+
export async function clipKeysFromFilmJob(env, job) {
|
|
40
|
+
const out = new Map();
|
|
41
|
+
if (job.finish_shots?.length) {
|
|
42
|
+
// Finish WAS set up for this job: the assembled clips are the FINISHED ones only. Never substitute
|
|
43
|
+
// a raw i2v clip for a finish shot that did not reach "done" -- that silent-degrade (#245/#246)
|
|
44
|
+
// shipped unfinished clips marked done with applied=[] (wan: RIFE crashed at idx 0 -> the shot
|
|
45
|
+
// "failed" -> the job fell through to the raw _wan clip). A failed finish fails the render in
|
|
46
|
+
// advanceFinishPhase; this is the defense-in-depth so assemble can never ship a raw clip.
|
|
47
|
+
for (const fs of job.finish_shots) {
|
|
48
|
+
if (fs.status === "done" && fs.clip_key)
|
|
49
|
+
out.set(fs.shot_id, fs.clip_key);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
// No finish modules installed (finish_shots empty) -> assemble the raw i2v clips (the clips_only path).
|
|
54
|
+
if (!job.clip_job_id)
|
|
55
|
+
return out;
|
|
56
|
+
const cjObj = await env.R2_RENDERS.get(clipDocKey(job.clip_job_id));
|
|
57
|
+
if (!cjObj)
|
|
58
|
+
return out;
|
|
59
|
+
const clipJob = JSON.parse(await cjObj.text());
|
|
60
|
+
for (const sh of clipJob.shots) {
|
|
61
|
+
if (sh.status === "done" && sh.clip_key)
|
|
62
|
+
out.set(sh.shot_id, sh.clip_key);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** Internal: keyframes-only path -- record keys and mark done (no i2v / assemble). */
|
|
67
|
+
function completeKeyframesOnly(job, kfOut) {
|
|
68
|
+
const kfs = kfOut.keyframes || [];
|
|
69
|
+
if (!kfs.length) {
|
|
70
|
+
job.phase = "failed";
|
|
71
|
+
job.error = "keyframe stage produced no keyframes";
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
job.keyframes = kfs.map((k) => ({ shot_id: k.shot_id, keyframe_key: k.keyframe_key }));
|
|
75
|
+
job.phase = "done";
|
|
76
|
+
}
|
|
77
|
+
/** Bank any freshly-trained cast LoRA so a character is trained ONCE and reused across every project,
|
|
78
|
+
* instead of retrained on every render. THE long-standing bug: inline-trained adapters were saved to
|
|
79
|
+
* R2 but never written back to cast_members, so resolveCastLoras never saw them `ready` and the
|
|
80
|
+
* backend retrained the same LoRA every render (a silent ~20-min tax). For each slot the keyframe
|
|
81
|
+
* module reports under trained_loras, map slot -> cast id via job.cast_loras, copy the render-scoped
|
|
82
|
+
* adapter to a character-stable key (survives project deletion), and mark the cast member ready.
|
|
83
|
+
* Reused slots (backend reports the already-banked key) and unmapped slots are no-ops; a versioned
|
|
84
|
+
* stable key keyed on job.created_at makes a retry idempotent. Best-effort but LOUD on failure
|
|
85
|
+
* (unlike the silent state-tar restore it replaces). Keyed on cast id (PK) only. */
|
|
86
|
+
async function recordTrainedLorasToCast(env, job, kfOut) {
|
|
87
|
+
const trained = kfOut.trained_loras;
|
|
88
|
+
const castIds = job.cast_loras;
|
|
89
|
+
if (!trained || !castIds)
|
|
90
|
+
return;
|
|
91
|
+
for (const [slot, srcKey] of Object.entries(trained)) {
|
|
92
|
+
const castId = castIds[slot];
|
|
93
|
+
if (!Number.isInteger(castId) || castId <= 0 || typeof srcKey !== "string" || !srcKey)
|
|
94
|
+
continue;
|
|
95
|
+
const stableKey = deriveLoraDestKey(castId, job.created_at);
|
|
96
|
+
try {
|
|
97
|
+
// Advance hot path: a transient D1 blip here would needlessly skip banking a freshly-trained
|
|
98
|
+
// adapter (-> a wasteful retrain next render), so retry the cast read + readiness write.
|
|
99
|
+
const cast = await withD1Retry(() => getCastById(env, castId));
|
|
100
|
+
if (!cast)
|
|
101
|
+
continue;
|
|
102
|
+
// Reused this render (srcKey == current key) or already banked this render (retry): no-op.
|
|
103
|
+
if (cast.lora_status === "ready" && (cast.lora_key === srcKey || cast.lora_key === stableKey))
|
|
104
|
+
continue;
|
|
105
|
+
const obj = await env.R2_RENDERS.get(srcKey);
|
|
106
|
+
if (!obj) {
|
|
107
|
+
console.warn(`recordTrainedLoras: adapter missing in R2 (${srcKey}); cast ${castId} not banked`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
await env.R2_RENDERS.put(stableKey, obj.body);
|
|
111
|
+
await withD1Retry(() => markLoraReady(env, castId, stableKey));
|
|
112
|
+
console.log(`recordTrainedLoras: cast ${castId} slot ${slot} banked ${srcKey} -> ${stableKey} (cross-project reuse)`);
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
console.warn(`recordTrainedLoras: cast ${castId} slot ${slot} failed: ${e.message}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Internal: after keyframes, either stop (preview) or hand off to the clip orchestrator. */
|
|
120
|
+
async function afterKeyframeOutput(env, job, kfOut, preModules) {
|
|
121
|
+
// Bank trained adapters before anything else, so a character LoRA is recorded even for a
|
|
122
|
+
// keyframes-only preview / regen (which is exactly where the perpetual retrain hurt most).
|
|
123
|
+
await recordTrainedLorasToCast(env, job, kfOut);
|
|
124
|
+
if (job.keyframes_only) {
|
|
125
|
+
completeKeyframesOnly(job, kfOut);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
await advanceToClips(env, job, kfOut, preModules);
|
|
129
|
+
}
|
|
130
|
+
/** Internal: presign each matched keyframe -> start the clip job, advancing the film to phase=clips. A
|
|
131
|
+
* keyframe module that reports completion with a PARTIAL set (fewer keyframes than scenes) advances
|
|
132
|
+
* delivering what rendered, but records a LOUD keyframes_incomplete degrade rather than silently
|
|
133
|
+
* rebasing every downstream counter to the smaller total (#622, the normal-completion sibling of the
|
|
134
|
+
* #619 stall). */
|
|
135
|
+
async function advanceToClips(env, job, kfOut, preModules) {
|
|
136
|
+
const { matched, missing } = joinKeyframesToScenes(job.scenes, kfOut.keyframes || []);
|
|
137
|
+
if (!matched.length) {
|
|
138
|
+
job.phase = "failed";
|
|
139
|
+
job.error = `keyframe stage produced none of the requested shots (missing: ${missing.join(", ")})`;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (missing.length && !job.keyframes_incomplete) {
|
|
143
|
+
// A keyframe module reported completion with a PARTIAL set (fewer keyframes than scenes): the #622
|
|
144
|
+
// sibling of the #619 stall, on the NORMAL (non-recovery) completion path. Silently building the clip
|
|
145
|
+
// job from `matched` alone rebases every downstream counter to the smaller total, so the film reports
|
|
146
|
+
// a clean complete over a half-set -- the exact silent-half-film shape (#245/#249). Deliver the scenes
|
|
147
|
+
// that DID render, but LOUDLY: record the drop on keyframes_incomplete + emit the structured event,
|
|
148
|
+
// the same clips-delivered degrade contract as the keyframe stall ceiling (#619). Guarded on
|
|
149
|
+
// !keyframes_incomplete so the ceiling-recovery path (which sets + emits BEFORE calling here) does not
|
|
150
|
+
// double-record the same drop.
|
|
151
|
+
job.keyframes_incomplete = { adopted: matched.length, expected: job.scenes.length, dropped: missing };
|
|
152
|
+
emitKeyframesIncomplete(job);
|
|
153
|
+
console.warn(`film ${job.film_id}: keyframe module completed with only ${matched.length}/${job.scenes.length} keyframes; delivering the rendered scenes, dropped ${missing.join(", ")} (#622)`);
|
|
154
|
+
}
|
|
155
|
+
const shots = [];
|
|
156
|
+
for (const m of matched) {
|
|
157
|
+
const keyframe_url = await presignR2Get(env, m.keyframe_key, 1800); // 30min: covers a long cloud i2v job
|
|
158
|
+
shots.push({ shot_id: m.shot_id, keyframe_url, prompt: m.prompt, seconds: m.seconds });
|
|
159
|
+
}
|
|
160
|
+
const clip = await startClipJob(env, {
|
|
161
|
+
project: job.project, shots,
|
|
162
|
+
motion_backend: job.motion_backend ?? undefined,
|
|
163
|
+
config: job.motion_config,
|
|
164
|
+
}, preModules);
|
|
165
|
+
job.clip_job_id = clip.job_id;
|
|
166
|
+
job.phase = "clips";
|
|
167
|
+
}
|
|
168
|
+
const lastPersistedFilmPhase = new Map();
|
|
169
|
+
const putFilm = async (env, job) => {
|
|
170
|
+
const prev = lastPersistedFilmPhase.get(job.film_id);
|
|
171
|
+
if (prev !== job.phase) {
|
|
172
|
+
emitStructuredEvent({
|
|
173
|
+
ev: "film.phase",
|
|
174
|
+
film_id: job.film_id,
|
|
175
|
+
project: job.project,
|
|
176
|
+
from: prev ?? null,
|
|
177
|
+
to: job.phase,
|
|
178
|
+
});
|
|
179
|
+
if (job.phase === "done" || job.phase === "failed") {
|
|
180
|
+
emitStructuredEvent({
|
|
181
|
+
ev: "film.render.terminal",
|
|
182
|
+
film_id: job.film_id,
|
|
183
|
+
project: job.project,
|
|
184
|
+
status: job.phase,
|
|
185
|
+
...(job.error ? { error: job.error } : {}),
|
|
186
|
+
});
|
|
187
|
+
lastPersistedFilmPhase.delete(job.film_id);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
lastPersistedFilmPhase.set(job.film_id, job.phase);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
await env.R2_RENDERS.put(filmKey(job.film_id), JSON.stringify(job), {
|
|
194
|
+
httpMetadata: { contentType: "application/json" },
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
/** #584 dialogue-aware finish order. A finish module that CONSUMES the shot dialogue audio
|
|
198
|
+
* (`finish_consumes_audio`, i.e. lip-sync) is calibrated to the SOURCE frame rate, so it must run on
|
|
199
|
+
* the native-fps clip BEFORE any finish step that resamples time (interpolation); otherwise its
|
|
200
|
+
* audio->mouth mapping smears across the interpolated frames (the breathy look). For a shot that HAS a
|
|
201
|
+
* dialogue line, stable-partition `serving` so audio-consuming modules run first, preserving ui.order
|
|
202
|
+
* within each group (`serving` is already ui.order-sorted and Array.filter keeps order): rife 10 ->
|
|
203
|
+
* lipsync 15 -> upscale 20 becomes lipsync -> rife -> upscale. A shot with NO line keeps the plain
|
|
204
|
+
* ui.order unchanged, where such a module no-ops (no audio_key). The reorder changes each step INPUT
|
|
205
|
+
* clip, so the #583 finishStepInputHash differs on its own -- no special-case. */
|
|
206
|
+
export function finishChainForShot(serving, isDialogueShot) {
|
|
207
|
+
if (!isDialogueShot)
|
|
208
|
+
return serving;
|
|
209
|
+
return [...serving.filter((m) => m.finish_consumes_audio), ...serving.filter((m) => !m.finish_consumes_audio)];
|
|
210
|
+
}
|
|
211
|
+
/** Internal: clips done -> set up the finish chain (one FinishShot per done clip). No finish modules
|
|
212
|
+
* installed -> skip straight to assemble (the raw clips). No clips rendered at all -> fail (nothing
|
|
213
|
+
* to assemble). */
|
|
214
|
+
async function enterFinishPhase(env, job, clipJob, preModules) {
|
|
215
|
+
// #523 Layer 1 defense-in-depth: validate any clip that reached done via a film-level R2 reclaim after
|
|
216
|
+
// advanceClipJob's own pass, so finish/dialogue/upscale never spends on a structurally-corrupt clip. The
|
|
217
|
+
// `validated` flag makes this idempotent (a no-op on the normal path). Persist if it dropped a shot so
|
|
218
|
+
// the clip-job summary stays honest.
|
|
219
|
+
if (job.clip_job_id && (await validateDoneClips(env, clipJob))) {
|
|
220
|
+
await env.R2_RENDERS.put(clipDocKey(job.clip_job_id), JSON.stringify(clipJob), { httpMetadata: { contentType: "application/json" } });
|
|
221
|
+
}
|
|
222
|
+
// #523 Layer 2: pixel-content gate (video-finish container) at the finish spend boundary. A "corrupt"
|
|
223
|
+
// verdict fails the shot here, so finish/upscale never spends on a noise clip; "suspect" degrades. A
|
|
224
|
+
// no-op when the tier is not installed (self-host) -- Layer 1 still stands. Persist if it changed a shot.
|
|
225
|
+
if (job.clip_job_id && (await contentValidateDoneClips(env, clipJob))) {
|
|
226
|
+
await env.R2_RENDERS.put(clipDocKey(job.clip_job_id), JSON.stringify(clipJob), { httpMetadata: { contentType: "application/json" } });
|
|
227
|
+
}
|
|
228
|
+
const modules = preModules ?? await discoverModules(env);
|
|
229
|
+
const serving = servingForHook(modules, "finish"); // ui.order; the full finish chain
|
|
230
|
+
const doneClips = clipJob.shots.filter((s) => s.status === "done" && s.clip_key);
|
|
231
|
+
if (!doneClips.length) {
|
|
232
|
+
// #754: surface the per-shot failure reasons instead of only the bare generic.
|
|
233
|
+
job.phase = "failed";
|
|
234
|
+
const reasons = describeClipFailures(clipJob);
|
|
235
|
+
job.error = reasons ? `no clips rendered to assemble -- ${reasons}` : "no clips rendered to assemble";
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (!serving.length) {
|
|
239
|
+
job.phase = job.clips_only ? "done" : "assemble";
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
// #584 dialogue-aware finish order: an audio-consuming finish module (lip-sync) runs FIRST on the
|
|
243
|
+
// native-fps clip for a shot that HAS a dialogue line; a shot with no line keeps the plain ui.order.
|
|
244
|
+
// See finishChainForShot. Known here because job.dialogue_lines is set before this phase.
|
|
245
|
+
const dialogueShotIds = new Set((job.dialogue_lines ?? []).filter((l) => l.shot_id && (l.text ?? "").trim().length > 0).map((l) => l.shot_id));
|
|
246
|
+
job.finish_shots = doneClips.map((s) => {
|
|
247
|
+
const ordered = finishChainForShot(serving, dialogueShotIds.has(s.shot_id));
|
|
248
|
+
return {
|
|
249
|
+
shot_id: s.shot_id,
|
|
250
|
+
clip_key: s.clip_key,
|
|
251
|
+
chain: ordered.map((m) => m.binding),
|
|
252
|
+
configs: resolveFinishConfigs(ordered, job.finish_config),
|
|
253
|
+
idx: 0,
|
|
254
|
+
status: "pending",
|
|
255
|
+
applied: [],
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
// finish_shots are built; interpose the dialogue phase (synthesize per-shot speech) before finish so
|
|
259
|
+
// a lip-sync finish module has the audio to drive the mouth. No dialogue -> straight to finish.
|
|
260
|
+
await enterDialogueOrFinish(env, job, preModules);
|
|
261
|
+
}
|
|
262
|
+
/** Fold a dialogue module's batch result into the per-shot audio map the finish stage reads. */
|
|
263
|
+
function applyDialogueOutput(job, out) {
|
|
264
|
+
const map = {};
|
|
265
|
+
for (const a of out?.audio || []) {
|
|
266
|
+
if (a && typeof a.shot_id === "string" && typeof a.audio_key === "string")
|
|
267
|
+
map[a.shot_id] = a.audio_key;
|
|
268
|
+
}
|
|
269
|
+
job.dialogue_audio = map;
|
|
270
|
+
}
|
|
271
|
+
/** After finish_shots are built: if the film has dialogue lines AND a `dialogue` module is installed,
|
|
272
|
+
* submit the per-shot speech batch and enter the dialogue phase; otherwise go straight to finish. A
|
|
273
|
+
* submit failure (or no module) soft-degrades to a SILENT finish -- a dialogue glitch must never fail
|
|
274
|
+
* a fully-rendered film (lip-sync no-ops without an audio_key). */
|
|
275
|
+
async function enterDialogueOrFinish(env, job, preModules) {
|
|
276
|
+
const lines = job.dialogue_lines;
|
|
277
|
+
if (!lines || !lines.length) {
|
|
278
|
+
await enterSpeechOrFinish(env, job, preModules);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const envRec = env;
|
|
282
|
+
const dialogueModule = servingForHook(preModules ?? await discoverModules(envRec), "dialogue")[0];
|
|
283
|
+
const fetcher = dialogueModule ? resolveFetcher(envRec, dialogueModule.binding) : null;
|
|
284
|
+
if (!fetcher) {
|
|
285
|
+
await enterSpeechOrFinish(env, job, preModules);
|
|
286
|
+
return;
|
|
287
|
+
} // no dialogue module bound: silent film
|
|
288
|
+
const req = {
|
|
289
|
+
hook: "dialogue",
|
|
290
|
+
input: { project: job.project, lines },
|
|
291
|
+
config: {},
|
|
292
|
+
context: { project: job.project, job_id: job.film_id },
|
|
293
|
+
};
|
|
294
|
+
const r = await invokeModule(fetcher, req);
|
|
295
|
+
if (!r.ok) {
|
|
296
|
+
console.warn(`film ${job.film_id}: dialogue submit failed (${r.error}); silent finish`);
|
|
297
|
+
await enterSpeechOrFinish(env, job, preModules);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (r.pending) {
|
|
301
|
+
job.dialogue_poll = r.poll;
|
|
302
|
+
job.phase = "dialogue";
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if ("output" in r) {
|
|
306
|
+
const v = hookOutputViolation(dialogueModule.name, "dialogue", r.output);
|
|
307
|
+
if (v) {
|
|
308
|
+
console.warn(`film ${job.film_id}: dialogue ${v}; silent finish`);
|
|
309
|
+
await enterSpeechOrFinish(env, job, preModules);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
applyDialogueOutput(job, r.output);
|
|
313
|
+
}
|
|
314
|
+
await enterSpeechOrFinish(env, job, preModules);
|
|
315
|
+
}
|
|
316
|
+
/** Poll the in-flight dialogue batch. On done, record the per-shot audio map and advance to finish; a
|
|
317
|
+
* failure soft-degrades to a silent finish (the rendered clips are fine, just unvoiced). */
|
|
318
|
+
async function advanceDialoguePhase(env, job, preModules) {
|
|
319
|
+
if (!job.dialogue_poll) {
|
|
320
|
+
await enterSpeechOrFinish(env, job, preModules);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const envRec = env;
|
|
324
|
+
const dialogueModule = servingForHook(preModules ?? await discoverModules(envRec), "dialogue")[0];
|
|
325
|
+
const fetcher = dialogueModule ? resolveFetcher(envRec, dialogueModule.binding) : null;
|
|
326
|
+
if (!fetcher) {
|
|
327
|
+
job.dialogue_poll = undefined;
|
|
328
|
+
await enterSpeechOrFinish(env, job, preModules);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const p = await pollModule(fetcher, { poll: job.dialogue_poll });
|
|
332
|
+
if (!p.ok) {
|
|
333
|
+
console.warn(`film ${job.film_id}: dialogue failed (${p.error}); silent finish`);
|
|
334
|
+
job.dialogue_poll = undefined;
|
|
335
|
+
await enterSpeechOrFinish(env, job, preModules);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (p.pending)
|
|
339
|
+
return; // still synthesizing
|
|
340
|
+
const out = p.output;
|
|
341
|
+
const v = hookOutputViolation(dialogueModule.name, "dialogue", out);
|
|
342
|
+
if (v) {
|
|
343
|
+
console.warn(`film ${job.film_id}: dialogue ${v}; silent finish`);
|
|
344
|
+
job.dialogue_poll = undefined;
|
|
345
|
+
await enterSpeechOrFinish(env, job, preModules);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
applyDialogueOutput(job, out);
|
|
349
|
+
job.dialogue_poll = undefined;
|
|
350
|
+
await enterSpeechOrFinish(env, job, preModules);
|
|
351
|
+
}
|
|
352
|
+
/** After dialogue is resolved: if any `speech` module is installed AND there is dialogue audio to clean,
|
|
353
|
+
* build the per-shot speech chain and enter the speech phase; otherwise go straight to finish. No speech
|
|
354
|
+
* module, or no shot with dialogue audio -> straight to finish (an unvoiced film needs no speech pass). */
|
|
355
|
+
async function enterSpeechOrFinish(env, job, preModules) {
|
|
356
|
+
const audio = job.dialogue_audio ?? {};
|
|
357
|
+
const shotIds = Object.keys(audio);
|
|
358
|
+
if (!shotIds.length) {
|
|
359
|
+
job.phase = "finish";
|
|
360
|
+
return;
|
|
361
|
+
} // unvoiced film: nothing to enhance
|
|
362
|
+
const serving = servingForHook(preModules ?? await discoverModules(env), "speech"); // ui.order
|
|
363
|
+
const chain = serving.map((m) => m.binding);
|
|
364
|
+
if (!chain.length) {
|
|
365
|
+
job.phase = "finish";
|
|
366
|
+
return;
|
|
367
|
+
} // no speech modules installed: passthrough to finish
|
|
368
|
+
const configs = resolveFinishConfigs(serving, job.speech_config ?? {});
|
|
369
|
+
job.speech_shots = shotIds.map((shot_id) => ({
|
|
370
|
+
shot_id, audio_key: audio[shot_id], chain, configs, idx: 0, status: "pending", applied: [],
|
|
371
|
+
}));
|
|
372
|
+
job.phase = "speech";
|
|
373
|
+
}
|
|
374
|
+
/** Advance the speech chain: per shot, submit its current speech module or poll the in-flight one,
|
|
375
|
+
* chaining the enhanced audio forward on completion. A transient invocation/poll blip re-dispatches the
|
|
376
|
+
* step up to the cap (classifyFinishRetry, shared with finish); a DETERMINISTIC failure does NOT fail the
|
|
377
|
+
* render -- speech is a POLISH step, so a hard step failure DEGRADES the shot (keep its current audio,
|
|
378
|
+
* record the reason, mark the chain done) rather than failing a fully-rendered film (#249/#77). When
|
|
379
|
+
* every shot is terminal, fold the cleaned (or, on a degrade, original) audio back into job.dialogue_audio
|
|
380
|
+
* -- so a lip-sync finish module drives the mouth from it -- and advance to finish. */
|
|
381
|
+
async function advanceSpeechPhase(env, job) {
|
|
382
|
+
const envRec = env;
|
|
383
|
+
const degrade = (ss, reason) => {
|
|
384
|
+
// A hard failure on a POLISH step: keep the current audio (original survives), record the reason
|
|
385
|
+
// honestly (no fake applied tag), advance idx so the chain completes. The render never fails here.
|
|
386
|
+
ss.degraded = reason;
|
|
387
|
+
ss.idx += 1;
|
|
388
|
+
ss.poll = undefined;
|
|
389
|
+
ss.attempts = 0;
|
|
390
|
+
if (ss.idx >= ss.chain.length)
|
|
391
|
+
ss.status = "done";
|
|
392
|
+
};
|
|
393
|
+
const blipOrDegrade = (ss, error, keepPoll) => {
|
|
394
|
+
const d = classifyFinishRetry(error, ss.attempts ?? 0); // reuse the shared transient classifier
|
|
395
|
+
if (d.action === "retry") {
|
|
396
|
+
ss.attempts = d.attempts;
|
|
397
|
+
ss.error = `speech ${ss.chain[ss.idx]} transient (attempt ${d.attempts}/${FINISH_STEP_MAX_ATTEMPTS}), retrying: ${error ?? ""}`;
|
|
398
|
+
if (!keepPoll)
|
|
399
|
+
ss.poll = undefined;
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
degrade(ss, `${ss.chain[ss.idx]}: ${error ?? "speech step failed"}`);
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
for (const ss of job.speech_shots || []) {
|
|
406
|
+
if (ss.status !== "pending")
|
|
407
|
+
continue;
|
|
408
|
+
const fetcher = resolveFetcher(envRec, ss.chain[ss.idx]);
|
|
409
|
+
if (!fetcher) {
|
|
410
|
+
degrade(ss, `speech module ${ss.chain[ss.idx]} not bound`);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const req = {
|
|
414
|
+
hook: "speech",
|
|
415
|
+
input: { shot_id: ss.shot_id, audio_key: ss.audio_key },
|
|
416
|
+
config: ss.configs?.[ss.idx] ?? {},
|
|
417
|
+
context: { project: job.project, job_id: job.film_id },
|
|
418
|
+
};
|
|
419
|
+
if (!ss.poll) {
|
|
420
|
+
const r = await invokeModule(fetcher, req);
|
|
421
|
+
if (!r.ok) {
|
|
422
|
+
blipOrDegrade(ss, r.error, false);
|
|
423
|
+
}
|
|
424
|
+
else if (r.pending) {
|
|
425
|
+
ss.poll = r.poll;
|
|
426
|
+
}
|
|
427
|
+
else if ("output" in r) {
|
|
428
|
+
const v = hookOutputViolation(ss.chain[ss.idx], "speech", r.output);
|
|
429
|
+
if (v) {
|
|
430
|
+
degrade(ss, v);
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
applySpeechOutput(ss, r.output);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
degrade(ss, "speech module returned neither output nor a poll token");
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
const p = await pollModule(fetcher, { poll: ss.poll });
|
|
442
|
+
if (p.ok && !p.pending) {
|
|
443
|
+
const out = p.output;
|
|
444
|
+
const v = hookOutputViolation(ss.chain[ss.idx], "speech", out);
|
|
445
|
+
if (v) {
|
|
446
|
+
degrade(ss, v);
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
applySpeechOutput(ss, out);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
else if (!p.ok && classifyFinishFailure(p.error) === "transient") {
|
|
453
|
+
blipOrDegrade(ss, p.error, true);
|
|
454
|
+
}
|
|
455
|
+
else if (!p.ok) {
|
|
456
|
+
blipOrDegrade(ss, p.error, false);
|
|
457
|
+
}
|
|
458
|
+
// else: still pending -> leave it for the next tick
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const speechShots = job.speech_shots || [];
|
|
462
|
+
if (speechShots.every((ss) => ss.status !== "pending")) {
|
|
463
|
+
// Fold the cleaned (or, on a degrade, original) audio back into dialogue_audio so a lip-sync finish
|
|
464
|
+
// module drives the mouth from it. This is the single point folding speech results into film state.
|
|
465
|
+
for (const ss of speechShots)
|
|
466
|
+
(job.dialogue_audio ??= {})[ss.shot_id] = ss.audio_key;
|
|
467
|
+
job.phase = "finish";
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/** R2 object ETag (unquoted) or null if the key is absent / a HEAD fails. Shared by the invoke-time
|
|
471
|
+
* provenance stamp and the #583 adoption gate so both hash the same input identity. */
|
|
472
|
+
async function headEtag(env, key) {
|
|
473
|
+
if (!key)
|
|
474
|
+
return null;
|
|
475
|
+
try {
|
|
476
|
+
return (await env.R2_RENDERS.head(key))?.etag ?? null;
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
return null;
|
|
480
|
+
} // HEAD miss -> null etag -> gate re-runs, never mis-adopts
|
|
481
|
+
}
|
|
482
|
+
/** #583 ADOPTION GATE. Adopt an R2 finish artifact ONLY when its provenance sidecar (`<key>.hash`, written
|
|
483
|
+
* by the producer) is present AND matches the current step's recomputed input hash. A MISSING sidecar (a
|
|
484
|
+
* legacy/unstamped artifact) or a MISMATCH (a same-project resubmit changed the finish inputs -- the #583
|
|
485
|
+
* bug) => do NOT adopt; re-run the step. Uses finishStepInputHash, the SAME function that stamped the
|
|
486
|
+
* sidecar at invoke time, so the write and the gate can never drift. */
|
|
487
|
+
async function finishArtifactHashMatches(env, job, fs, artifactKey) {
|
|
488
|
+
let stored;
|
|
489
|
+
try {
|
|
490
|
+
const sc = await env.R2_RENDERS.get(`${artifactKey}.hash`);
|
|
491
|
+
if (!sc)
|
|
492
|
+
return false; // no sidecar -> never adopt blind (#583)
|
|
493
|
+
stored = (await sc.text()).trim();
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
const [clipEtag, audioEtag] = await Promise.all([
|
|
499
|
+
headEtag(env, fs.clip_key),
|
|
500
|
+
headEtag(env, job.dialogue_audio?.[fs.shot_id]),
|
|
501
|
+
]);
|
|
502
|
+
const expected = await finishStepInputHash(clipEtag, audioEtag, fs.configs?.[fs.idx]);
|
|
503
|
+
return stored === expected;
|
|
504
|
+
}
|
|
505
|
+
/** R2-authoritative recovery for a finish step whose RunPod job is GONE (poll 404s, GC'd-after-complete)
|
|
506
|
+
* or FROZEN (envelope stuck IN_PROGRESS so /poll pends forever, #166) MID-chain: if THIS step's expected
|
|
507
|
+
* output (finishStepOutputKey) is already in R2, fold it in and advance idx so the next module dispatches
|
|
508
|
+
* -- instead of pending to the hard-deadline. Distinct from finishShotAdoptableFromR2, which adopts only
|
|
509
|
+
* the chain's FINAL artifact: this advances ONE step on its OWN predicted output, so the remaining modules
|
|
510
|
+
* still run and it can never ship a half-finished clip (the mid-chain phantom-adopt the silent-render bug
|
|
511
|
+
* warned against). Returns true iff it advanced the step. */
|
|
512
|
+
async function adoptFinishStepFromR2(env, job, fs, preModules) {
|
|
513
|
+
// The tick threads its once-discovered registry in (preModules); a direct caller discovers fresh.
|
|
514
|
+
const modules = preModules ?? await discoverModules(env);
|
|
515
|
+
const expected = finishStepOutputKey(job.project, fs, modules);
|
|
516
|
+
if (!expected)
|
|
517
|
+
return false;
|
|
518
|
+
if ((await env.R2_RENDERS.head(expected)) === null)
|
|
519
|
+
return false;
|
|
520
|
+
// #583 gate: a present artifact is adopted only if its provenance sidecar matches this step's inputs;
|
|
521
|
+
// a missing/mismatched sidecar (legacy artifact, or a resubmit with changed inputs) re-runs the step.
|
|
522
|
+
if (!(await finishArtifactHashMatches(env, job, fs, expected)))
|
|
523
|
+
return false;
|
|
524
|
+
// #583: an R2-adopted step is REUSED, not run -- record the marker in `adopted`, never a fake `applied`-run tag.
|
|
525
|
+
adoptFinishStepOutput(fs, expected, finishStepAppliedTag(fs, modules));
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
/** Advance the finish chain: per shot, submit its current finish module or poll the in-flight one,
|
|
529
|
+
* chaining to the next module on completion. Phase -> assemble when every shot is terminal. */
|
|
530
|
+
async function advanceFinishPhase(env, job, preModules) {
|
|
531
|
+
const envRec = env;
|
|
532
|
+
// A transient invocation/poll blip re-dispatches the step (status stays `pending`) up to the cap
|
|
533
|
+
// instead of failing it; a deterministic reject or the cap exhausted fails loud. `keepPoll` keeps a
|
|
534
|
+
// poll token to re-poll (a lost poll) vs clearing it to re-submit (a failed invoke).
|
|
535
|
+
const failOrRetry = (fs, error, keepPoll) => {
|
|
536
|
+
const d = classifyFinishRetry(error, fs.attempts ?? 0);
|
|
537
|
+
if (d.action === "retry") {
|
|
538
|
+
fs.attempts = d.attempts;
|
|
539
|
+
fs.error = `finish ${fs.chain[fs.idx]} transient (attempt ${d.attempts}/${FINISH_STEP_MAX_ATTEMPTS}), retrying: ${error ?? ""}`;
|
|
540
|
+
if (!keepPoll)
|
|
541
|
+
fs.poll = undefined; // re-submit next tick; status stays "pending"
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
fs.status = "failed";
|
|
545
|
+
fs.error = error;
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
for (const fs of job.finish_shots || []) {
|
|
549
|
+
if (fs.status !== "pending")
|
|
550
|
+
continue;
|
|
551
|
+
const fetcher = resolveFetcher(envRec, fs.chain[fs.idx]);
|
|
552
|
+
if (!fetcher) {
|
|
553
|
+
fs.status = "failed";
|
|
554
|
+
fs.error = `finish module ${fs.chain[fs.idx]} not bound`;
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
const req = {
|
|
558
|
+
hook: "finish",
|
|
559
|
+
input: { shot_id: fs.shot_id, clip_key: fs.clip_key, audio_key: job.dialogue_audio?.[fs.shot_id] },
|
|
560
|
+
config: fs.configs?.[fs.idx] ?? {}, // validated per-module config (issue #75); {} only for legacy jobs
|
|
561
|
+
context: { project: job.project, job_id: job.film_id },
|
|
562
|
+
};
|
|
563
|
+
if (!fs.poll) {
|
|
564
|
+
// #583: stamp the step's input provenance so the producer writes `<output_key>.hash`. Compute the
|
|
565
|
+
// ONE hash (finishStepInputHash) the future adoption gate also uses, over the CURRENT input clip +
|
|
566
|
+
// audio etags + validated config; pass it as the opaque `output_hash`. HEADs are best-effort (a
|
|
567
|
+
// null etag still yields a deterministic hash); done only on invoke, never on a re-poll.
|
|
568
|
+
const [clipEtag, audioEtag] = await Promise.all([
|
|
569
|
+
headEtag(env, fs.clip_key),
|
|
570
|
+
headEtag(env, job.dialogue_audio?.[fs.shot_id]),
|
|
571
|
+
]);
|
|
572
|
+
req.input.output_hash = await finishStepInputHash(clipEtag, audioEtag, fs.configs?.[fs.idx]);
|
|
573
|
+
const r = await invokeModule(fetcher, req);
|
|
574
|
+
if (!r.ok) {
|
|
575
|
+
failOrRetry(fs, r.error, false);
|
|
576
|
+
}
|
|
577
|
+
else if (r.pending) {
|
|
578
|
+
fs.poll = r.poll;
|
|
579
|
+
}
|
|
580
|
+
else if ("output" in r) {
|
|
581
|
+
const v = hookOutputViolation(fs.chain[fs.idx], "finish", r.output);
|
|
582
|
+
if (v) {
|
|
583
|
+
fs.status = "failed";
|
|
584
|
+
fs.error = v;
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
applyFinishOutput(fs, r.output);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
fs.status = "failed";
|
|
592
|
+
fs.error = "finish module returned neither output nor a poll token";
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
const p = await pollModule(fetcher, { poll: fs.poll });
|
|
597
|
+
if (p.ok && !p.pending) {
|
|
598
|
+
const out = p.output;
|
|
599
|
+
const v = hookOutputViolation(fs.chain[fs.idx], "finish", out);
|
|
600
|
+
if (v) {
|
|
601
|
+
fs.status = "failed";
|
|
602
|
+
fs.error = v;
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
applyFinishOutput(fs, out);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
else if (!p.ok && classifyFinishFailure(p.error) === "transient") {
|
|
609
|
+
failOrRetry(fs, p.error, true); // a transport blip: re-poll the same job under the cap
|
|
610
|
+
}
|
|
611
|
+
else if (!(await adoptFinishStepFromR2(env, job, fs, preModules))) {
|
|
612
|
+
// The step's RunPod job is GONE (a deterministic poll failure -- 404 job-not-found, the
|
|
613
|
+
// GC'd-after-complete path) or FROZEN (envelope stuck IN_PROGRESS so /poll pends forever, #166),
|
|
614
|
+
// and this step's output is NOT in R2. A deterministic failure with no artifact fails loud; a
|
|
615
|
+
// still-pending poll with no artifact stays pending (the job may yet finish, or its output land).
|
|
616
|
+
// The whole point: a mid-chain finish step can no longer pend forever when its output is in R2 --
|
|
617
|
+
// the wedge that stalled the showcase (RIFE done, idx never advanced, lip-sync never dispatched).
|
|
618
|
+
if (!p.ok)
|
|
619
|
+
failOrRetry(fs, p.error, true);
|
|
620
|
+
}
|
|
621
|
+
// else: adoptFinishStepFromR2 folded this step's R2 output in and advanced idx (R2 authoritative).
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
// R2 PRESENCE IS AUTHORITATIVE (issue #141), symmetric to the clips reclaim: the finish output may
|
|
625
|
+
// already be in R2 at renders/<project>/clips/<shot>_finished.mp4 even though the module verdict says
|
|
626
|
+
// otherwise -- a shot it fast-failed on a GC'd job (#141), OR a last-chain shot stuck `pending` because
|
|
627
|
+
// the RunPod envelope froze at IN_PROGRESS and the poll never sees COMPLETED (RUN #29). Reclaim any
|
|
628
|
+
// adoptable shot whose finished clip is present BEFORE the every-terminal judgment, so the finish phase
|
|
629
|
+
// never advances dropping a shot -- and never false-fails at the hard-deadline -- with the clip in R2.
|
|
630
|
+
// Only one R2 list, only when there is an adoptable shot to reclaim (the all-done happy path pays nothing).
|
|
631
|
+
const finishShots = job.finish_shots || [];
|
|
632
|
+
if (finishShots.some(finishShotAdoptableFromR2)) {
|
|
633
|
+
const present = await listClipsByShotId(env, job.project, finishShots.map((fs) => fs.shot_id), finishedClipFileMatchesShot);
|
|
634
|
+
// #583 gate: adopt a present final artifact only when its provenance sidecar matches the shot's current
|
|
635
|
+
// inputs. A same-job recovery (#141/#166) matches (inputs unchanged) and still adopts; a cross-film
|
|
636
|
+
// resubmit with changed inputs (or a legacy unstamped artifact) fails the match and re-runs.
|
|
637
|
+
const verified = new Map();
|
|
638
|
+
for (const fs of finishShots) {
|
|
639
|
+
if (!finishShotAdoptableFromR2(fs))
|
|
640
|
+
continue;
|
|
641
|
+
const key = present.get(fs.shot_id);
|
|
642
|
+
if (key && await finishArtifactHashMatches(env, job, fs, key))
|
|
643
|
+
verified.set(fs.shot_id, key);
|
|
644
|
+
}
|
|
645
|
+
reclaimFinishShotsFromR2(finishShots, verified, preModules); // #583: thread modules so the reused marker reconstructs into `adopted`
|
|
646
|
+
}
|
|
647
|
+
// #662 honesty guard: a DONE finish shot's per-step ledger MUST reconcile 1:1 to its chain (every step
|
|
648
|
+
// accounted for, run OR reused). Log LOUD if it does not -- an under-reconciled ledger is the "applied
|
|
649
|
+
// drops one tag" class and must never ship silently (#245/#249). Never fails the render: the ledger is a
|
|
650
|
+
// record and the union applied+adopted still carries the transforms; this surfaces a bookkeeping drop.
|
|
651
|
+
for (const fs of finishShots) {
|
|
652
|
+
if (fs.status === "done" && !finishShotLedgerReconciles(fs)) {
|
|
653
|
+
console.warn(`film ${job.film_id}: finish shot ${fs.shot_id} ledger does NOT reconcile to its chain [${fs.chain.join(", ")}] (ledger ${(fs.ledger ?? []).length}/${fs.chain.length}); applied=${JSON.stringify(fs.applied)} adopted=${JSON.stringify(fs.adopted ?? [])} (#662)`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (finishShots.every((fs) => fs.status !== "pending")) {
|
|
657
|
+
// Fail LOUD on a genuinely-failed finish step. After the bounded transient-retry (failOrRetry)
|
|
658
|
+
// and the R2 reclaim above, a shot still "failed" has no path left and no finished artifact -- so
|
|
659
|
+
// the render must NOT advance to done/assemble shipping the raw i2v clip with applied=[]. That
|
|
660
|
+
// silent-degrade (#245/#246) shipped green-but-unfinished films (wan: RIFE crashed at idx 0, the
|
|
661
|
+
// shot "failed", the job went done with the raw clip, error:None). Surface the real error instead.
|
|
662
|
+
const failed = finishShots.filter((fs) => fs.status === "failed");
|
|
663
|
+
if (failed.length) {
|
|
664
|
+
job.phase = "failed";
|
|
665
|
+
job.error = `finish failed for ${failed.length} shot(s): ` +
|
|
666
|
+
failed.map((fs) => `${fs.shot_id} at ${fs.chain[fs.idx] ?? "?"} (${fs.error ?? "no error"})`).join("; ");
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
job.phase = job.clips_only ? "done" : "assemble";
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/** Call the video-finish container's POST /finish, retrying on a transient gateway status -- 503 (a
|
|
673
|
+
* cold container can 503 while its port is still binding -- same shape as callImagePrep in
|
|
674
|
+
* bundle-assembler) or 504 (a cold-boot + ffmpeg concat that exceeds the request window; issue #82).
|
|
675
|
+
* backoffMs is injectable so tests do not actually wait. Returns the Response or null on a network
|
|
676
|
+
* error. The orchestrator (enterAssemblePhase) adds an outer, across-polls auto-recover on top of
|
|
677
|
+
* this in-request retry, since a single request window may not outlast a fully-cold container. */
|
|
678
|
+
export async function callVideoFinish(env, payload, opts = {}) {
|
|
679
|
+
const retries = opts.retries ?? 3;
|
|
680
|
+
const backoffMs = opts.backoffMs ?? 1500;
|
|
681
|
+
const init = {
|
|
682
|
+
method: "POST",
|
|
683
|
+
headers: { "content-type": "application/json" },
|
|
684
|
+
body: JSON.stringify(payload),
|
|
685
|
+
};
|
|
686
|
+
// video-finish runs always-on on the fleet, reached over a Workers VPC binding (private, no cold
|
|
687
|
+
// start) -- so the old Container-DO singleton + warm-/health dance is gone (issue #83).
|
|
688
|
+
const vpc = asFetcher(env.VIDEO_FINISH_VPC);
|
|
689
|
+
if (!vpc)
|
|
690
|
+
return null;
|
|
691
|
+
let resp = null;
|
|
692
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
693
|
+
try {
|
|
694
|
+
resp = await vpc.fetch("http://video-finish/finish", init);
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
resp = null;
|
|
698
|
+
}
|
|
699
|
+
if (resp && resp.status !== 503 && resp.status !== 504)
|
|
700
|
+
return resp;
|
|
701
|
+
if (attempt < retries - 1)
|
|
702
|
+
await new Promise((r) => setTimeout(r, backoffMs)); // container still binding / warming
|
|
703
|
+
}
|
|
704
|
+
return resp;
|
|
705
|
+
}
|
|
706
|
+
/** POST to the always-on fleet audio-mix container (/mix), mirroring callVideoFinish: a private
|
|
707
|
+
* Workers VPC binding, retry the transient gateway statuses. Returns null when the binding is not
|
|
708
|
+
* provisioned (#231 is additive -- the caller then degrades to the single-track remux). */
|
|
709
|
+
export async function callAudioMix(env, payload, opts = {}) {
|
|
710
|
+
const mix = asFetcher(env.AUDIO_MIX_VPC);
|
|
711
|
+
if (!mix)
|
|
712
|
+
return null; // not provisioned -> caller degrades to single-track mux
|
|
713
|
+
const retries = opts.retries ?? 3;
|
|
714
|
+
const backoffMs = opts.backoffMs ?? 1500;
|
|
715
|
+
const init = { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) };
|
|
716
|
+
let resp = null;
|
|
717
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
718
|
+
try {
|
|
719
|
+
resp = await mix.fetch("http://audio-mix/mix", init);
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
resp = null;
|
|
723
|
+
}
|
|
724
|
+
if (resp && resp.status !== 503 && resp.status !== 504)
|
|
725
|
+
return resp;
|
|
726
|
+
if (attempt < retries - 1)
|
|
727
|
+
await new Promise((r) => setTimeout(r, backoffMs));
|
|
728
|
+
}
|
|
729
|
+
return resp;
|
|
730
|
+
}
|
|
731
|
+
/** Pure: should the mux phase run the multi-track mix (#231)? Only when the film has BOTH dialogue
|
|
732
|
+
* (baked into the assembled video by lip-sync) AND a music bed, and the audio-mix VPC is bound.
|
|
733
|
+
* Otherwise the single-track remux is correct (and unchanged). */
|
|
734
|
+
export function shouldMultiTrackMix(job, env) {
|
|
735
|
+
const hasDialogue = !!job.dialogue_audio && Object.keys(job.dialogue_audio).length > 0;
|
|
736
|
+
return hasDialogue && !!job.audio_key && !!job.silent_film_key && !!env.AUDIO_MIX_VPC;
|
|
737
|
+
}
|
|
738
|
+
/** #231: mix the film's dialogue (in the assembled video) under-ducked with the music bed + loudnorm via
|
|
739
|
+
* /mix, returning the mixed audio R2 key to remux -- or null to DEGRADE to the single-track bed mux. The
|
|
740
|
+
* mix is a POLISH step: any failure soft-degrades (keep the bed), never fails a fully-rendered film
|
|
741
|
+
* (#249/#77). /mix runs ffmpeg -i per track, so the assembled video's URL works as the dialogue track
|
|
742
|
+
* (its audio stream is extracted); the bed is the music track. */
|
|
743
|
+
async function mixFilmAudio(env, job, videoKey, bedKey) {
|
|
744
|
+
const mixKey = job.mix_audio_key
|
|
745
|
+
?? videoKey.replace(/\.mp4$/i, "") + "-mix-" + crypto.randomUUID().slice(0, 8) + ".mp3";
|
|
746
|
+
job.mix_audio_key = mixKey;
|
|
747
|
+
const [dialogueUrl, musicUrl, outputUrl] = await Promise.all([
|
|
748
|
+
presignR2Get(env, videoKey, 1800),
|
|
749
|
+
presignR2Get(env, bedKey, 1800),
|
|
750
|
+
presignR2Put(env, mixKey, 1800),
|
|
751
|
+
]);
|
|
752
|
+
const resp = await callAudioMix(env, {
|
|
753
|
+
tracks: [
|
|
754
|
+
{ url: dialogueUrl, role: "dialogue", gainDb: 0 },
|
|
755
|
+
{ url: musicUrl, role: "music", gainDb: 0 },
|
|
756
|
+
],
|
|
757
|
+
outputUrl,
|
|
758
|
+
outputKey: mixKey,
|
|
759
|
+
format: "mp3",
|
|
760
|
+
loudnessTargetLufs: -14,
|
|
761
|
+
});
|
|
762
|
+
if (!resp || !resp.ok) {
|
|
763
|
+
console.warn(`film ${job.film_id}: audio-mix unreachable/${resp ? resp.status : "null"}; degrading to single-track mux (#231)`);
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
let body;
|
|
767
|
+
try {
|
|
768
|
+
body = (await resp.json());
|
|
769
|
+
}
|
|
770
|
+
catch {
|
|
771
|
+
console.warn(`film ${job.film_id}: audio-mix returned non-JSON; degrading to single-track mux`);
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
if (!body.ok || !body.key) {
|
|
775
|
+
console.warn(`film ${job.film_id}: audio-mix not ok (${body.error ?? "no key"}); degrading to single-track mux`);
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
return mixKey; // mixed dialogue + ducked music + loudnorm; remux this in place of the bare bed
|
|
779
|
+
}
|
|
780
|
+
const filmOutKey = (filmId) => `renders/${filmId}/film.mp4`;
|
|
781
|
+
// Cap on across-polls assemble re-attempts before a transient failure goes terminal (issue #82).
|
|
782
|
+
const MAX_ASSEMBLE_ATTEMPTS = 6;
|
|
783
|
+
// #600 film.finish in-flight window (s): a deterministic step key still absent from R2 whose last
|
|
784
|
+
// dispatch is within this window is treated as STILL ENCODING and is NOT re-dispatched (no duplicate
|
|
785
|
+
// encode). Set above the longest single film.finish encode; the driver 90-min phase deadline is the
|
|
786
|
+
// ultimate backstop, so a genuinely dead encode retries after at most one stale window.
|
|
787
|
+
export const FILM_FINISH_INFLIGHT_WINDOW_SECONDS = 1200;
|
|
788
|
+
/** Internal: the assemble leg. Gather the final clips (in scene order), presign each as a fetchable
|
|
789
|
+
* GET + presign the film output as a PUT, and hand them to the video-finish container, which ffmpeg-
|
|
790
|
+
* concats them into one mp4 and PUTs it. This is a CPU-only job (never GPU). The container call is
|
|
791
|
+
* synchronous; for a long film it can run a while, so if the request times out the phase stays
|
|
792
|
+
* "assemble" and the next advance re-attempts (re-PUTting the same key is idempotent). */
|
|
793
|
+
/** Best-effort: on the done-transition, fire the `notify` hook chain -- every installed notify module
|
|
794
|
+
* (email, webhook, ...) delivers independently. Presigns the film's download link + hands over the
|
|
795
|
+
* completion context. A notifier failure (or none installed) NEVER fails the already-assembled render;
|
|
796
|
+
* the film is in R2 by the time this runs. */
|
|
797
|
+
async function fireNotify(env, job, preModules) {
|
|
798
|
+
if (!job.film_key)
|
|
799
|
+
return;
|
|
800
|
+
try {
|
|
801
|
+
const envRec = env;
|
|
802
|
+
const notifiers = servingForHook(preModules ?? await discoverModules(envRec), "notify");
|
|
803
|
+
if (!notifiers.length)
|
|
804
|
+
return;
|
|
805
|
+
const download_url = await presignR2Get(env, job.film_key, FILM_DOWNLOAD_TTL_SECONDS); // matches the poll summary
|
|
806
|
+
const input = {
|
|
807
|
+
event: "render.complete", film_id: job.film_id, project: job.project,
|
|
808
|
+
download_url,
|
|
809
|
+
};
|
|
810
|
+
const context = { project: job.project, job_id: job.film_id };
|
|
811
|
+
for (const m of notifiers) {
|
|
812
|
+
const fetcher = resolveFetcher(envRec, m.binding);
|
|
813
|
+
if (!fetcher)
|
|
814
|
+
continue;
|
|
815
|
+
try {
|
|
816
|
+
// Inject the operator-set install-config (e.g. notify-email's notify_email recipient) as the
|
|
817
|
+
// user config, then clamp through the contract; render-scope fields stay at their defaults.
|
|
818
|
+
const installConfig = await loadInstallConfig(env, m.name, m.config_schema);
|
|
819
|
+
await invokeModule(fetcher, {
|
|
820
|
+
hook: "notify", input, config: validateConfig(m.config_schema ?? {}, installConfig), context,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
catch { /* best-effort per notifier -- a delivery failure never fails the render */ }
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
catch (e) {
|
|
827
|
+
console.warn(`notify chain failed for ${job.film_id}: ${e.message}`);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
/** Final transition: run the film.finish chain (title / credit cards) on the assembled+muxed film,
|
|
831
|
+
* then mark done + notify. FAIL-SAFE: no film.finish module, no title/credits, or ANY error -> the
|
|
832
|
+
* film keeps its original key. A film.finish step must never drop a fully-rendered film. (#190) */
|
|
833
|
+
async function transitionToDone(env, job, preModules) {
|
|
834
|
+
let complete = true;
|
|
835
|
+
try {
|
|
836
|
+
complete = await applyFilmFinish(env, job, preModules);
|
|
837
|
+
}
|
|
838
|
+
catch (e) {
|
|
839
|
+
// A swallowed throw must not ship as a silent green: record it on the job so the degraded outcome is
|
|
840
|
+
// observable. The film keeps its original (uncarded) key -- a finish step never drops the film. (#190)
|
|
841
|
+
const msg = e.message;
|
|
842
|
+
job.film_finish = {
|
|
843
|
+
applied: job.film_finish?.applied ?? [],
|
|
844
|
+
errors: [...(job.film_finish?.errors ?? []), `film.finish threw: ${msg}`],
|
|
845
|
+
steps: job.film_finish?.steps,
|
|
846
|
+
degraded: job.film_finish?.degraded ?? `threw: ${msg}`,
|
|
847
|
+
};
|
|
848
|
+
console.warn(`film.finish failed for ${job.film_id}: ${msg}; keeping the original film`);
|
|
849
|
+
complete = true; // a THROW is terminal fail-safe: ship the uncarded film, never loop on it
|
|
850
|
+
}
|
|
851
|
+
// #600: an in-flight film.finish step is still encoding -- do NOT finalize. Leave the phase
|
|
852
|
+
// (assemble / mux) so the existing idempotent re-entry resumes next tick and adopts the step when its
|
|
853
|
+
// deterministic artifact lands; the in-flight guard stops a duplicate encode meanwhile.
|
|
854
|
+
if (!complete)
|
|
855
|
+
return;
|
|
856
|
+
job.phase = "done";
|
|
857
|
+
await fireNotify(env, job, preModules);
|
|
858
|
+
}
|
|
859
|
+
// #602 async job+poll: a film.finish step whose encode outlasts a request budget is submitted ONCE and
|
|
860
|
+
// polled ACROSS TICKS with the persisted module poll token, so no request holds it open. This bounds
|
|
861
|
+
// how many TERMINAL poll/submit failures a single step re-dispatches (the deterministic output key
|
|
862
|
+
// makes a re-run idempotent) before it soft-degrades -- ships the film UNCARDED, never fails it (#190).
|
|
863
|
+
export const FILM_FINISH_STEP_MAX_ATTEMPTS = 3;
|
|
864
|
+
// The async output PUT is presigned ONCE per submit and must outlive the whole single-step encode
|
|
865
|
+
// (polled across ticks), unlike the synchronous path's short-lived per-request presign. Sized above the
|
|
866
|
+
// driver's ~90-min phase ceiling backstop so a long single encode's PUT never expires mid-flight.
|
|
867
|
+
export const FILM_FINISH_ASYNC_PRESIGN_TTL_SECONDS = 7200;
|
|
868
|
+
/** Presign the per-step transport (film GET + carded-film PUT + optional .srt sidecar PUT) and build the
|
|
869
|
+
* FilmFinishInput seed. Shared by the synchronous dispatchChain path (ttl 1800) and the async submit
|
|
870
|
+
* path (a long ttl, since the PUT must outlive a multi-tick encode). The module is credentialless: it
|
|
871
|
+
* only ever sees these presigned URLs, never R2 creds. */
|
|
872
|
+
async function filmFinishSeed(env, input, inKey, outKey, captions, ttl = 1800) {
|
|
873
|
+
const sidecarKey = outKey.replace(/\.mp4$/i, "") + ".srt";
|
|
874
|
+
const [videoUrl, outputUrl, sidecarUrl] = await Promise.all([
|
|
875
|
+
presignR2Get(env, inKey, ttl),
|
|
876
|
+
presignR2Put(env, outKey, ttl),
|
|
877
|
+
presignR2Put(env, sidecarKey, ttl),
|
|
878
|
+
]);
|
|
879
|
+
return {
|
|
880
|
+
film_key: inKey,
|
|
881
|
+
video_url: videoUrl,
|
|
882
|
+
output_url: outputUrl,
|
|
883
|
+
output_key: outKey,
|
|
884
|
+
title: input.film_titles?.title,
|
|
885
|
+
credits: input.film_titles?.credits,
|
|
886
|
+
captions,
|
|
887
|
+
sidecar_url: sidecarUrl,
|
|
888
|
+
sidecar_key: sidecarKey,
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
/** Dispatch ONE film.finish module against DETERMINISTIC keys: read inKey, write outKey (plus its .srt).
|
|
892
|
+
* #600: a deterministic outKey is what makes a completed step ADOPTABLE from R2 on a later tick instead
|
|
893
|
+
* of being re-encoded under a fresh random key. Reuses dispatchChain for a single-element chain so the
|
|
894
|
+
* module config-clamp / degrade / error handling stays identical. Returns whether it produced a real
|
|
895
|
+
* carded output at outKey (ok), plus the observable applied / errors / degraded. */
|
|
896
|
+
async function runFilmFinishStep(env, input, module, inKey, outKey, captions) {
|
|
897
|
+
const envRec = env;
|
|
898
|
+
const seed = await filmFinishSeed(env, input, inKey, outKey, captions);
|
|
899
|
+
// Single-element chain: dispatchChain gives the config-clamp + degrade/error handling for free;
|
|
900
|
+
// nextInput is never called with one module.
|
|
901
|
+
const result = await dispatchChain(envRec, [module], "film.finish", seed, { project: input.project, job_id: input.job_id }, {
|
|
902
|
+
nextInput: async (prev) => prev,
|
|
903
|
+
configFor: (name) => input.film_finish_config?.[name],
|
|
904
|
+
});
|
|
905
|
+
const degradeParts = [...result.degraded];
|
|
906
|
+
let out = result.output;
|
|
907
|
+
if (out !== null) {
|
|
908
|
+
const v = hookOutputViolation(module.name, "film.finish", out);
|
|
909
|
+
if (v) {
|
|
910
|
+
degradeParts.push(v);
|
|
911
|
+
out = null;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
const degraded = degradeParts.length > 0 ? degradeParts.join("; ") : undefined;
|
|
915
|
+
// Follow the MODULE contract: the film lives at out.film_key -- the deterministic outKey on a REAL
|
|
916
|
+
// write, but the INPUT key on a noop (subtitle enabled=false -> "noop:no-cards") or a passthrough
|
|
917
|
+
// degrade, neither of which writes outKey. Falling back to inKey keeps the chain reading a real file
|
|
918
|
+
// (a bare outKey would 404 the next step`s GET). Adoption stays honest by construction: only a real
|
|
919
|
+
// write leaves an artifact at the deterministic outKey, so only real steps are ever adopted.
|
|
920
|
+
const film_key = typeof out?.film_key === "string" && out.film_key.length > 0 ? out.film_key : inKey;
|
|
921
|
+
const prepend_seconds = typeof out?.prepend_seconds === "number" && Number.isFinite(out.prepend_seconds) && out.prepend_seconds > 0 ? out.prepend_seconds : undefined;
|
|
922
|
+
return { film_key, applied: result.applied, errors: result.errors, steps: out?.applied, degraded, prepend_seconds };
|
|
923
|
+
}
|
|
924
|
+
/** #663: after the film.finish chain, produce the FINAL .srt subtitle sidecar. The subtitle module
|
|
925
|
+
* (ui.order 5) writes its sidecar timed to the pre-card assembled film; a later film-titles step
|
|
926
|
+
* (ui.order 10) prepends a title card, shifting the FINAL film. This reads the raw per-step sidecar
|
|
927
|
+
* (never mutated), shifts every cue by the total prepend that ran AFTER it, and writes the result to a
|
|
928
|
+
* key named next to the FINAL film (discoverable + idempotent). Returns the final sidecar key, or
|
|
929
|
+
* undefined when no sidecar exists (burn-only mode, or a silent / dialogue-free film). A zero offset
|
|
930
|
+
* (no title card / credits-only) is a straight copy so the surfaced key stays consistent. */
|
|
931
|
+
async function finalizeSidecar(env, base, finalFilmKey, stepCount, prepends, captions) {
|
|
932
|
+
// A sidecar can only exist when there was dialogue to caption; skip the R2 probes on a silent film.
|
|
933
|
+
if (!(captions ?? []).some((c) => typeof c.text === "string" && c.text.trim().length > 0))
|
|
934
|
+
return undefined;
|
|
935
|
+
// Locate the raw sidecar a subtitle step wrote by its deterministic per-step key. First present wins
|
|
936
|
+
// (only the subtitle module writes one, and it runs before any card step).
|
|
937
|
+
let rawKey;
|
|
938
|
+
let rawIndex = -1;
|
|
939
|
+
for (let n = 0; n < stepCount; n++) {
|
|
940
|
+
const k = `${base}-ff${n}.srt`;
|
|
941
|
+
if (await r2ObjectExists(env, k)) {
|
|
942
|
+
rawKey = k;
|
|
943
|
+
rawIndex = n;
|
|
944
|
+
break;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
if (!rawKey)
|
|
948
|
+
return undefined; // burn-only, or the subtitle module wrote no sidecar
|
|
949
|
+
const finalKey = finalFilmKey.replace(/\.mp4$/i, "") + ".srt";
|
|
950
|
+
// Subtitle was the terminal step (no later card): its sidecar is already aligned to the final film and
|
|
951
|
+
// already lives at the final key -- surface it as-is, never rewrite the raw artifact in place.
|
|
952
|
+
if (finalKey === rawKey)
|
|
953
|
+
return rawKey;
|
|
954
|
+
// Sum the prepend of every step AFTER the one that wrote the sidecar (a title card shifts the timeline;
|
|
955
|
+
// credits append at the end and record 0).
|
|
956
|
+
let shift = 0;
|
|
957
|
+
for (let n = rawIndex + 1; n < stepCount; n++) {
|
|
958
|
+
const sec = prepends[`${base}-ff${n}.mp4`];
|
|
959
|
+
if (typeof sec === "number" && sec > 0)
|
|
960
|
+
shift += sec;
|
|
961
|
+
}
|
|
962
|
+
const obj = await env.R2_RENDERS.get(rawKey);
|
|
963
|
+
if (!obj)
|
|
964
|
+
return undefined; // vanished between HEAD and GET (GC race); nothing to re-time
|
|
965
|
+
const rawText = await obj.text();
|
|
966
|
+
const finalText = shift > 0 ? retimeSrt(rawText, shift) : rawText;
|
|
967
|
+
await env.R2_RENDERS.put(finalKey, finalText, { httpMetadata: { contentType: "application/x-subrip; charset=utf-8" } });
|
|
968
|
+
return finalKey;
|
|
969
|
+
}
|
|
970
|
+
/** Run the film.finish chain (subtitle / title / credit cards) on an assembled+muxed film. Reused by the
|
|
971
|
+
* single-film path AND the scatter gather. Captions are FILM-LEVEL (buildCaptionCues computes each line
|
|
972
|
+
* start from the cumulative duration of preceding shots), so the caller passes the FULL scenes +
|
|
973
|
+
* dialogue_lines in assembled (shot) order.
|
|
974
|
+
*
|
|
975
|
+
* #600 SURVIVABLE: each step writes a DETERMINISTIC per-step key (<film>-ff<n>.mp4), so before running a
|
|
976
|
+
* step the chain HEADs that key -- PRESENT means ADOPT the prior attempt output (no re-encode), ABSENT
|
|
977
|
+
* means dispatch. A big film whose whole chain exceeds one request budget therefore makes progress
|
|
978
|
+
* across the existing per-tick assemble re-entry: each tick adopts every completed step and re-runs only
|
|
979
|
+
* the incomplete one, instead of re-burning the whole chain under a fresh random key (the film-374268a2
|
|
980
|
+
* loop). R2 presence IS the persisted progress (#122 / #141). FAIL-SAFE: a step soft-degrade / failure
|
|
981
|
+
* passes the film through (recorded in degraded), never drops it (#190). */
|
|
982
|
+
export async function runFilmFinish(env, input, preModules, opts) {
|
|
983
|
+
const envRec = env;
|
|
984
|
+
const modules = preModules ?? await discoverModules(envRec);
|
|
985
|
+
const steps = servingForHook(modules, "film.finish");
|
|
986
|
+
if (steps.length === 0) {
|
|
987
|
+
return { ran: false, film_key: input.film_key, applied: [], adopted: [], errors: [], complete: true }; // nothing installed -> no-op
|
|
988
|
+
}
|
|
989
|
+
// Time-synced dialogue captions for the subtitle module (empty means it no-ops); computed once, reused
|
|
990
|
+
// by every step (film-titles ignores them). See src/captions.ts.
|
|
991
|
+
const bundleDurations = await readShotDurationsFromBundle(env, input.bundle_key);
|
|
992
|
+
// #698: time cues to the ACTUAL assembled cut (actual per-shot seconds win, bundle plan fills any shot
|
|
993
|
+
// the container did not report), not the bundle plan -- which drifts on every non-final tier where the
|
|
994
|
+
// delivered clip is shorter than its planned target (trailing cues otherwise point past EOF).
|
|
995
|
+
const durations = captionDurations(bundleDurations, input.actual_durations);
|
|
996
|
+
const captions = buildCaptionCues(input.scenes, input.dialogue_lines ?? [], durations);
|
|
997
|
+
const base = input.film_key.replace(/\.mp4$/i, "");
|
|
998
|
+
let curKey = input.film_key;
|
|
999
|
+
const applied = [];
|
|
1000
|
+
const adopted = [];
|
|
1001
|
+
const errors = [];
|
|
1002
|
+
const degradeParts = [];
|
|
1003
|
+
let lastSteps;
|
|
1004
|
+
const now = opts?.now ?? Date.now();
|
|
1005
|
+
// persistPoll present => the caller persists per tick, so drive the module async (submit once, poll
|
|
1006
|
+
// across ticks). Absent => the synchronous dispatchChain step (behavior-identical to pre-#602).
|
|
1007
|
+
const asyncDrive = !!opts?.persistPoll;
|
|
1008
|
+
let complete = true;
|
|
1009
|
+
// #663: title-card prepend offsets keyed by the prepending step`s deterministic outKey. Bound to the
|
|
1010
|
+
// persisted job map when the caller persists (survives cross-tick adoption), else in-memory (single tick).
|
|
1011
|
+
const prepends = opts?.prepends ?? {};
|
|
1012
|
+
const recordPrepend = async (key, seconds) => {
|
|
1013
|
+
prepends[key] = seconds;
|
|
1014
|
+
await opts?.persistPrepend?.(key, seconds);
|
|
1015
|
+
};
|
|
1016
|
+
// Soft-degrade ONE step (ship the film uncarded, #190) without failing the render: record the reason,
|
|
1017
|
+
// forget any in-flight token, and let the chain continue from the CURRENT (uncarded) key.
|
|
1018
|
+
const softDegradeStep = async (outKey, reason) => {
|
|
1019
|
+
errors.push(reason);
|
|
1020
|
+
degradeParts.push(reason);
|
|
1021
|
+
if (opts?.dispatched)
|
|
1022
|
+
delete opts.dispatched[outKey];
|
|
1023
|
+
if (asyncDrive)
|
|
1024
|
+
await opts.persistPoll(outKey, null);
|
|
1025
|
+
};
|
|
1026
|
+
// Fold a completed film.finish output into the chain (advance to the carded key, record what ran).
|
|
1027
|
+
// Returns false when the output VIOLATES the contract (the caller soft-degrades instead of folding a
|
|
1028
|
+
// malformed key forward -- a bare/absent key would 404 the next step`s GET).
|
|
1029
|
+
const foldOutput = async (module, out, outKey) => {
|
|
1030
|
+
if (hookOutputViolation(module.name, "film.finish", out))
|
|
1031
|
+
return false;
|
|
1032
|
+
applied.push(module.name);
|
|
1033
|
+
if (Array.isArray(out.applied))
|
|
1034
|
+
lastSteps = out.applied;
|
|
1035
|
+
if (typeof out.degraded === "string" && out.degraded.length > 0)
|
|
1036
|
+
degradeParts.push(`${module.name}: ${out.degraded}`);
|
|
1037
|
+
curKey = typeof out.film_key === "string" && out.film_key.length > 0 ? out.film_key : outKey;
|
|
1038
|
+
// #663: a title card this step prepended shifts the final timeline; record it so the post-chain .srt
|
|
1039
|
+
// re-time (and a later-tick resume that only ADOPTS this step) can offset any earlier sidecar.
|
|
1040
|
+
const pp = typeof out.prepend_seconds === "number" && Number.isFinite(out.prepend_seconds) && out.prepend_seconds > 0 ? out.prepend_seconds : 0;
|
|
1041
|
+
if (pp > 0)
|
|
1042
|
+
await recordPrepend(outKey, pp);
|
|
1043
|
+
return true;
|
|
1044
|
+
};
|
|
1045
|
+
for (let n = 0; n < steps.length; n++) {
|
|
1046
|
+
const module = steps[n];
|
|
1047
|
+
const outKey = base + "-ff" + n + ".mp4";
|
|
1048
|
+
// AUTHORITATIVE completion (#122/#141/#600): this step`s deterministic artifact is already in R2 --
|
|
1049
|
+
// a prior attempt completed (its request/encode outlived the poll), or the container PUT it between
|
|
1050
|
+
// polls. Adopt it (REUSE, never a fake applied run, #583) and thread it forward.
|
|
1051
|
+
if (await r2ObjectExists(env, outKey)) {
|
|
1052
|
+
adopted.push(module.name);
|
|
1053
|
+
curKey = outKey;
|
|
1054
|
+
if (opts?.polls)
|
|
1055
|
+
delete opts.polls[outKey];
|
|
1056
|
+
if (opts?.dispatched)
|
|
1057
|
+
delete opts.dispatched[outKey];
|
|
1058
|
+
if (opts?.attempts)
|
|
1059
|
+
delete opts.attempts[outKey];
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
if (!asyncDrive) {
|
|
1063
|
+
// --- legacy synchronous path (unchanged): #600 in-flight guard + one dispatchChain step. ---
|
|
1064
|
+
const lastTs = opts?.dispatched?.[outKey];
|
|
1065
|
+
if (lastTs !== undefined && now - lastTs < FILM_FINISH_INFLIGHT_WINDOW_SECONDS * 1000) {
|
|
1066
|
+
complete = false;
|
|
1067
|
+
break;
|
|
1068
|
+
}
|
|
1069
|
+
await opts?.persistDispatch?.(outKey, now);
|
|
1070
|
+
const r = await runFilmFinishStep(env, input, module, curKey, outKey, captions);
|
|
1071
|
+
if (opts?.dispatched)
|
|
1072
|
+
delete opts.dispatched[outKey];
|
|
1073
|
+
errors.push(...r.errors);
|
|
1074
|
+
applied.push(...r.applied);
|
|
1075
|
+
if (r.steps)
|
|
1076
|
+
lastSteps = r.steps;
|
|
1077
|
+
if (r.degraded)
|
|
1078
|
+
degradeParts.push(r.degraded);
|
|
1079
|
+
curKey = r.film_key;
|
|
1080
|
+
if (r.prepend_seconds && r.prepend_seconds > 0)
|
|
1081
|
+
await recordPrepend(outKey, r.prepend_seconds);
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
// --- async submit+poll path (#602) ---
|
|
1085
|
+
const fetcher = resolveFetcher(envRec, module.binding);
|
|
1086
|
+
if (!fetcher) {
|
|
1087
|
+
await softDegradeStep(outKey, `${module.name}: not reachable`);
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
const config = validateConfig(module.config_schema, input.film_finish_config?.[module.name]);
|
|
1091
|
+
const context = { project: input.project, job_id: input.job_id };
|
|
1092
|
+
const token = opts?.polls?.[outKey];
|
|
1093
|
+
if (token) {
|
|
1094
|
+
// An async job is in flight for this step: poll it.
|
|
1095
|
+
const p = await pollModule(fetcher, { poll: token });
|
|
1096
|
+
if (p.ok && !p.pending) {
|
|
1097
|
+
const out = p.output;
|
|
1098
|
+
if (!(await foldOutput(module, out, outKey))) {
|
|
1099
|
+
await softDegradeStep(outKey, `${module.name}: ${hookOutputViolation(module.name, "film.finish", out)}`);
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
if (opts?.dispatched)
|
|
1103
|
+
delete opts.dispatched[outKey];
|
|
1104
|
+
if (opts?.attempts)
|
|
1105
|
+
delete opts.attempts[outKey];
|
|
1106
|
+
await opts.persistPoll(outKey, null);
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
if (p.ok) {
|
|
1110
|
+
complete = false;
|
|
1111
|
+
break;
|
|
1112
|
+
} // still encoding -> resume next tick
|
|
1113
|
+
// Terminal poll failure (container job failed / not found past its restart grace / bad token).
|
|
1114
|
+
// Bounded re-dispatch: forget the token so next tick re-submits (idempotent), until the cap; then
|
|
1115
|
+
// soft-degrade the step (ship uncarded, #190). R2 adoption still short-circuits if it lands.
|
|
1116
|
+
const attempts = (opts?.attempts?.[outKey] ?? 0) + 1;
|
|
1117
|
+
if (opts?.attempts)
|
|
1118
|
+
opts.attempts[outKey] = attempts;
|
|
1119
|
+
if (opts?.dispatched)
|
|
1120
|
+
delete opts.dispatched[outKey];
|
|
1121
|
+
await opts.persistPoll(outKey, null);
|
|
1122
|
+
if (attempts >= FILM_FINISH_STEP_MAX_ATTEMPTS) {
|
|
1123
|
+
await softDegradeStep(outKey, `${module.name}: ${p.error} (after ${attempts} attempts)`);
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
complete = false;
|
|
1127
|
+
break; // re-submit next tick (bounded)
|
|
1128
|
+
}
|
|
1129
|
+
// No token: SUBMIT. An async module returns { pending, poll }; a sync (or fallback) module or a
|
|
1130
|
+
// pre-#602 container returns the output directly (or a soft-degrade passthrough).
|
|
1131
|
+
// #600 in-flight guard (async flavor): a recent dispatch with NO token means either a sync-fallback
|
|
1132
|
+
// module is still holding a request open on this step, or an async submit whose request died before
|
|
1133
|
+
// persisting its token -- do NOT fire a DUPLICATE encode; resume next tick (adopt once the
|
|
1134
|
+
// deterministic key lands, or re-submit past the window).
|
|
1135
|
+
const lastTs = opts?.dispatched?.[outKey];
|
|
1136
|
+
if (lastTs !== undefined && now - lastTs < FILM_FINISH_INFLIGHT_WINDOW_SECONDS * 1000) {
|
|
1137
|
+
complete = false;
|
|
1138
|
+
break;
|
|
1139
|
+
}
|
|
1140
|
+
await opts?.persistDispatch?.(outKey, now); // crash-safe #600 marker: guards a sync-fallback encode
|
|
1141
|
+
const seed = await filmFinishSeed(env, input, curKey, outKey, captions, FILM_FINISH_ASYNC_PRESIGN_TTL_SECONDS);
|
|
1142
|
+
const r = await invokeModule(fetcher, { hook: "film.finish", input: seed, config, context });
|
|
1143
|
+
if (r.ok && r.pending) {
|
|
1144
|
+
// Accepted async: persist the token (it supersedes the dispatch marker) and resume next tick.
|
|
1145
|
+
await opts.persistPoll(outKey, r.poll);
|
|
1146
|
+
if (opts?.dispatched)
|
|
1147
|
+
delete opts.dispatched[outKey];
|
|
1148
|
+
complete = false;
|
|
1149
|
+
break;
|
|
1150
|
+
}
|
|
1151
|
+
if (opts?.dispatched)
|
|
1152
|
+
delete opts.dispatched[outKey];
|
|
1153
|
+
if (r.ok && "output" in r) {
|
|
1154
|
+
const out = r.output;
|
|
1155
|
+
if (!(await foldOutput(module, out, outKey)))
|
|
1156
|
+
await softDegradeStep(outKey, `${module.name}: ${hookOutputViolation(module.name, "film.finish", out)}`);
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
// Submit ok:false (a deterministic input/config reject): soft-degrade this step (#190).
|
|
1160
|
+
await softDegradeStep(outKey, `${module.name}: ${r.error ?? "invoke failed"}`);
|
|
1161
|
+
}
|
|
1162
|
+
const degraded = degradeParts.length > 0 ? degradeParts.join("; ") : undefined;
|
|
1163
|
+
// #663: once the chain COMPLETES, materialize the final subtitle sidecar next to the final film,
|
|
1164
|
+
// re-timed for any title-card prepend. Skipped on an in-flight stop (produced next tick when complete).
|
|
1165
|
+
const sidecar_key = complete ? await finalizeSidecar(env, base, curKey, steps.length, prepends, captions) : undefined;
|
|
1166
|
+
return { ran: true, film_key: curKey, applied, adopted, errors, steps: lastSteps, degraded, complete, sidecar_key };
|
|
1167
|
+
}
|
|
1168
|
+
/** Single-film film.finish: thin wrapper over runFilmFinish that folds the outcome back onto the job
|
|
1169
|
+
* (behavior-identical to the pre-refactor inline version -- no-op leaves the job untouched). */
|
|
1170
|
+
async function applyFilmFinish(env, job, preModules) {
|
|
1171
|
+
if (!job.film_key)
|
|
1172
|
+
return true; // nothing to card -> complete
|
|
1173
|
+
job.film_finish_dispatched ??= {};
|
|
1174
|
+
job.film_finish_polls ??= {};
|
|
1175
|
+
job.film_finish_attempts ??= {};
|
|
1176
|
+
job.film_finish_prepend ??= {};
|
|
1177
|
+
const r = await runFilmFinish(env, {
|
|
1178
|
+
film_key: job.film_key,
|
|
1179
|
+
scenes: job.scenes,
|
|
1180
|
+
dialogue_lines: job.dialogue_lines,
|
|
1181
|
+
film_titles: job.film_titles,
|
|
1182
|
+
film_finish_config: job.film_finish_config,
|
|
1183
|
+
bundle_key: job.bundle_key,
|
|
1184
|
+
project: job.project,
|
|
1185
|
+
job_id: job.film_id,
|
|
1186
|
+
actual_durations: job.actual_clip_durations,
|
|
1187
|
+
}, preModules, {
|
|
1188
|
+
dispatched: job.film_finish_dispatched,
|
|
1189
|
+
persistDispatch: async (key, ts) => { job.film_finish_dispatched[key] = ts; await putFilm(env, job); },
|
|
1190
|
+
// #602 async job+poll: persist the per-step module poll token + terminal-failure count so submit and
|
|
1191
|
+
// poll span ticks (a long single step no longer re-burns each tick). null token => forget the step.
|
|
1192
|
+
polls: job.film_finish_polls,
|
|
1193
|
+
attempts: job.film_finish_attempts,
|
|
1194
|
+
persistPoll: async (key, token) => {
|
|
1195
|
+
if (token === null)
|
|
1196
|
+
delete job.film_finish_polls[key];
|
|
1197
|
+
else
|
|
1198
|
+
job.film_finish_polls[key] = token;
|
|
1199
|
+
await putFilm(env, job);
|
|
1200
|
+
},
|
|
1201
|
+
// #663: persist title-card prepend offsets so the post-chain .srt re-time recovers them even when the
|
|
1202
|
+
// prepending step is adopted (not re-folded) on a later poll tick.
|
|
1203
|
+
prepends: job.film_finish_prepend,
|
|
1204
|
+
persistPrepend: async (key, seconds) => { job.film_finish_prepend[key] = seconds; await putFilm(env, job); },
|
|
1205
|
+
});
|
|
1206
|
+
if (!r.ran)
|
|
1207
|
+
return true; // no film.finish module installed -> leave job untouched (identical to pre-refactor)
|
|
1208
|
+
if (r.errors.length > 0) {
|
|
1209
|
+
console.warn(`film.finish errors for ${job.film_id}: ${r.errors.join("; ")}`);
|
|
1210
|
+
}
|
|
1211
|
+
if (r.degraded) {
|
|
1212
|
+
console.warn(`film.finish degraded for ${job.film_id}: ${r.degraded} -- film shipped WITHOUT cards`);
|
|
1213
|
+
}
|
|
1214
|
+
if (r.adopted.length > 0) {
|
|
1215
|
+
console.log(`film.finish adopted ${r.adopted.length} completed step(s) from R2 for ${job.film_id}: ${r.adopted.join(", ")}`);
|
|
1216
|
+
}
|
|
1217
|
+
job.film_finish = { applied: r.applied, adopted: r.adopted, errors: r.errors, steps: r.steps, degraded: r.degraded, sidecar_key: r.sidecar_key };
|
|
1218
|
+
// #600: advance the film key ONLY when the chain COMPLETED. On an in-flight stop, keep the assembled
|
|
1219
|
+
// key so the deterministic step base stays stable across re-entries (a shifted base would re-burn).
|
|
1220
|
+
if (r.complete)
|
|
1221
|
+
job.film_key = r.film_key;
|
|
1222
|
+
return r.complete;
|
|
1223
|
+
}
|
|
1224
|
+
/** Emit the loud, structured degrade event when the video-finish tier is UNAVAILABLE (VIDEO_FINISH_VPC
|
|
1225
|
+
* unbound, or the container/tunnel unreachable after the bounded assemble/mux retry) so the film
|
|
1226
|
+
* COMPLETES with what was rendered instead of hard-failing after the GPU spend (#519). Mirrors the
|
|
1227
|
+
* scatter.* structured events (docs/observability.md): a Loki-greppable `{"ev":"film.finish_unavailable"}`
|
|
1228
|
+
* line the UI and smoke tests assert on. NEVER emitted for a genuine per-shot / container ERROR -- that
|
|
1229
|
+
* still fails the render loud (#245/#249); this is the UNAVAILABILITY path only. */
|
|
1230
|
+
function emitFinishUnavailable(job) {
|
|
1231
|
+
const u = job.finish_unavailable;
|
|
1232
|
+
if (!u)
|
|
1233
|
+
return;
|
|
1234
|
+
emitStructuredEvent({
|
|
1235
|
+
ev: "film.finish_unavailable",
|
|
1236
|
+
film_id: job.film_id,
|
|
1237
|
+
project: job.project,
|
|
1238
|
+
at: u.at,
|
|
1239
|
+
delivered: u.delivered,
|
|
1240
|
+
clips: u.clips?.length ?? 0,
|
|
1241
|
+
reason: u.reason,
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
/** Emit the loud, structured degrade event when the keyframe stall recovery reached the phase ceiling
|
|
1245
|
+
* with only a PARTIAL keyframe set (#619): the missing scenes never rendered, so the film advances
|
|
1246
|
+
* delivering what DID render, but this makes the drop greppable ({"ev":"film.keyframes_incomplete"})
|
|
1247
|
+
* for the UI + smoke tests, never a silent half-film (#245/#249). */
|
|
1248
|
+
function emitKeyframesIncomplete(job) {
|
|
1249
|
+
const k = job.keyframes_incomplete;
|
|
1250
|
+
if (!k)
|
|
1251
|
+
return;
|
|
1252
|
+
emitStructuredEvent({
|
|
1253
|
+
ev: "film.keyframes_incomplete",
|
|
1254
|
+
film_id: job.film_id,
|
|
1255
|
+
project: job.project,
|
|
1256
|
+
adopted: k.adopted,
|
|
1257
|
+
expected: k.expected,
|
|
1258
|
+
dropped: k.dropped,
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
/** Video-finish tier UNAVAILABLE at assemble (VIDEO_FINISH_VPC unbound, or the concat container
|
|
1262
|
+
* unreachable after the bounded retry): there is no single concatenated film, but every per-shot clip
|
|
1263
|
+
* is rendered and sitting in R2. COMPLETE the film delivering those clips with a loud, structured
|
|
1264
|
+
* "clips only, finish unavailable" status, rather than hard-failing after the keyframe/i2v/finish GPU
|
|
1265
|
+
* spend (#519 -- "you can at least get your clips if you close your laptop"). UNAVAILABILITY ONLY: a
|
|
1266
|
+
* container that RAN and reported a real assemble error still fails loud (#245/#249). */
|
|
1267
|
+
function degradeAssembleUnavailable(job, finalClips, reason) {
|
|
1268
|
+
job.finish_unavailable = { at: "assemble", reason, delivered: "clips", clips: finalClips };
|
|
1269
|
+
job.assemble_attempts = 0;
|
|
1270
|
+
emitFinishUnavailable(job);
|
|
1271
|
+
job.phase = "done"; // no assembled film to finish/notify; the clips ARE the delivered render
|
|
1272
|
+
}
|
|
1273
|
+
/** Video-finish tier UNAVAILABLE at mux (VIDEO_FINISH_VPC unbound, or the remux container unreachable
|
|
1274
|
+
* after the bounded retry): the SILENT assembled film exists in R2 (silentKey), the audio bed just
|
|
1275
|
+
* could not be muxed onto it. Ship the silent film with a loud, structured status rather than
|
|
1276
|
+
* hard-failing a fully-rendered film (#519). transitionToDone still runs any film.finish cards on the
|
|
1277
|
+
* silent film and fires notify with its download link. UNAVAILABILITY ONLY (#245/#249). */
|
|
1278
|
+
async function degradeMuxUnavailable(env, job, silentKey, reason, preModules) {
|
|
1279
|
+
job.finish_unavailable = { at: "mux", reason, delivered: "silent_film" };
|
|
1280
|
+
job.mux_attempts = 0;
|
|
1281
|
+
emitFinishUnavailable(job);
|
|
1282
|
+
job.film_key = silentKey;
|
|
1283
|
+
await transitionToDone(env, job, preModules);
|
|
1284
|
+
}
|
|
1285
|
+
async function enterMuxPhase(env, job, preModules) {
|
|
1286
|
+
const silentKey = job.silent_film_key;
|
|
1287
|
+
const audioKey = job.audio_key;
|
|
1288
|
+
if (!silentKey || !audioKey) {
|
|
1289
|
+
job.film_key = silentKey;
|
|
1290
|
+
await transitionToDone(env, job, preModules);
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
if (!env.VIDEO_FINISH_VPC) {
|
|
1294
|
+
await degradeMuxUnavailable(env, job, silentKey, "video-finish tier not installed (VIDEO_FINISH_VPC unbound); shipped silent film", preModules);
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
const outKey = job.mux_output_key
|
|
1298
|
+
?? silentKey.replace(/\.mp4$/i, "") + "-audio-" + crypto.randomUUID().slice(0, 8) + ".mp4";
|
|
1299
|
+
job.mux_output_key = outKey;
|
|
1300
|
+
// #231: a dialogue + music film gets a PROPER multi-track mix first -- duck the music under the
|
|
1301
|
+
// dialogue + loudness-normalize via the audio-mix container -- then remux that single mixed track.
|
|
1302
|
+
// Soft-degrades to the bare bed (single-track remux, prior behavior) when the audio-mix VPC is not
|
|
1303
|
+
// bound or the mix fails; an audio-polish miss never fails a fully-rendered film (#249/#77).
|
|
1304
|
+
let audioToMux = audioKey;
|
|
1305
|
+
if (shouldMultiTrackMix(job, env)) {
|
|
1306
|
+
const mixed = await mixFilmAudio(env, job, silentKey, audioKey);
|
|
1307
|
+
if (mixed)
|
|
1308
|
+
audioToMux = mixed;
|
|
1309
|
+
}
|
|
1310
|
+
const [videoUrl, audioUrl, outputUrl] = await Promise.all([
|
|
1311
|
+
presignR2Get(env, silentKey, 1800),
|
|
1312
|
+
presignR2Get(env, audioToMux, 1800),
|
|
1313
|
+
presignR2Put(env, outKey, 1800),
|
|
1314
|
+
]);
|
|
1315
|
+
const resp = await callVideoFinish(env, {
|
|
1316
|
+
clips: [{ url: videoUrl }],
|
|
1317
|
+
outputUrl,
|
|
1318
|
+
outputKey: outKey,
|
|
1319
|
+
audioUrl,
|
|
1320
|
+
remuxAudioOnly: true,
|
|
1321
|
+
});
|
|
1322
|
+
const transport = classifyAssembleTransport(resp ? resp.status : null, job.mux_attempts ?? 0, MAX_ASSEMBLE_ATTEMPTS);
|
|
1323
|
+
job.mux_attempts = transport.attempts;
|
|
1324
|
+
if (transport.state === "retry") {
|
|
1325
|
+
job.phase = "mux";
|
|
1326
|
+
job.error = transport.error;
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
if (transport.state === "exhausted") {
|
|
1330
|
+
await degradeMuxUnavailable(env, job, silentKey, transport.error, preModules);
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
if (!resp) {
|
|
1334
|
+
await degradeMuxUnavailable(env, job, silentKey, "video-finish container unreachable; shipped silent film", preModules);
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
if (!resp.ok) {
|
|
1338
|
+
let detail = "";
|
|
1339
|
+
try {
|
|
1340
|
+
detail = (await resp.text()).slice(0, 400);
|
|
1341
|
+
}
|
|
1342
|
+
catch { /* body unreadable */ }
|
|
1343
|
+
job.phase = "failed";
|
|
1344
|
+
job.error = `video-finish mux returned ${resp.status}${detail ? `: ${detail}` : ""}`;
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
let body;
|
|
1348
|
+
try {
|
|
1349
|
+
body = (await resp.json());
|
|
1350
|
+
}
|
|
1351
|
+
catch {
|
|
1352
|
+
job.phase = "failed";
|
|
1353
|
+
job.error = "video-finish returned a non-JSON response";
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
if (!body.ok) {
|
|
1357
|
+
job.phase = "failed";
|
|
1358
|
+
job.error = `video-finish mux failed: ${body.error || "unknown error"}`;
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
job.film_key = outKey;
|
|
1362
|
+
await transitionToDone(env, job, preModules);
|
|
1363
|
+
}
|
|
1364
|
+
/** After the silent film is assembled and an audio bed exists: set up the `master` chain (if any master
|
|
1365
|
+
* module is installed) to polish the bed before mux. No master module -> straight to mux with the bed
|
|
1366
|
+
* as-is. Mirrors enterDialogueOrFinish -- it kicks the phase and lets advanceMasterPhase drive it. */
|
|
1367
|
+
async function enterMasterOrMux(env, job, preModules) {
|
|
1368
|
+
const envRec = env;
|
|
1369
|
+
const serving = servingForHook(preModules ?? await discoverModules(envRec), "master"); // ui.order; the full master chain
|
|
1370
|
+
const chain = serving.map((mod) => mod.binding);
|
|
1371
|
+
if (!chain.length) {
|
|
1372
|
+
job.phase = "mux";
|
|
1373
|
+
await enterMuxPhase(env, job, preModules);
|
|
1374
|
+
return;
|
|
1375
|
+
} // no master installed: mux as-is
|
|
1376
|
+
// Per-step planner config, clamped against each module's schema (by name), aligned to chain order --
|
|
1377
|
+
// mirrors enterSpeechOrFinish / the finish chain so the audio-master knobs (target_lufs/upscale/format)
|
|
1378
|
+
// actually reach the module instead of dispatching with {}.
|
|
1379
|
+
const configs = resolveFinishConfigs(serving, job.master_config ?? {});
|
|
1380
|
+
job.master = { chain, idx: 0, applied: [], degraded: [], configs };
|
|
1381
|
+
job.phase = "master";
|
|
1382
|
+
await advanceMasterPhase(env, job, preModules);
|
|
1383
|
+
}
|
|
1384
|
+
/** Drive the master chain over the film's audio bed: submit the current step or poll the in-flight one,
|
|
1385
|
+
* folding each mastered bed back into job.audio_key. FAIL-SAFE -- an unbound / failed / stalled step
|
|
1386
|
+
* soft-degrades (passes the CURRENT bed through, records the reason) and the chain advances; the render
|
|
1387
|
+
* NEVER fails on a master miss (#249 / #77). When the chain is exhausted, record the outcome and mux.
|
|
1388
|
+
* One network round-trip (submit OR poll) per step per tick: a synchronous step folds and continues in
|
|
1389
|
+
* the same tick; an async step parks on its poll token and returns (the next advanceFilmJob re-enters). */
|
|
1390
|
+
async function advanceMasterPhase(env, job, preModules) {
|
|
1391
|
+
const m = job.master;
|
|
1392
|
+
if (!m || !job.audio_key) {
|
|
1393
|
+
job.phase = "mux";
|
|
1394
|
+
await enterMuxPhase(env, job, preModules);
|
|
1395
|
+
return;
|
|
1396
|
+
} // defensive: nothing to master
|
|
1397
|
+
const envRec = env;
|
|
1398
|
+
const seconds = filmSeconds(job);
|
|
1399
|
+
// A step in flight can freeze (a RunPod envelope stuck IN_PROGRESS pends forever). If the phase has sat
|
|
1400
|
+
// past the stall ceiling with a step still pending, soft-degrade THIS step (passthrough) and move on --
|
|
1401
|
+
// a stuck polish ships the un-mastered bed, it never hangs the render or drops the film.
|
|
1402
|
+
if (m.poll && phaseAgeSeconds(job) >= MASTER_STALL_SECONDS) {
|
|
1403
|
+
console.warn(`film ${job.film_id}: master step ${m.chain[m.idx]} stalled; passing the bed through`);
|
|
1404
|
+
degradeMasterStep(m, "stalled");
|
|
1405
|
+
}
|
|
1406
|
+
while (!masterChainDone(m)) {
|
|
1407
|
+
const fetcher = resolveFetcher(envRec, m.chain[m.idx]);
|
|
1408
|
+
if (!fetcher) {
|
|
1409
|
+
degradeMasterStep(m, "module not bound");
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
if (!m.poll) {
|
|
1413
|
+
// CREDENTIALLESS module: the core owns the R2 S3 creds and presigns the bed GET + the mastered PUT
|
|
1414
|
+
// (the module/container hold no R2 creds), exactly as the film.finish chain does. Each step masters
|
|
1415
|
+
// the PRIOR step's output (job.audio_key carries forward), so presign per step from the current bed.
|
|
1416
|
+
const audioKey = job.audio_key;
|
|
1417
|
+
// The step's clamped planner config (target_lufs / upscale / format). The output key extension must
|
|
1418
|
+
// match the format the module/container will write, so derive it from the same config.
|
|
1419
|
+
const cfg = m.configs?.[m.idx] ?? {};
|
|
1420
|
+
const format = cfg.format === "mp3" ? "mp3" : "wav";
|
|
1421
|
+
const outputKey = masteredBedKey(audioKey, format);
|
|
1422
|
+
const [audioUrl, outputUrl] = await Promise.all([
|
|
1423
|
+
presignR2Get(env, audioKey, 1800), // 30min: covers a multi-minute CPU master
|
|
1424
|
+
presignR2Put(env, outputKey, 1800),
|
|
1425
|
+
]);
|
|
1426
|
+
const req = {
|
|
1427
|
+
hook: "master",
|
|
1428
|
+
input: {
|
|
1429
|
+
film_id: job.film_id, audio_key: audioKey,
|
|
1430
|
+
audio_url: audioUrl, output_url: outputUrl, output_key: outputKey, seconds,
|
|
1431
|
+
},
|
|
1432
|
+
config: cfg,
|
|
1433
|
+
context: { project: job.project, job_id: job.film_id },
|
|
1434
|
+
};
|
|
1435
|
+
const r = await invokeModule(fetcher, req);
|
|
1436
|
+
if (!r.ok) {
|
|
1437
|
+
const d = classifyFinishRetry(r.error, m.attempts ?? 0, MASTER_STEP_MAX_ATTEMPTS);
|
|
1438
|
+
if (d.action === "retry") {
|
|
1439
|
+
m.attempts = d.attempts;
|
|
1440
|
+
return;
|
|
1441
|
+
} // transient: re-submit next tick
|
|
1442
|
+
degradeMasterStep(m, `invoke failed: ${r.error}`);
|
|
1443
|
+
continue; // terminal: passthrough + advance
|
|
1444
|
+
}
|
|
1445
|
+
if (r.pending) {
|
|
1446
|
+
m.poll = r.poll;
|
|
1447
|
+
m.attempts = 0;
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
if ("output" in r) {
|
|
1451
|
+
const v = hookOutputViolation(m.chain[m.idx], "master", r.output);
|
|
1452
|
+
if (v) {
|
|
1453
|
+
degradeMasterStep(m, v);
|
|
1454
|
+
continue;
|
|
1455
|
+
}
|
|
1456
|
+
job.audio_key = applyMasterOutput(m, job.audio_key, r.output);
|
|
1457
|
+
continue;
|
|
1458
|
+
}
|
|
1459
|
+
degradeMasterStep(m, "module returned neither output nor a poll token");
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
const p = await pollModule(fetcher, { poll: m.poll });
|
|
1463
|
+
if (p.ok && !p.pending) {
|
|
1464
|
+
const out = p.output;
|
|
1465
|
+
const v = hookOutputViolation(m.chain[m.idx], "master", out);
|
|
1466
|
+
if (v) {
|
|
1467
|
+
degradeMasterStep(m, v);
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
job.audio_key = applyMasterOutput(m, job.audio_key, out);
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
if (p.ok)
|
|
1474
|
+
return; // still mastering -- poll again next tick
|
|
1475
|
+
const d = classifyFinishRetry(p.error, m.attempts ?? 0, MASTER_STEP_MAX_ATTEMPTS);
|
|
1476
|
+
if (d.action === "retry") {
|
|
1477
|
+
m.attempts = d.attempts;
|
|
1478
|
+
return;
|
|
1479
|
+
} // transient poll blip: re-poll next tick
|
|
1480
|
+
degradeMasterStep(m, `poll failed: ${p.error}`); // terminal: passthrough + advance
|
|
1481
|
+
}
|
|
1482
|
+
// Chain exhausted: the (maybe mastered) bed is in job.audio_key. A degrade is observable, not a silent green.
|
|
1483
|
+
if (m.degraded.length)
|
|
1484
|
+
console.warn(`film ${job.film_id}: master degraded -- ${m.degraded.join("; ")}`);
|
|
1485
|
+
job.phase = "mux";
|
|
1486
|
+
await enterMuxPhase(env, job, preModules);
|
|
1487
|
+
}
|
|
1488
|
+
async function finishAssembledFilm(env, job, silentKey, preModules) {
|
|
1489
|
+
job.silent_film_key = silentKey;
|
|
1490
|
+
if (!job.audio_key) {
|
|
1491
|
+
job.film_key = silentKey;
|
|
1492
|
+
await transitionToDone(env, job, preModules);
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
// There IS an audio bed: master it (music upscale + loudness) if a master module is installed, then mux.
|
|
1496
|
+
// enterMasterOrMux soft-degrades to a straight mux when no master module is present (or the polish fails).
|
|
1497
|
+
await enterMasterOrMux(env, job, preModules);
|
|
1498
|
+
}
|
|
1499
|
+
async function enterAssemblePhase(env, job, finalClips, preModules) {
|
|
1500
|
+
if (!finalClips.length) {
|
|
1501
|
+
job.phase = "failed";
|
|
1502
|
+
job.error = "no clips to assemble";
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
// Derive completion from R2 presence: if the concat output is already in R2, a prior
|
|
1506
|
+
// attempt's ffmpeg PUT succeeded even though its response was lost (the container 504'd
|
|
1507
|
+
// after writing, or the poll window closed mid-PUT and the job was re-driven). Re-running
|
|
1508
|
+
// the concat would be wasted CPU, so finalize straight from the existing object. This is
|
|
1509
|
+
// what lets a stalled-after-PUT assemble self-heal on the next poll / sweep tick instead of
|
|
1510
|
+
// looping. (issue #122)
|
|
1511
|
+
const outputKey = filmOutKey(job.film_id);
|
|
1512
|
+
if (await r2ObjectExists(env, outputKey)) {
|
|
1513
|
+
job.assemble_attempts = 0;
|
|
1514
|
+
await finishAssembledFilm(env, job, outputKey, preModules);
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
if (!env.VIDEO_FINISH_VPC) {
|
|
1518
|
+
degradeAssembleUnavailable(job, finalClips, "video-finish tier not installed (VIDEO_FINISH_VPC unbound); delivered per-shot clips");
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
const clips = [];
|
|
1522
|
+
for (const c of finalClips) {
|
|
1523
|
+
clips.push({ url: await presignR2Get(env, c.clip_key, 1800) }); // 30min: covers a multi-clip concat
|
|
1524
|
+
}
|
|
1525
|
+
const outputUrl = await presignR2Put(env, outputKey, 1800);
|
|
1526
|
+
// Talking film: when shots carry per-shot dialogue, the lip-sync module baked that audio into each
|
|
1527
|
+
// clip. Tell the container to preserve per-clip audio through the concat (keepClipAudio) instead of
|
|
1528
|
+
// stripping it (-an) -- otherwise the assembled film comes out silent despite the spoken clips.
|
|
1529
|
+
const keepClipAudio = !!job.dialogue_audio && Object.keys(job.dialogue_audio).length > 0;
|
|
1530
|
+
// Resolution/fps are left to the container default (it normalizes the clips); the motion output
|
|
1531
|
+
// does not carry width/height, so matching the source resolution is a later polish, not a gate.
|
|
1532
|
+
const resp = await callVideoFinish(env, { clips, outputUrl, outputKey, keepClipAudio });
|
|
1533
|
+
// A transient gateway outcome (unreachable / 502 / 503 / 504) auto-recovers across polls instead of
|
|
1534
|
+
// going terminal: the clips are intact in R2 and re-PUTting the same film key is idempotent, so keep
|
|
1535
|
+
// phase="assemble" and let the next poll re-attempt against a (by then) warmer container -- bounded so
|
|
1536
|
+
// a genuinely stuck assemble still fails loudly (issue #82).
|
|
1537
|
+
const transport = classifyAssembleTransport(resp ? resp.status : null, job.assemble_attempts ?? 0, MAX_ASSEMBLE_ATTEMPTS);
|
|
1538
|
+
// One assignment for every outcome: the helper returns the next counter value (prior+1 on a transient
|
|
1539
|
+
// failure, 0 once the container gives a definitive answer -- so a slow-but-successful finish never
|
|
1540
|
+
// carries stale attempts toward the cap, and a manual phase-reset starts from a full budget).
|
|
1541
|
+
job.assemble_attempts = transport.attempts;
|
|
1542
|
+
if (transport.state === "retry") {
|
|
1543
|
+
job.phase = "assemble"; // unchanged; next advanceFilmJob poll re-enters this leg
|
|
1544
|
+
job.error = transport.error;
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
if (transport.state === "exhausted") {
|
|
1548
|
+
degradeAssembleUnavailable(job, finalClips, transport.error);
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
// state === "ok": a transient status is never null, so resp is non-null here. The guard keeps the
|
|
1552
|
+
// compiler happy and is a defensive backstop.
|
|
1553
|
+
if (!resp) {
|
|
1554
|
+
degradeAssembleUnavailable(job, finalClips, "video-finish container unreachable; delivered per-shot clips");
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
if (!resp.ok) {
|
|
1558
|
+
// A non-transient error status: the container's own failure (e.g. a 500 with an ffmpeg/assemble
|
|
1559
|
+
// error body). Surface the body -- an opaque "returned 500" is undiagnosable -- and go terminal;
|
|
1560
|
+
// retrying a real assemble error would only loop.
|
|
1561
|
+
let detail = "";
|
|
1562
|
+
try {
|
|
1563
|
+
detail = (await resp.text()).slice(0, 400);
|
|
1564
|
+
}
|
|
1565
|
+
catch { /* body unreadable */ }
|
|
1566
|
+
job.phase = "failed";
|
|
1567
|
+
job.error = `video-finish container returned ${resp.status}${detail ? `: ${detail}` : ""}`;
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
let body;
|
|
1571
|
+
try {
|
|
1572
|
+
body = (await resp.json());
|
|
1573
|
+
}
|
|
1574
|
+
catch {
|
|
1575
|
+
job.phase = "failed";
|
|
1576
|
+
job.error = "video-finish returned a non-JSON response";
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
if (!body.ok) {
|
|
1580
|
+
job.phase = "failed";
|
|
1581
|
+
job.error = `video-finish failed: ${body.error || "unknown error"}`;
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
// #697/#698: capture the ACTUAL per-clip assembled seconds the container probed (submit order ==
|
|
1585
|
+
// finalClips order). Persisted so the later film.finish chain times captions to the real cut (#698).
|
|
1586
|
+
const actual = mapClipDurationsToShots(finalClips, body.clipDurations);
|
|
1587
|
+
job.actual_clip_durations = Object.keys(actual).length > 0 ? actual : undefined;
|
|
1588
|
+
// #697 per-shot duration honesty gate: an outlived/retried encode race can deliver a truncated clip
|
|
1589
|
+
// (a 0.085s "4s" shot) that the pixel gate (#558) cannot see -- it checks pixel content, not length.
|
|
1590
|
+
// Compare each clip against its PLANNED seconds and FAIL LOUD below the floor, rather than ship a film
|
|
1591
|
+
// that is a subliminal flash in front of the real footage (#245/#249: a broken deliverable fails the
|
|
1592
|
+
// render, never a silent green). Fires only on evidence -- an older container reporting no durations
|
|
1593
|
+
// leaves the map empty and the gate no-ops (logged, not a false failure).
|
|
1594
|
+
if (Object.keys(actual).length > 0) {
|
|
1595
|
+
const bundleDurations = await readShotDurationsFromBundle(env, job.bundle_key);
|
|
1596
|
+
const planned = resolvePlannedSeconds(job.scenes, bundleDurations);
|
|
1597
|
+
const fraction = resolveClipDurationFloor(typeof env.FILM_CLIP_DURATION_FLOOR === "string" ? env.FILM_CLIP_DURATION_FLOOR : undefined);
|
|
1598
|
+
const shortfalls = findClipDurationShortfalls(finalClips, actual, planned, fraction);
|
|
1599
|
+
if (shortfalls.length > 0) {
|
|
1600
|
+
job.phase = "failed";
|
|
1601
|
+
job.error = `duration gate: ${shortfalls.length} shot(s) delivered below ${Math.round(fraction * 100)}% of plan: ` +
|
|
1602
|
+
shortfalls.map((sf) => `${sf.shot_id} ${sf.actual.toFixed(2)}s vs planned ${sf.planned.toFixed(2)}s (floor ${sf.floor.toFixed(2)}s)`).join("; ");
|
|
1603
|
+
console.warn(`film ${job.film_id}: ${job.error}`);
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
else {
|
|
1608
|
+
console.warn(`film ${job.film_id}: video-finish reported no per-clip durations; duration gate skipped (redeploy video-finish to arm #697)`);
|
|
1609
|
+
}
|
|
1610
|
+
await finishAssembledFilm(env, job, outputKey, preModules);
|
|
1611
|
+
}
|
|
1612
|
+
/** Start a film at the clips phase using existing keyframe keys (finalize / cloud / hybrid). */
|
|
1613
|
+
export async function startFilmFromKeyframes(env, args, preModules) {
|
|
1614
|
+
const scenes = coerceSceneIds(args.scenes ?? []);
|
|
1615
|
+
const stagedAudio = await resolveStagedAudioKey(env, args.audio_key);
|
|
1616
|
+
const { matched, missing } = joinKeyframesToScenes(scenes, args.keyframes || []);
|
|
1617
|
+
const job = {
|
|
1618
|
+
film_id: "film-" + crypto.randomUUID(),
|
|
1619
|
+
project: args.project,
|
|
1620
|
+
bundle_key: args.bundle_key,
|
|
1621
|
+
scenes,
|
|
1622
|
+
motion_backend: args.motion_backend ?? null,
|
|
1623
|
+
motion_config: args.motion_config ?? {},
|
|
1624
|
+
finish_config: args.finish_config ?? {},
|
|
1625
|
+
speech_config: args.speech_config ?? {},
|
|
1626
|
+
film_finish_config: args.film_finish_config ?? {},
|
|
1627
|
+
master_config: args.master_config ?? {},
|
|
1628
|
+
keyframe_binding: null,
|
|
1629
|
+
phase: "failed",
|
|
1630
|
+
created_at: Date.now(),
|
|
1631
|
+
phase_started_at: Date.now(),
|
|
1632
|
+
derive_mode: args.derive_mode,
|
|
1633
|
+
parent_render_id: args.parent_render_id,
|
|
1634
|
+
audio_key: stagedAudio,
|
|
1635
|
+
};
|
|
1636
|
+
if (!matched.length) {
|
|
1637
|
+
job.error = `no keyframes matched requested shots (missing: ${missing.join(", ")})`;
|
|
1638
|
+
await putFilm(env, job);
|
|
1639
|
+
return job;
|
|
1640
|
+
}
|
|
1641
|
+
const shots = [];
|
|
1642
|
+
for (const m of matched) {
|
|
1643
|
+
const keyframe_url = await presignR2Get(env, m.keyframe_key, 1800);
|
|
1644
|
+
shots.push({
|
|
1645
|
+
shot_id: m.shot_id,
|
|
1646
|
+
keyframe_url,
|
|
1647
|
+
keyframe_key: m.keyframe_key,
|
|
1648
|
+
prompt: m.prompt,
|
|
1649
|
+
seconds: m.seconds,
|
|
1650
|
+
motion_backend: args.per_shot_motion?.[m.shot_id],
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
const clip = await startClipJob(env, {
|
|
1654
|
+
project: args.project,
|
|
1655
|
+
shots,
|
|
1656
|
+
motion_backend: args.motion_backend,
|
|
1657
|
+
config: args.motion_config,
|
|
1658
|
+
module_configs: args.motion_configs,
|
|
1659
|
+
}, preModules);
|
|
1660
|
+
job.clip_job_id = clip.job_id;
|
|
1661
|
+
job.phase = summarizeJob(clip).failed === clip.shots.length ? "failed" : "clips";
|
|
1662
|
+
if (job.phase === "failed")
|
|
1663
|
+
job.error = "every clip submission failed";
|
|
1664
|
+
await putFilm(env, job);
|
|
1665
|
+
return job;
|
|
1666
|
+
}
|
|
1667
|
+
/** Start a film job: resolve the keyframe module, submit the project preview, persist the poll token. */
|
|
1668
|
+
export async function startFilmJob(env, args, preModules) {
|
|
1669
|
+
const scenes = coerceSceneIds(args.scenes ?? []);
|
|
1670
|
+
// Dialogue lines must join on the SAME coerced ids as the scenes, or a caller-supplied id scheme
|
|
1671
|
+
// (`s1`/`s2`) strands the TTS audio under keys no consumer reads (silent + uncaptioned film, #563).
|
|
1672
|
+
const dialogueLines = coerceDialogueLineIds(args.scenes ?? [], args.dialogue_lines);
|
|
1673
|
+
const stagedAudio = args.clips_only ? undefined : await resolveStagedAudioKey(env, args.audio_key);
|
|
1674
|
+
const envRec = env;
|
|
1675
|
+
const modules = preModules ?? await discoverModules(envRec);
|
|
1676
|
+
// Honor the planner's keyframe backend pick (e.g. cloud-keyframe) over the ui.order default, mirroring
|
|
1677
|
+
// motion.backend selection. An explicit-but-unknown choice resolves to null -> the render fails loud
|
|
1678
|
+
// with a clear "keyframe module <choice> not installed" rather than silently swapping backends.
|
|
1679
|
+
const kfServing = servingForHook(modules, "keyframe");
|
|
1680
|
+
const kf = (args.keyframe_backend ? kfServing.find((m) => m.name === args.keyframe_backend) : kfServing[0]) ?? null;
|
|
1681
|
+
const job = {
|
|
1682
|
+
film_id: "film-" + crypto.randomUUID(),
|
|
1683
|
+
project: args.project, bundle_key: args.bundle_key, scenes,
|
|
1684
|
+
motion_backend: args.motion_backend ?? null, motion_config: args.motion_config ?? {},
|
|
1685
|
+
finish_config: args.finish_config ?? {},
|
|
1686
|
+
speech_config: args.speech_config ?? {},
|
|
1687
|
+
film_finish_config: args.film_finish_config ?? {},
|
|
1688
|
+
master_config: args.master_config ?? {},
|
|
1689
|
+
keyframes_only: !!args.keyframes_only,
|
|
1690
|
+
clips_only: !!args.clips_only,
|
|
1691
|
+
audio_key: stagedAudio,
|
|
1692
|
+
film_titles: args.film_titles,
|
|
1693
|
+
keyframe_binding: kf ? kf.binding : null, phase: "keyframe", created_at: Date.now(),
|
|
1694
|
+
phase_started_at: Date.now(),
|
|
1695
|
+
dialogue_lines: dialogueLines && dialogueLines.length ? dialogueLines : undefined,
|
|
1696
|
+
cast_loras: args.cast_loras && Object.keys(args.cast_loras).length ? args.cast_loras : undefined,
|
|
1697
|
+
// #762: persist the requested quality tier so filmRenderRowSeedFromJob records an honest label.
|
|
1698
|
+
quality_tier: args.quality_tier,
|
|
1699
|
+
};
|
|
1700
|
+
const fetcher = kf ? resolveFetcher(envRec, kf.binding) : null;
|
|
1701
|
+
if (!kf || !fetcher) {
|
|
1702
|
+
job.phase = "failed";
|
|
1703
|
+
job.error = kf
|
|
1704
|
+
? `keyframe module ${kf.name} (${kf.binding}) is not bound`
|
|
1705
|
+
: (args.keyframe_backend ? `keyframe module ${args.keyframe_backend} not installed` : "no keyframe module installed");
|
|
1706
|
+
}
|
|
1707
|
+
else {
|
|
1708
|
+
const config = validateConfig(kf.config_schema, args.keyframe_config);
|
|
1709
|
+
const keyframeInput = {
|
|
1710
|
+
project: args.project,
|
|
1711
|
+
bundle_key: args.bundle_key,
|
|
1712
|
+
shot_ids: scenes.map((s) => s.shot_id),
|
|
1713
|
+
};
|
|
1714
|
+
if (args.pretrained_loras && Object.keys(args.pretrained_loras).length) {
|
|
1715
|
+
keyframeInput.pretrained_loras = { ...args.pretrained_loras };
|
|
1716
|
+
}
|
|
1717
|
+
const r = await invokeModule(fetcher, {
|
|
1718
|
+
hook: "keyframe",
|
|
1719
|
+
input: keyframeInput,
|
|
1720
|
+
config,
|
|
1721
|
+
context: { project: args.project, job_id: job.film_id },
|
|
1722
|
+
});
|
|
1723
|
+
if (!r.ok) {
|
|
1724
|
+
job.phase = "failed";
|
|
1725
|
+
job.error = r.error;
|
|
1726
|
+
}
|
|
1727
|
+
else if (r.pending) {
|
|
1728
|
+
job.keyframe_poll = r.poll;
|
|
1729
|
+
job.keyframe_job_id = r.jobId;
|
|
1730
|
+
}
|
|
1731
|
+
else if ("output" in r) {
|
|
1732
|
+
const v = hookOutputViolation(kf.name, "keyframe", r.output);
|
|
1733
|
+
if (v) {
|
|
1734
|
+
job.phase = "failed";
|
|
1735
|
+
job.error = v;
|
|
1736
|
+
}
|
|
1737
|
+
else {
|
|
1738
|
+
await afterKeyframeOutput(env, job, r.output, modules);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
else {
|
|
1742
|
+
job.phase = "failed";
|
|
1743
|
+
job.error = "keyframe module returned neither output nor a poll token";
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
await putFilm(env, job);
|
|
1747
|
+
return job;
|
|
1748
|
+
}
|
|
1749
|
+
/** Mark an in-flight film job cancelled. Terminal jobs are returned unchanged. */
|
|
1750
|
+
export async function cancelFilmJob(env, filmId) {
|
|
1751
|
+
const obj = await env.R2_RENDERS.get(filmKey(filmId));
|
|
1752
|
+
if (!obj)
|
|
1753
|
+
return null;
|
|
1754
|
+
const job = JSON.parse(await obj.text());
|
|
1755
|
+
if (job.phase === "done" || job.phase === "failed")
|
|
1756
|
+
return job;
|
|
1757
|
+
// #328: STOP the in-flight RunPod job, not just the studio state -- a cancel that leaves the GPU
|
|
1758
|
+
// training is a lie to the user and a money leak. Run this BEFORE mutating phase, so the helper can
|
|
1759
|
+
// still see the in-flight keyframe poll token. (Motion/finish/speech phases adopt /cancel in
|
|
1760
|
+
// follow-ups once their modules advertise `cancelable`; until then cancelInFlightKeyframe is a no-op
|
|
1761
|
+
// off the keyframe phase and the orphan, if any, is the existing behavior -- not made worse here.)
|
|
1762
|
+
await cancelInFlightKeyframe(env, job);
|
|
1763
|
+
// #536: the motion-phase sibling -- STOP any in-flight clip shots RunPod jobs too, so a user cancel off
|
|
1764
|
+
// the clips phase does not leave the GPU running (the follow-up the cancelInFlightKeyframe comment named).
|
|
1765
|
+
if (job.clip_job_id)
|
|
1766
|
+
await cancelInFlightClips(env, job.clip_job_id);
|
|
1767
|
+
job.cancelled = true;
|
|
1768
|
+
job.phase = "failed";
|
|
1769
|
+
job.error = "cancelled";
|
|
1770
|
+
await putFilm(env, job);
|
|
1771
|
+
return job;
|
|
1772
|
+
}
|
|
1773
|
+
/** List the keyframe PNGs the GPU wrote for a project and join them to the job's scenes. The keyframe
|
|
1774
|
+
* stage writes `renders/<project>/keyframes/<shot_id>.png` itself (its own R2 creds; see the keyframe
|
|
1775
|
+
* module), so the core can recover an orphaned keyframe phase straight from R2 presence -- no GPU re-
|
|
1776
|
+
* run. Returns only keyframes whose shot_id is in the storyboard, so a stale PNG from an older render
|
|
1777
|
+
* of the same project can never inject a shot the film did not ask for. Also drops any keyframe written BEFORE
|
|
1778
|
+
* this run started (`createdAtMs`, the film job created_at): a prior render of the same project name leaves a
|
|
1779
|
+
* FULL stale set at the identical shot_id paths, which the pending-poll fast path would otherwise adopt on
|
|
1780
|
+
* tick one -- cancelling the live producer and shipping wrong content silently (#661, the #245/#249 class).
|
|
1781
|
+
* This run own orphans (the #129/#619/#143 recovery) always upload AFTER created_at, so legit recovery
|
|
1782
|
+
* survives; a leftover from an older render becomes invisible. */
|
|
1783
|
+
export async function listProjectKeyframes(env, project, scenes, createdAtMs) {
|
|
1784
|
+
const prefix = `renders/${project}/keyframes/`;
|
|
1785
|
+
const wanted = new Set(scenes.map((s) => s.shot_id));
|
|
1786
|
+
const out = [];
|
|
1787
|
+
let cursor;
|
|
1788
|
+
do {
|
|
1789
|
+
const listed = await env.R2_RENDERS.list({ prefix, cursor, limit: 1000 });
|
|
1790
|
+
for (const o of listed.objects) {
|
|
1791
|
+
// Freshness guard (#661): skip any object written BEFORE this run started (R2Object.uploaded is a Date,
|
|
1792
|
+
// job stamps are epoch ms -- normalize explicitly). Only enforced when a floor is passed.
|
|
1793
|
+
if (createdAtMs && o.uploaded.getTime() < createdAtMs)
|
|
1794
|
+
continue;
|
|
1795
|
+
const file = o.key.slice(prefix.length);
|
|
1796
|
+
// Images only: the backend also writes a `<shot_id>.hash` param-hash sidecar per keyframe
|
|
1797
|
+
// (backend #112, reuse-vs-regen). Without this filter the sidecar shares the shot_id, sorts
|
|
1798
|
+
// before .png, and the first-seen dedupe below adopts the 16-byte hash as the keyframe (#578).
|
|
1799
|
+
if (!/\.(png|jpe?g|webp)$/i.test(file))
|
|
1800
|
+
continue;
|
|
1801
|
+
const shot_id = file.replace(/\.[^.]+$/, ""); // drop the extension (.png)
|
|
1802
|
+
if (shot_id && wanted.has(shot_id))
|
|
1803
|
+
out.push({ shot_id, keyframe_key: o.key });
|
|
1804
|
+
}
|
|
1805
|
+
cursor = listed.truncated ? listed.cursor : undefined;
|
|
1806
|
+
} while (cursor);
|
|
1807
|
+
// De-dupe (a project could in principle hold .png + another ext for a shot); keep the first seen.
|
|
1808
|
+
const seen = new Set();
|
|
1809
|
+
return out.filter((k) => (seen.has(k.shot_id) ? false : (seen.add(k.shot_id), true)));
|
|
1810
|
+
}
|
|
1811
|
+
/** True iff EVERY scene's keyframe is already in R2 (the full set, one per shot). Gate for adopting on a
|
|
1812
|
+
* pending poll (#129 envelope-freeze): a partial set means generation is still in flight, so we must NOT
|
|
1813
|
+
* advance early -- only adopt once the set is complete. (The 20min stall backstop is intentionally
|
|
1814
|
+
* lenient on partials, treating absent shots as genuine non-renders at the ceiling.) */
|
|
1815
|
+
export async function keyframeSetCompleteInR2(env, job) {
|
|
1816
|
+
if (!job.scenes.length)
|
|
1817
|
+
return false;
|
|
1818
|
+
const present = await listProjectKeyframes(env, job.project, job.scenes, job.created_at);
|
|
1819
|
+
const have = new Set(present.map((k) => k.shot_id));
|
|
1820
|
+
return job.scenes.every((s) => have.has(s.shot_id));
|
|
1821
|
+
}
|
|
1822
|
+
/** Cancel the film's in-flight keyframe RunPod job THROUGH its module, honestly. No-op when no keyframe
|
|
1823
|
+
* job is in flight (wrong phase, no poll token, or no bound backend). When the bound module is missing
|
|
1824
|
+
* or not `cancelable`, or the cancel call fails, we LOG the orphan rather than swallow it: an orphaned
|
|
1825
|
+
* GPU job is a money leak that betrays scale-to-zero (#327 / #328), so it stays visible even when we
|
|
1826
|
+
* cannot stop it. Read keyframe_poll BEFORE the caller clears it. Exported for the orchestrator
|
|
1827
|
+
* unit test (it asserts the adopt + DELETE-cancel paths actually issue a cancel). */
|
|
1828
|
+
export async function cancelInFlightKeyframe(env, job) {
|
|
1829
|
+
if (job.phase !== "keyframe" || !job.keyframe_poll || !job.keyframe_binding)
|
|
1830
|
+
return;
|
|
1831
|
+
const poll = job.keyframe_poll;
|
|
1832
|
+
// NAME the backend job in every orphan log so a left-running job is actionable (an operator can
|
|
1833
|
+
// cancel it by hand -- exactly how this bug was caught). keyframe_job_id comes from the module's
|
|
1834
|
+
// #318 jobId on the pending invoke; "(job id unknown)" only if a module omitted that optional field.
|
|
1835
|
+
const jobId = job.keyframe_job_id ?? "(job id unknown)";
|
|
1836
|
+
const envRec = env;
|
|
1837
|
+
const modules = await discoverModules(envRec);
|
|
1838
|
+
const kf = modules.find((m) => m.binding === job.keyframe_binding) ?? null;
|
|
1839
|
+
const fetcher = kf ? resolveFetcher(envRec, kf.binding) : null;
|
|
1840
|
+
if (!kf || !fetcher) {
|
|
1841
|
+
console.warn(`film ${job.film_id}: cannot cancel in-flight keyframe job -- module ${job.keyframe_binding} not bound; RunPod job ${jobId} left running (ORPHAN) (#327)`);
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
if (!kf.cancelable) {
|
|
1845
|
+
console.warn(`film ${job.film_id}: keyframe module ${kf.name} has no cancel primitive (cancelable=false) -- RunPod job ${jobId} left running (ORPHAN) (#327)`);
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
const r = await cancelModule(fetcher, { poll });
|
|
1849
|
+
if (r.ok) {
|
|
1850
|
+
console.warn(`film ${job.film_id}: cancelled in-flight keyframe RunPod job ${jobId} via ${kf.name} (#327)`);
|
|
1851
|
+
}
|
|
1852
|
+
else {
|
|
1853
|
+
console.warn(`film ${job.film_id}: keyframe cancel FAILED (${r.error}) -- RunPod job ${jobId} left running (ORPHAN) (#327)`);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
/** Recover a keyframe phase whose module poll has gone stale (RunPod GC'd the finished job) by adopting
|
|
1857
|
+
* the keyframes already in R2. Keyframes upload to R2 progressively as the batch renders, so a stall
|
|
1858
|
+
* mid-batch leaves a PARTIAL set; adopting it is fine, but treating it as THE set -- cancelling the
|
|
1859
|
+
* still-running producer and advancing -- ships a silent half-film (#619). So this mirrors the clips
|
|
1860
|
+
* recovery (#143): it advances (cancel + afterKeyframeOutput) ONLY when the adopted set covers every
|
|
1861
|
+
* scene, or once the phase ceiling has expired. Below the ceiling a partial set HOLDS -- no cancel, no
|
|
1862
|
+
* advance, keyframe_recovered NOT set -- so the next stalled sweep picks up the keyframes that land
|
|
1863
|
+
* after this pass. At the ceiling with a partial set it advances LOUDLY, delivering what rendered:
|
|
1864
|
+
* records the dropped scenes on `keyframes_incomplete`, emits the structured event, and never lets the
|
|
1865
|
+
* film report a clean complete over the rebased total (#245/#249). `atCeiling` is true once the phase
|
|
1866
|
+
* has passed PHASE_HARD_DEADLINE_SECONDS. Returns true iff it advanced the phase; a partial hold (or
|
|
1867
|
+
* nothing in R2) returns false and leaves the phase in "keyframe". */
|
|
1868
|
+
async function recoverStalledKeyframePhase(env, job, preModules, atCeiling) {
|
|
1869
|
+
const adopted = await listProjectKeyframes(env, job.project, job.scenes, job.created_at);
|
|
1870
|
+
if (!adopted.length)
|
|
1871
|
+
return false; // nothing in R2 to adopt -- not actually complete; let the ceiling hard-fail
|
|
1872
|
+
const covered = new Set(adopted.map((k) => k.shot_id));
|
|
1873
|
+
const dropped = job.scenes.filter((s) => !covered.has(s.shot_id)).map((s) => s.shot_id);
|
|
1874
|
+
if (dropped.length && !atCeiling) {
|
|
1875
|
+
// Partial set, still inside the phase window: the rest of the batch may still be uploading. HOLD --
|
|
1876
|
+
// do NOT cancel the live producer and do NOT advance (that is exactly the #619 silent-half-film bug).
|
|
1877
|
+
// Re-fires every stalled sweep (like the clips recovery, #143) until the set is complete or the ceiling.
|
|
1878
|
+
console.warn(`film ${job.film_id}: keyframe poll stale with a PARTIAL set (${adopted.length}/${job.scenes.length} in R2; missing ${dropped.join(", ")}); holding, not advancing (#619)`);
|
|
1879
|
+
return false;
|
|
1880
|
+
}
|
|
1881
|
+
if (dropped.length) {
|
|
1882
|
+
// At the ceiling with a partial set: the missing scenes did not render and will not. Advance with
|
|
1883
|
+
// what landed, but LOUDLY -- record the drop + emit the event, so the film never reports a clean
|
|
1884
|
+
// complete over the rebased (smaller) shot total (#619, clips-delivered degrade discipline #245/#249).
|
|
1885
|
+
job.keyframes_incomplete = { adopted: adopted.length, expected: job.scenes.length, dropped };
|
|
1886
|
+
emitKeyframesIncomplete(job);
|
|
1887
|
+
console.warn(`film ${job.film_id}: keyframe phase hit the ceiling with only ${adopted.length}/${job.scenes.length} keyframes; delivering the rendered scenes, dropped ${dropped.join(", ")} (#619)`);
|
|
1888
|
+
}
|
|
1889
|
+
else {
|
|
1890
|
+
console.warn(`film ${job.film_id}: keyframe poll stale, adopting the full set of ${adopted.length} keyframes from R2 (#129)`);
|
|
1891
|
+
}
|
|
1892
|
+
// #327: STOP the still-running RunPod job BEFORE discarding its poll token. Adopting the cached
|
|
1893
|
+
// keyframes satisfies the work, but the GPU job keeps training/rendering unless we cancel it; clearing
|
|
1894
|
+
// keyframe_poll without cancelling is exactly what orphaned it. Best-effort, honest-degrade-logged.
|
|
1895
|
+
await cancelInFlightKeyframe(env, job);
|
|
1896
|
+
job.keyframe_recovered = true;
|
|
1897
|
+
job.keyframe_poll = undefined; // the RunPod job is cancelled (or logged as an orphan) above
|
|
1898
|
+
await afterKeyframeOutput(env, job, { project: job.project, keyframes: adopted }, preModules);
|
|
1899
|
+
return true;
|
|
1900
|
+
}
|
|
1901
|
+
// clipFileMatchesShot + the shot-id->clip-key R2 listing live in render-orchestrator (the layer that owns
|
|
1902
|
+
// the clip job + advanceClipJob), so the fail-time reclaim and the stall-recovery share ONE matcher (no
|
|
1903
|
+
// drift). listProjectClips here is the scenes-shaped wrapper the film recovery uses.
|
|
1904
|
+
export { clipFileMatchesShot };
|
|
1905
|
+
/** List the motion clips the GPU wrote for a project, joined to the job's scenes by shot id (scene-shaped
|
|
1906
|
+
* wrapper over render-orchestrator's listClipsByShotId). When a motion.backend poll never resolves (GC'd
|
|
1907
|
+
* RunPod job), the clip is still in R2; matching by shot-id boundary recovers a stalled clips phase from
|
|
1908
|
+
* R2 presence, no GPU re-run. Only shots in the storyboard are returned. */
|
|
1909
|
+
export async function listProjectClips(env, project, scenes, createdAtMs) {
|
|
1910
|
+
const wanted = scenes.map((s) => s.shot_id);
|
|
1911
|
+
const found = await listClipsByShotId(env, project, wanted, clipFileMatchesShot, createdAtMs);
|
|
1912
|
+
return wanted.filter((s) => found.has(s)).map((s) => ({ shot_id: s, clip_key: found.get(s) }));
|
|
1913
|
+
}
|
|
1914
|
+
/** Recover a clips phase whose motion.backend poll has gone stale by adopting the clips already in R2.
|
|
1915
|
+
* Loads the clip job doc, marks any not-yet-done shot whose clip IS in R2 done with that key (pending OR
|
|
1916
|
+
* a shot the module prematurely failed -- artifact present in R2 is the source of truth and overrides a
|
|
1917
|
+
* module's failure verdict; #141), re-PUTs the clip doc, and -- only once every shot is terminal --
|
|
1918
|
+
* advances to the finish chain exactly as a normal clips completion would.
|
|
1919
|
+
*
|
|
1920
|
+
* RE-FIRES across sweeps (issue #143): the 10 clips finish + go stale at DIFFERENT times, so one pass may
|
|
1921
|
+
* adopt only the shots whose clips have landed so far while others are still rendering. This must run
|
|
1922
|
+
* every stalled sweep until the job is complete -- so it does NOT set a one-shot `clips_recovered` gate on
|
|
1923
|
+
* a partial pass (unlike the keyframe batch, which completes all at once). `clips_recovered` is set ONLY
|
|
1924
|
+
* when the job is complete and we advance to finish -- a record that adoption closed the job, not a guard
|
|
1925
|
+
* that would block the next partial pass. Returns true iff it advanced the film phase out of "clips".
|
|
1926
|
+
* A partial pass returns false (phase stays "clips"; the next stalled sweep re-attempts the rest); a pass
|
|
1927
|
+
* that adopts nothing AND finds nothing already terminal also returns false (the hard ceiling decides). */
|
|
1928
|
+
async function recoverStalledClipsPhase(env, job, preModules) {
|
|
1929
|
+
if (!job.clip_job_id)
|
|
1930
|
+
return false;
|
|
1931
|
+
const cjObj = await env.R2_RENDERS.get(clipDocKey(job.clip_job_id));
|
|
1932
|
+
if (!cjObj)
|
|
1933
|
+
return false;
|
|
1934
|
+
const clipJob = JSON.parse(await cjObj.text());
|
|
1935
|
+
// Same R2-presence reclaim the clip leg uses (adopt any not-done shot whose clip is in R2 -- pending OR
|
|
1936
|
+
// a module fast-fail; the artifact wins). Shared helper = one matcher, no drift. Persist partial progress
|
|
1937
|
+
// so a later sweep starts from it (idempotent re-PUT); a shot with no R2 clip is left for the next sweep.
|
|
1938
|
+
const adopted = await reclaimClipsFromR2(env, clipJob);
|
|
1939
|
+
if (adopted) {
|
|
1940
|
+
await env.R2_RENDERS.put(clipDocKey(job.clip_job_id), JSON.stringify(clipJob), { httpMetadata: { contentType: "application/json" } });
|
|
1941
|
+
console.warn(`film ${job.film_id}: clips poll stale, adopted ${adopted} orphaned clips from R2 this pass (#143)`);
|
|
1942
|
+
}
|
|
1943
|
+
// Only advance once the WHOLE job is terminal -- otherwise stay in "clips" and let the next stalled sweep
|
|
1944
|
+
// pick up the shots that have since landed. Do NOT set a one-shot gate on a partial pass.
|
|
1945
|
+
if (!summarizeJob(clipJob).complete)
|
|
1946
|
+
return false;
|
|
1947
|
+
job.clips_recovered = true;
|
|
1948
|
+
await enterFinishPhase(env, job, clipJob, preModules);
|
|
1949
|
+
return true;
|
|
1950
|
+
}
|
|
1951
|
+
/** The stall-recovery pass, run after the normal phase advance. For a pollable phase that has not
|
|
1952
|
+
* progressed within its deadline: try a same-phase recovery (keyframe adoption from R2), else, once
|
|
1953
|
+
* past the absolute ceiling, fail loudly so a wedged render surfaces instead of hanging forever (#129).
|
|
1954
|
+
* Returns true iff it changed the phase (so the caller re-stamps phase_started_at + persists). */
|
|
1955
|
+
async function recoverStalledPhase(env, job, preModules, now = Date.now()) {
|
|
1956
|
+
if (!POLLABLE_PHASES.has(job.phase))
|
|
1957
|
+
return false;
|
|
1958
|
+
const age = phaseAgeSeconds(job, now);
|
|
1959
|
+
// Same-phase recovery: a keyframe poll that never resolved, but keyframes are landing in R2. Mirrors
|
|
1960
|
+
// the clips recovery (#143/#619): a partial set below the ceiling HOLDS (re-fires next sweep), the full
|
|
1961
|
+
// set advances, and the ceiling advances what rendered with a loud keyframes_incomplete degrade. NO
|
|
1962
|
+
// one-shot gate on a partial pass -- keyframe_recovered is set only when it actually advances, so the
|
|
1963
|
+
// !keyframe_recovered guard just stops a re-run AFTER the phase has moved on.
|
|
1964
|
+
if (job.phase === "keyframe" && !job.keyframe_recovered && age >= KEYFRAME_STALL_SECONDS) {
|
|
1965
|
+
if (await recoverStalledKeyframePhase(env, job, preModules, age >= PHASE_HARD_DEADLINE_SECONDS))
|
|
1966
|
+
return true;
|
|
1967
|
+
}
|
|
1968
|
+
// Same-phase recovery: a clips (motion.backend) poll that never resolved, but the clips are in R2
|
|
1969
|
+
// (issue #139). Symmetric to keyframe adoption -- collect the orphaned clips by shot name and advance
|
|
1970
|
+
// to finish, so an own-gpu render whose GPU work completed does not loud-fail with its clips intact.
|
|
1971
|
+
// NO !clips_recovered guard (issue #143): clips finish + go stale at DIFFERENT times, so this must
|
|
1972
|
+
// RE-FIRE every stalled sweep to pick up shots whose clips land after an earlier partial pass;
|
|
1973
|
+
// recoverStalledClipsPhase only advances (and sets clips_recovered) once the whole job is complete.
|
|
1974
|
+
if (job.phase === "clips" && age >= KEYFRAME_STALL_SECONDS) {
|
|
1975
|
+
if (await recoverStalledClipsPhase(env, job, preModules))
|
|
1976
|
+
return true;
|
|
1977
|
+
}
|
|
1978
|
+
// Absolute ceiling: a still-pollable phase this old is genuinely wedged with nothing in R2 to adopt
|
|
1979
|
+
// (keyframe/clips adoption above already rescued any phase whose artifacts landed; a finish phase has
|
|
1980
|
+
// no adoption yet; or the GPU truly produced nothing). Fail loudly rather than hang. For the per-shot
|
|
1981
|
+
// phases the ceiling tracks last_progress_at (#704): a slow local-gpu card landing one clip every few
|
|
1982
|
+
// minutes is healthy however long the phase runs, so only 90min with NO new shot fails; the batch
|
|
1983
|
+
// keyframe phase keeps the phase_started_at clock (age above).
|
|
1984
|
+
const ceilingAge = ceilingAgeSeconds(job, now);
|
|
1985
|
+
if (ceilingAge >= PHASE_HARD_DEADLINE_SECONDS) {
|
|
1986
|
+
const stuckPhase = job.phase;
|
|
1987
|
+
job.phase = "failed";
|
|
1988
|
+
job.error = `render stalled in phase "${stuckPhase}" for ${Math.floor(ceilingAge / 60)}min with no progress; failing so it does not hang (resubmit to retry) (#129/#704)`;
|
|
1989
|
+
return true;
|
|
1990
|
+
}
|
|
1991
|
+
return false;
|
|
1992
|
+
}
|
|
1993
|
+
/** Read the film + clip job docs without advancing anything: what a driver that LOST the advance
|
|
1994
|
+
* lease returns (the winner's state propagates via the doc; the loser just reports it). */
|
|
1995
|
+
async function readFilmJobReadOnly(env, filmId) {
|
|
1996
|
+
const obj = await env.R2_RENDERS.get(filmKey(filmId));
|
|
1997
|
+
if (!obj)
|
|
1998
|
+
return null;
|
|
1999
|
+
const job = JSON.parse(await obj.text());
|
|
2000
|
+
let clipJob = null;
|
|
2001
|
+
if (job.clip_job_id) {
|
|
2002
|
+
const cj = await env.R2_RENDERS.get(clipDocKey(job.clip_job_id));
|
|
2003
|
+
if (cj)
|
|
2004
|
+
clipJob = JSON.parse(await cj.text());
|
|
2005
|
+
}
|
|
2006
|
+
return { job, clipJob };
|
|
2007
|
+
}
|
|
2008
|
+
/** Claim the film-advance lease, failing OPEN: the lease is a protective serializer, not a
|
|
2009
|
+
* correctness gate on the doc contents, so a D1 blip must not stall every render (the sweep is
|
|
2010
|
+
* the only driver for fire-and-forget jobs). On a claim error the tick advances unguarded --
|
|
2011
|
+
* exactly the pre-lease behavior -- and says so. */
|
|
2012
|
+
async function claimAdvanceOrFailOpen(env, filmId) {
|
|
2013
|
+
if (!env.DB)
|
|
2014
|
+
return { won: true }; // no D1 bound (unit-test fakes): nothing to claim against
|
|
2015
|
+
try {
|
|
2016
|
+
return await claimFilmAdvance(env, filmId);
|
|
2017
|
+
}
|
|
2018
|
+
catch (e) {
|
|
2019
|
+
console.warn(`film ${filmId}: advance lease unavailable (${e.message}); advancing unguarded`);
|
|
2020
|
+
return { won: true };
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
/** Advance a film job across its two phases. Returns the job + the underlying clip job (for the
|
|
2024
|
+
* summary), or null if no such film job exists.
|
|
2025
|
+
*
|
|
2026
|
+
* ONE DRIVER PER TICK (S4): this function is driven concurrently by the 1-minute cron sweep AND
|
|
2027
|
+
* every client status poll, and its body is an unlocked read-modify-write on the R2 job doc with
|
|
2028
|
+
* submit-bearing legs (clip start, dialogue batch, per-shot finish/speech/master steps, mux,
|
|
2029
|
+
* notify). Two concurrent drivers could each observe phase N incomplete and BOTH submit phase
|
|
2030
|
+
* N+1's external work -- duplicated GPU spend -- and clobber each other's doc writes (a lost
|
|
2031
|
+
* poll token orphans a RunPod job). So the whole tick runs under a D1 lease (claimFilmAdvance,
|
|
2032
|
+
* the claimFinish conditional-UPDATE pattern): the loser skips quietly and reports the doc
|
|
2033
|
+
* read-only; the lease is released after the tick and expires on its own if the winner crashed,
|
|
2034
|
+
* so a genuine retry is never deadlocked. */
|
|
2035
|
+
export async function advanceFilmJob(env, filmId) {
|
|
2036
|
+
const claim = await claimAdvanceOrFailOpen(env, filmId);
|
|
2037
|
+
if (!claim.won)
|
|
2038
|
+
return readFilmJobReadOnly(env, filmId);
|
|
2039
|
+
try {
|
|
2040
|
+
return await advanceFilmJobLocked(env, filmId);
|
|
2041
|
+
}
|
|
2042
|
+
finally {
|
|
2043
|
+
if (claim.lease !== undefined) {
|
|
2044
|
+
try {
|
|
2045
|
+
await releaseFilmAdvance(env, filmId, claim.lease);
|
|
2046
|
+
}
|
|
2047
|
+
catch (e) {
|
|
2048
|
+
console.warn(`film ${filmId}: advance lease release failed (${e.message}); it expires on its own`);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
/** The advance tick body; the caller holds (or fail-opened past) the advance lease. */
|
|
2054
|
+
async function advanceFilmJobLocked(env, filmId) {
|
|
2055
|
+
const obj = await env.R2_RENDERS.get(filmKey(filmId));
|
|
2056
|
+
if (!obj)
|
|
2057
|
+
return null;
|
|
2058
|
+
const job = JSON.parse(await obj.text());
|
|
2059
|
+
if (job.cancelled)
|
|
2060
|
+
return { job, clipJob: null };
|
|
2061
|
+
const envRec = env;
|
|
2062
|
+
const entryPhase = job.phase;
|
|
2063
|
+
// #521: discover the module registry ONCE per tick and thread it through every phase leg, instead of
|
|
2064
|
+
// each phase function re-fanning-out N `/module.json` subrequests. A tick can chain several discovering
|
|
2065
|
+
// legs (finish -> assemble -> master/mux -> film.finish + notify), so the old per-leg discovery blew the
|
|
2066
|
+
// free-plan 50-subrequest cap on a 25-module install (F9). Manifests are static within a tick.
|
|
2067
|
+
const modules = await discoverModules(envRec);
|
|
2068
|
+
// Stall recovery (#129): a pollable phase whose module poll never resolves (RunPod GC'd the finished
|
|
2069
|
+
// job) would otherwise hang IN_PROGRESS forever. Run BEFORE the phase legs so an adopted keyframe
|
|
2070
|
+
// phase advances to clips and the clips leg below drives it in the same tick. A persist happens at the
|
|
2071
|
+
// end via the phase-transition stamp; the helper only mutates the in-memory job.
|
|
2072
|
+
await recoverStalledPhase(env, job, modules);
|
|
2073
|
+
// Phase 1: poll the keyframe job; on completion, presign + hand off to the clip orchestrator.
|
|
2074
|
+
if (job.phase === "keyframe" && job.keyframe_poll) {
|
|
2075
|
+
const fetcher = job.keyframe_binding ? resolveFetcher(envRec, job.keyframe_binding) : null;
|
|
2076
|
+
if (!fetcher) {
|
|
2077
|
+
job.phase = "failed";
|
|
2078
|
+
job.error = "keyframe module no longer bound";
|
|
2079
|
+
}
|
|
2080
|
+
else {
|
|
2081
|
+
const p = await pollModule(fetcher, { poll: job.keyframe_poll });
|
|
2082
|
+
if (!p.ok) {
|
|
2083
|
+
job.phase = "failed";
|
|
2084
|
+
job.error = p.error;
|
|
2085
|
+
}
|
|
2086
|
+
else if (!p.pending) {
|
|
2087
|
+
const out = p.output;
|
|
2088
|
+
const v = hookOutputViolation(job.keyframe_binding ?? "keyframe", "keyframe", out);
|
|
2089
|
+
if (v) {
|
|
2090
|
+
job.phase = "failed";
|
|
2091
|
+
job.error = v;
|
|
2092
|
+
}
|
|
2093
|
+
else
|
|
2094
|
+
await afterKeyframeOutput(env, job, out, modules);
|
|
2095
|
+
}
|
|
2096
|
+
else if (await keyframeSetCompleteInR2(env, job)) {
|
|
2097
|
+
// R2 PRESENCE IS AUTHORITATIVE, even on a *pending* poll (#129 sibling, mirrors #154 for finish):
|
|
2098
|
+
// the keyframe job's RunPod envelope can freeze at IN_PROGRESS after the GPU already wrote every
|
|
2099
|
+
// renders/<project>/keyframes/shot_NN.png to R2, so the poll reads pending forever. Don't wait for
|
|
2100
|
+
// KEYFRAME_STALL_SECONDS (20min) to adopt -- once the FULL set is in R2, advance now. The
|
|
2101
|
+
// completeness guard is essential: adopting a PARTIAL set (mid-generation) would advance to clips
|
|
2102
|
+
// with keyframes missing. (recoverStalledKeyframePhase stays as the >20min backstop, which HOLDS a
|
|
2103
|
+
// partial set below the ceiling and delivers-with-degrade at it, #619.) This path already proved the
|
|
2104
|
+
// FULL set is present, so atCeiling=false is moot: recovery takes its full-set advance branch.
|
|
2105
|
+
await recoverStalledKeyframePhase(env, job, modules, false);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
await putFilm(env, job);
|
|
2109
|
+
}
|
|
2110
|
+
// Phase 2: drive the clip orchestrator; when every shot is terminal, hand off to the finish chain.
|
|
2111
|
+
let clipJob = null;
|
|
2112
|
+
if (job.phase === "clips" && job.clip_job_id) {
|
|
2113
|
+
clipJob = await advanceClipJob(env, job.clip_job_id, modules);
|
|
2114
|
+
// R2 PRESENCE IS AUTHORITATIVE, BEFORE the complete-judgment (issue #141): a module fast-fail (#142)
|
|
2115
|
+
// makes summarizeJob read complete (done+failed===total) at ~150s; without this, enterFinishPhase
|
|
2116
|
+
// builds from done clips only and DROPS the failed shots -- even though their clips are in R2. Reclaim
|
|
2117
|
+
// any not-done shot whose clip is in R2 (only lists when failed>0; idempotent with advanceClipJob's own
|
|
2118
|
+
// reclaim) so the film never advances/assembles with a clip dropped that actually landed.
|
|
2119
|
+
if (clipJob && summarizeJob(clipJob).failed > 0) {
|
|
2120
|
+
const adopted = await reclaimClipsFromR2(env, clipJob);
|
|
2121
|
+
if (adopted > 0)
|
|
2122
|
+
await env.R2_RENDERS.put(clipDocKey(job.clip_job_id), JSON.stringify(clipJob), { httpMetadata: { contentType: "application/json" } });
|
|
2123
|
+
}
|
|
2124
|
+
if (clipJob && summarizeJob(clipJob).complete) {
|
|
2125
|
+
await enterFinishPhase(env, job, clipJob, modules);
|
|
2126
|
+
}
|
|
2127
|
+
await putFilm(env, job);
|
|
2128
|
+
}
|
|
2129
|
+
else if (job.clip_job_id) {
|
|
2130
|
+
const cj = await env.R2_RENDERS.get(clipDocKey(job.clip_job_id)); // load for the summary
|
|
2131
|
+
if (cj)
|
|
2132
|
+
clipJob = JSON.parse(await cj.text());
|
|
2133
|
+
}
|
|
2134
|
+
// Phase 2.5: synthesize per-shot dialogue audio (one batch via the dialogue module), then -> finish.
|
|
2135
|
+
// Soft-degrades to a silent finish on any failure (see advanceDialoguePhase).
|
|
2136
|
+
if (job.phase === "dialogue") {
|
|
2137
|
+
await advanceDialoguePhase(env, job, modules);
|
|
2138
|
+
await putFilm(env, job);
|
|
2139
|
+
}
|
|
2140
|
+
// Phase 2.6: enhance per-shot dialogue audio (the speech chain, async across requests), then -> finish.
|
|
2141
|
+
// A POLISH phase: a hard step failure degrades the shot (keeps the original audio) and the render
|
|
2142
|
+
// proceeds to finish -- a speech glitch must never fail a fully-rendered film (see advanceSpeechPhase).
|
|
2143
|
+
if (job.phase === "speech") {
|
|
2144
|
+
await advanceSpeechPhase(env, job);
|
|
2145
|
+
await putFilm(env, job);
|
|
2146
|
+
}
|
|
2147
|
+
// Phase 3: drive the finish chain per clip (async, across requests), then -> assemble.
|
|
2148
|
+
if (job.phase === "finish" && job.finish_shots) {
|
|
2149
|
+
await advanceFinishPhase(env, job, modules);
|
|
2150
|
+
await putFilm(env, job);
|
|
2151
|
+
}
|
|
2152
|
+
// Phase 4: assemble the final clips into one film (CPU-only ffmpeg concat in the video-finish
|
|
2153
|
+
// container), then -> done. The final clips are the finish-chain outputs if finish ran, else the
|
|
2154
|
+
// raw rendered clips; either way ordered by the storyboard. Reached inline once finish/clips
|
|
2155
|
+
// complete (the intermediate "assemble" was persisted above, so a timed-out concat just retries).
|
|
2156
|
+
if (job.phase === "assemble") {
|
|
2157
|
+
const source = job.finish_shots
|
|
2158
|
+
? job.finish_shots
|
|
2159
|
+
.filter((fs) => fs.status === "done")
|
|
2160
|
+
.map((fs) => ({ shot_id: fs.shot_id, clip_key: fs.clip_key }))
|
|
2161
|
+
: (clipJob?.shots || [])
|
|
2162
|
+
.filter((s) => s.status === "done" && s.clip_key)
|
|
2163
|
+
.map((s) => ({ shot_id: s.shot_id, clip_key: s.clip_key }));
|
|
2164
|
+
await enterAssemblePhase(env, job, orderFinalClips(job.scenes, source), modules);
|
|
2165
|
+
await putFilm(env, job);
|
|
2166
|
+
}
|
|
2167
|
+
// Phase 4.5: master the assembled film's audio bed (music upscale + loudness) before mux. Pollable
|
|
2168
|
+
// like dialogue; FAIL-SAFE -- a master miss passes the bed through and proceeds to mux (#249 / #77).
|
|
2169
|
+
if (job.phase === "master") {
|
|
2170
|
+
await advanceMasterPhase(env, job, modules);
|
|
2171
|
+
await putFilm(env, job);
|
|
2172
|
+
}
|
|
2173
|
+
// Phase 5: mux the (mastered) audio bed onto the silent film via video-finish (VPC remuxAudioOnly).
|
|
2174
|
+
if (job.phase === "mux") {
|
|
2175
|
+
await enterMuxPhase(env, job, modules);
|
|
2176
|
+
await putFilm(env, job);
|
|
2177
|
+
}
|
|
2178
|
+
// Re-stamp the stall clock on REAL progress (#136). The clips/finish/speech phases advance per shot
|
|
2179
|
+
// over many minutes inside ONE phase, so a UI stall signal measured from phase_started_at alone cries
|
|
2180
|
+
// wolf on a healthy long phase. filmProgressMarker changes on any finished shot (or a phase
|
|
2181
|
+
// transition), so a change is genuine progress -> refresh last_progress_at. Since #704 this stamp is
|
|
2182
|
+
// ALSO load-bearing for recovery: the 90min ceiling measures the per-shot phases (clips/speech/finish)
|
|
2183
|
+
// against last_progress_at, so a slow-but-landing local-gpu film never dies mid-progress; the
|
|
2184
|
+
// same-phase recovery triggers and the batch keyframe ceiling still measure from phase_started_at.
|
|
2185
|
+
const marker = filmProgressMarker(job, clipJob);
|
|
2186
|
+
const progressed = marker !== job.progress_marker;
|
|
2187
|
+
if (progressed) {
|
|
2188
|
+
job.progress_marker = marker;
|
|
2189
|
+
job.last_progress_at = Date.now();
|
|
2190
|
+
}
|
|
2191
|
+
// On any phase transition this tick, stamp when the new phase began (the stall recovery measures
|
|
2192
|
+
// against it) and persist. The phase legs above already persisted on the paths they took; this also
|
|
2193
|
+
// covers a recovery that failed the job at the ceiling (no leg ran after it), so that verdict lands
|
|
2194
|
+
// in R2. putFilm is an idempotent re-PUT, so the belt-and-suspenders double write is harmless.
|
|
2195
|
+
if (job.phase !== entryPhase) {
|
|
2196
|
+
job.phase_started_at = Date.now();
|
|
2197
|
+
await putFilm(env, job);
|
|
2198
|
+
}
|
|
2199
|
+
else if (progressed) {
|
|
2200
|
+
await putFilm(env, job);
|
|
2201
|
+
}
|
|
2202
|
+
return { job, clipJob };
|
|
2203
|
+
}
|
|
2204
|
+
//# sourceMappingURL=film-orchestrator.js.map
|