@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.
Files changed (267) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +87 -0
  3. package/dist/audio-routing.d.ts +2 -0
  4. package/dist/audio-routing.d.ts.map +1 -0
  5. package/dist/audio-routing.js +4 -0
  6. package/dist/audio-routing.js.map +1 -0
  7. package/dist/audio-stage.d.ts +4 -0
  8. package/dist/audio-stage.d.ts.map +1 -0
  9. package/dist/audio-stage.js +25 -0
  10. package/dist/audio-stage.js.map +1 -0
  11. package/dist/beat-analyze.d.ts +22 -0
  12. package/dist/beat-analyze.d.ts.map +1 -0
  13. package/dist/beat-analyze.js +111 -0
  14. package/dist/beat-analyze.js.map +1 -0
  15. package/dist/beat-sync-types.d.ts +33 -0
  16. package/dist/beat-sync-types.d.ts.map +1 -0
  17. package/dist/beat-sync-types.js +4 -0
  18. package/dist/beat-sync-types.js.map +1 -0
  19. package/dist/bundle-assembler.d.ts +54 -0
  20. package/dist/bundle-assembler.d.ts.map +1 -0
  21. package/dist/bundle-assembler.js +376 -0
  22. package/dist/bundle-assembler.js.map +1 -0
  23. package/dist/bundle-durations.d.ts +5 -0
  24. package/dist/bundle-durations.d.ts.map +1 -0
  25. package/dist/bundle-durations.js +68 -0
  26. package/dist/bundle-durations.js.map +1 -0
  27. package/dist/bundle-storyboard.d.ts +11 -0
  28. package/dist/bundle-storyboard.d.ts.map +1 -0
  29. package/dist/bundle-storyboard.js +82 -0
  30. package/dist/bundle-storyboard.js.map +1 -0
  31. package/dist/captions.d.ts +25 -0
  32. package/dist/captions.d.ts.map +1 -0
  33. package/dist/captions.js +56 -0
  34. package/dist/captions.js.map +1 -0
  35. package/dist/cast-db.d.ts +61 -0
  36. package/dist/cast-db.d.ts.map +1 -0
  37. package/dist/cast-db.js +324 -0
  38. package/dist/cast-db.js.map +1 -0
  39. package/dist/cast-lora-train.d.ts +18 -0
  40. package/dist/cast-lora-train.d.ts.map +1 -0
  41. package/dist/cast-lora-train.js +215 -0
  42. package/dist/cast-lora-train.js.map +1 -0
  43. package/dist/cast-loras.d.ts +21 -0
  44. package/dist/cast-loras.d.ts.map +1 -0
  45. package/dist/cast-loras.js +69 -0
  46. package/dist/cast-loras.js.map +1 -0
  47. package/dist/clip-content-validate.d.ts +43 -0
  48. package/dist/clip-content-validate.d.ts.map +1 -0
  49. package/dist/clip-content-validate.js +115 -0
  50. package/dist/clip-content-validate.js.map +1 -0
  51. package/dist/clip-job-model.d.ts +42 -0
  52. package/dist/clip-job-model.d.ts.map +1 -0
  53. package/dist/clip-job-model.js +8 -0
  54. package/dist/clip-job-model.js.map +1 -0
  55. package/dist/clip-validate.d.ts +33 -0
  56. package/dist/clip-validate.d.ts.map +1 -0
  57. package/dist/clip-validate.js +247 -0
  58. package/dist/clip-validate.js.map +1 -0
  59. package/dist/d1-retry.d.ts +20 -0
  60. package/dist/d1-retry.d.ts.map +1 -0
  61. package/dist/d1-retry.js +67 -0
  62. package/dist/d1-retry.js.map +1 -0
  63. package/dist/db-env.d.ts +6 -0
  64. package/dist/db-env.d.ts.map +1 -0
  65. package/dist/db-env.js +2 -0
  66. package/dist/db-env.js.map +1 -0
  67. package/dist/dialogue-lines.d.ts +23 -0
  68. package/dist/dialogue-lines.d.ts.map +1 -0
  69. package/dist/dialogue-lines.js +84 -0
  70. package/dist/dialogue-lines.js.map +1 -0
  71. package/dist/film-advance-lease.d.ts +9 -0
  72. package/dist/film-advance-lease.d.ts.map +1 -0
  73. package/dist/film-advance-lease.js +19 -0
  74. package/dist/film-advance-lease.js.map +1 -0
  75. package/dist/film-model.d.ts +415 -0
  76. package/dist/film-model.d.ts.map +1 -0
  77. package/dist/film-model.js +541 -0
  78. package/dist/film-model.js.map +1 -0
  79. package/dist/film-orchestrator.d.ts +216 -0
  80. package/dist/film-orchestrator.d.ts.map +1 -0
  81. package/dist/film-orchestrator.js +2204 -0
  82. package/dist/film-orchestrator.js.map +1 -0
  83. package/dist/film-render-bridge.d.ts +26 -0
  84. package/dist/film-render-bridge.d.ts.map +1 -0
  85. package/dist/film-render-bridge.js +176 -0
  86. package/dist/film-render-bridge.js.map +1 -0
  87. package/dist/finish-hash.d.ts +10 -0
  88. package/dist/finish-hash.d.ts.map +1 -0
  89. package/dist/finish-hash.js +60 -0
  90. package/dist/finish-hash.js.map +1 -0
  91. package/dist/index.d.ts +48 -0
  92. package/dist/index.d.ts.map +1 -0
  93. package/dist/index.js +45 -0
  94. package/dist/index.js.map +1 -0
  95. package/dist/key-safety.d.ts +5 -0
  96. package/dist/key-safety.d.ts.map +1 -0
  97. package/dist/key-safety.js +23 -0
  98. package/dist/key-safety.js.map +1 -0
  99. package/dist/lora-bundle.d.ts +19 -0
  100. package/dist/lora-bundle.d.ts.map +1 -0
  101. package/dist/lora-bundle.js +75 -0
  102. package/dist/lora-bundle.js.map +1 -0
  103. package/dist/lora-keys.d.ts +2 -0
  104. package/dist/lora-keys.d.ts.map +1 -0
  105. package/dist/lora-keys.js +4 -0
  106. package/dist/lora-keys.js.map +1 -0
  107. package/dist/modules/conformance.d.ts +58 -0
  108. package/dist/modules/conformance.d.ts.map +1 -0
  109. package/dist/modules/conformance.js +369 -0
  110. package/dist/modules/conformance.js.map +1 -0
  111. package/dist/modules/manifest-validate.d.ts +5 -0
  112. package/dist/modules/manifest-validate.d.ts.map +1 -0
  113. package/dist/modules/manifest-validate.js +60 -0
  114. package/dist/modules/manifest-validate.js.map +1 -0
  115. package/dist/modules/registry.d.ts +184 -0
  116. package/dist/modules/registry.d.ts.map +1 -0
  117. package/dist/modules/registry.js +653 -0
  118. package/dist/modules/registry.js.map +1 -0
  119. package/dist/modules/render-pipeline.d.ts +38 -0
  120. package/dist/modules/render-pipeline.d.ts.map +1 -0
  121. package/dist/modules/render-pipeline.js +40 -0
  122. package/dist/modules/render-pipeline.js.map +1 -0
  123. package/dist/modules/types.d.ts +544 -0
  124. package/dist/modules/types.d.ts.map +1 -0
  125. package/dist/modules/types.js +57 -0
  126. package/dist/modules/types.js.map +1 -0
  127. package/dist/operator-config.d.ts +20 -0
  128. package/dist/operator-config.d.ts.map +1 -0
  129. package/dist/operator-config.js +85 -0
  130. package/dist/operator-config.js.map +1 -0
  131. package/dist/output-extract.d.ts +8 -0
  132. package/dist/output-extract.d.ts.map +1 -0
  133. package/dist/output-extract.js +129 -0
  134. package/dist/output-extract.js.map +1 -0
  135. package/dist/planner-prompt.d.ts +12 -0
  136. package/dist/planner-prompt.d.ts.map +1 -0
  137. package/dist/planner-prompt.js +337 -0
  138. package/dist/planner-prompt.js.map +1 -0
  139. package/dist/planner-yaml.d.ts +15 -0
  140. package/dist/planner-yaml.d.ts.map +1 -0
  141. package/dist/planner-yaml.js +157 -0
  142. package/dist/planner-yaml.js.map +1 -0
  143. package/dist/platform/fetcher.d.ts +3 -0
  144. package/dist/platform/fetcher.d.ts.map +1 -0
  145. package/dist/platform/fetcher.js +7 -0
  146. package/dist/platform/fetcher.js.map +1 -0
  147. package/dist/platform/index.d.ts +6 -0
  148. package/dist/platform/index.d.ts.map +1 -0
  149. package/dist/platform/index.js +5 -0
  150. package/dist/platform/index.js.map +1 -0
  151. package/dist/platform/object-store-r2.d.ts +26 -0
  152. package/dist/platform/object-store-r2.d.ts.map +1 -0
  153. package/dist/platform/object-store-r2.js +74 -0
  154. package/dist/platform/object-store-r2.js.map +1 -0
  155. package/dist/platform/orchestrator-context.d.ts +20 -0
  156. package/dist/platform/orchestrator-context.d.ts.map +1 -0
  157. package/dist/platform/orchestrator-context.js +28 -0
  158. package/dist/platform/orchestrator-context.js.map +1 -0
  159. package/dist/platform/r2-types.d.ts +41 -0
  160. package/dist/platform/r2-types.d.ts.map +1 -0
  161. package/dist/platform/r2-types.js +3 -0
  162. package/dist/platform/r2-types.js.map +1 -0
  163. package/dist/platform/types.d.ts +90 -0
  164. package/dist/platform/types.d.ts.map +1 -0
  165. package/dist/platform/types.js +23 -0
  166. package/dist/platform/types.js.map +1 -0
  167. package/dist/preflight.d.ts +57 -0
  168. package/dist/preflight.d.ts.map +1 -0
  169. package/dist/preflight.js +243 -0
  170. package/dist/preflight.js.map +1 -0
  171. package/dist/presign.d.ts +5 -0
  172. package/dist/presign.d.ts.map +1 -0
  173. package/dist/presign.js +24 -0
  174. package/dist/presign.js.map +1 -0
  175. package/dist/public-id.d.ts +8 -0
  176. package/dist/public-id.d.ts.map +1 -0
  177. package/dist/public-id.js +25 -0
  178. package/dist/public-id.js.map +1 -0
  179. package/dist/render-adopt.d.ts +3 -0
  180. package/dist/render-adopt.d.ts.map +1 -0
  181. package/dist/render-adopt.js +92 -0
  182. package/dist/render-adopt.js.map +1 -0
  183. package/dist/render-log.d.ts +25 -0
  184. package/dist/render-log.d.ts.map +1 -0
  185. package/dist/render-log.js +139 -0
  186. package/dist/render-log.js.map +1 -0
  187. package/dist/render-module-config.d.ts +28 -0
  188. package/dist/render-module-config.d.ts.map +1 -0
  189. package/dist/render-module-config.js +124 -0
  190. package/dist/render-module-config.js.map +1 -0
  191. package/dist/render-mux.d.ts +18 -0
  192. package/dist/render-mux.d.ts.map +1 -0
  193. package/dist/render-mux.js +69 -0
  194. package/dist/render-mux.js.map +1 -0
  195. package/dist/render-orchestrator.d.ts +71 -0
  196. package/dist/render-orchestrator.d.ts.map +1 -0
  197. package/dist/render-orchestrator.js +376 -0
  198. package/dist/render-orchestrator.js.map +1 -0
  199. package/dist/render-sweep.d.ts +5 -0
  200. package/dist/render-sweep.d.ts.map +1 -0
  201. package/dist/render-sweep.js +62 -0
  202. package/dist/render-sweep.js.map +1 -0
  203. package/dist/renders-db.d.ts +129 -0
  204. package/dist/renders-db.d.ts.map +1 -0
  205. package/dist/renders-db.js +752 -0
  206. package/dist/renders-db.js.map +1 -0
  207. package/dist/runpod-submit.d.ts +151 -0
  208. package/dist/runpod-submit.d.ts.map +1 -0
  209. package/dist/runpod-submit.js +469 -0
  210. package/dist/runpod-submit.js.map +1 -0
  211. package/dist/runpod-types.d.ts +2 -0
  212. package/dist/runpod-types.d.ts.map +1 -0
  213. package/dist/runpod-types.js +4 -0
  214. package/dist/runpod-types.js.map +1 -0
  215. package/dist/scatter-notify.d.ts +4 -0
  216. package/dist/scatter-notify.d.ts.map +1 -0
  217. package/dist/scatter-notify.js +43 -0
  218. package/dist/scatter-notify.js.map +1 -0
  219. package/dist/scatter-orchestrator-types.d.ts +56 -0
  220. package/dist/scatter-orchestrator-types.d.ts.map +1 -0
  221. package/dist/scatter-orchestrator-types.js +3 -0
  222. package/dist/scatter-orchestrator-types.js.map +1 -0
  223. package/dist/scatter-orchestrator.d.ts +70 -0
  224. package/dist/scatter-orchestrator.d.ts.map +1 -0
  225. package/dist/scatter-orchestrator.js +614 -0
  226. package/dist/scatter-orchestrator.js.map +1 -0
  227. package/dist/scatter.d.ts +35 -0
  228. package/dist/scatter.d.ts.map +1 -0
  229. package/dist/scatter.js +171 -0
  230. package/dist/scatter.js.map +1 -0
  231. package/dist/secret-store.d.ts +5 -0
  232. package/dist/secret-store.d.ts.map +1 -0
  233. package/dist/secret-store.js +19 -0
  234. package/dist/secret-store.js.map +1 -0
  235. package/dist/shot-durations-parse.d.ts +3 -0
  236. package/dist/shot-durations-parse.d.ts.map +1 -0
  237. package/dist/shot-durations-parse.js +43 -0
  238. package/dist/shot-durations-parse.js.map +1 -0
  239. package/dist/srt.d.ts +11 -0
  240. package/dist/srt.d.ts.map +1 -0
  241. package/dist/srt.js +54 -0
  242. package/dist/srt.js.map +1 -0
  243. package/dist/storyboard-ids.d.ts +2 -0
  244. package/dist/storyboard-ids.d.ts.map +1 -0
  245. package/dist/storyboard-ids.js +12 -0
  246. package/dist/storyboard-ids.js.map +1 -0
  247. package/dist/storyboard-projects-db.d.ts +31 -0
  248. package/dist/storyboard-projects-db.d.ts.map +1 -0
  249. package/dist/storyboard-projects-db.js +153 -0
  250. package/dist/storyboard-projects-db.js.map +1 -0
  251. package/dist/storyboard-validate.d.ts +72 -0
  252. package/dist/storyboard-validate.d.ts.map +1 -0
  253. package/dist/storyboard-validate.js +595 -0
  254. package/dist/storyboard-validate.js.map +1 -0
  255. package/dist/structured-events.d.ts +6 -0
  256. package/dist/structured-events.d.ts.map +1 -0
  257. package/dist/structured-events.js +15 -0
  258. package/dist/structured-events.js.map +1 -0
  259. package/dist/tar.d.ts +9 -0
  260. package/dist/tar.d.ts.map +1 -0
  261. package/dist/tar.js +151 -0
  262. package/dist/tar.js.map +1 -0
  263. package/dist/voices.d.ts +12 -0
  264. package/dist/voices.d.ts.map +1 -0
  265. package/dist/voices.js +37 -0
  266. package/dist/voices.js.map +1 -0
  267. 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