@animastor/orchestration 0.1.1

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 (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -0
  3. package/package.json +51 -0
  4. package/src/host/host-bindings.js +138 -0
  5. package/src/index.js +99 -0
  6. package/src/orchestration/index.js +40 -0
  7. package/src/orchestration/orchestrator.js +630 -0
  8. package/src/orchestration/runtime-result-consumer.js +54 -0
  9. package/src/orchestration/scene-callbacks.js +542 -0
  10. package/src/orchestration/scene-orchestrator.js +601 -0
  11. package/src/orchestration/scene-restoration.js +129 -0
  12. package/src/orchestration/scene-utils.js +36 -0
  13. package/src/runtime/active-scenes-index.js +164 -0
  14. package/src/runtime/audio-fsm-port.js +187 -0
  15. package/src/runtime/circuit-breaker.js +588 -0
  16. package/src/runtime/counter-reconciliation.js +308 -0
  17. package/src/runtime/dispatch-engine.js +1768 -0
  18. package/src/runtime/failure-taxonomy.js +125 -0
  19. package/src/runtime/hub-cancel-port.js +122 -0
  20. package/src/runtime/layer-config-port.js +136 -0
  21. package/src/runtime/lease-manager.js +607 -0
  22. package/src/runtime/persistence-port.js +162 -0
  23. package/src/runtime/placeholder-audio-port.js +117 -0
  24. package/src/runtime/progress-events-port.js +121 -0
  25. package/src/runtime/reconciliation-engine.js +2405 -0
  26. package/src/runtime/retry-budget-manager.js +229 -0
  27. package/src/runtime/runtime-metrics.js +427 -0
  28. package/src/runtime/runtime-result-emitter.js +123 -0
  29. package/src/runtime/runtime-scheduler.js +629 -0
  30. package/src/runtime/scene-data-port.js +114 -0
  31. package/src/runtime/scene-window.js +827 -0
  32. package/src/runtime/video-fsm-port.js +189 -0
  33. package/src/runtime/worker-health.js +270 -0
  34. package/test/counter-reconciliation.test.js +164 -0
  35. package/test/mocks/redis-mock.js +221 -0
  36. package/test/test-bindings.js +60 -0
@@ -0,0 +1,54 @@
1
+ // ======================================================
2
+ // Runtime Result Consumer — Phase 5 seam (consumer side)
3
+ // ======================================================
4
+ // Orchestration-side adapter for the Runtime Result Contract.
5
+ // Implemented in orchestration, injected into runtime's emitter from the
6
+ // composition root (backend.cjs) — runtime never imports this module
7
+ // statically (the dependency flows through the injected callback).
8
+ //
9
+ // Direction after Phase 5:
10
+ // orchestration ▶ runtime ▶ Runtime Result Contract ▶ this consumer
11
+ //
12
+ // Phase 5 scope: the consumer OBSERVES dispatch finalizations (completed /
13
+ // failed / cancelled). Semantic reactions (state writes, re-dispatch policy)
14
+ // stay with the existing flows (task-handler → completeStage/failStage,
15
+ // reconciliation) — migrating them here is future work, see
16
+ // docs/architecture/PHASE_5_ORCHESTRATION_RUNTIME.md §7.
17
+ //
18
+ // Zero relative imports: this module must stay a leaf so that wiring it in
19
+ // cannot create a new architectural cycle (Phase 5 T8).
20
+
21
+ 'use strict';
22
+
23
+ const LOG_PREFIX = '[RUNTIME-RESULT-CONSUMER]';
24
+
25
+ /**
26
+ * @param {Object} [options]
27
+ * @param {(msg: string) => void} [options.log] injectable logger (tests)
28
+ * @returns {(result: object) => Promise<void>} consumer for the runtime result emitter
29
+ */
30
+ function createRuntimeResultConsumer(options = {}) {
31
+ const log = typeof options.log === 'function'
32
+ ? options.log
33
+ : (msg) => console.log(`${LOG_PREFIX} ${msg}`);
34
+
35
+ return async function handleRuntimeResult(result) {
36
+ if (!result || typeof result !== 'object') {
37
+ log('received non-object runtime result — ignoring');
38
+ return;
39
+ }
40
+
41
+ const where = `${result.bookId}/${result.chapterId}/${result.sceneId}`;
42
+ const stage = result.stage ? `:${result.stage}` : '';
43
+ const id = result.dispatchId
44
+ ? `dispatch=${result.dispatchId.slice(0, 24)}...`
45
+ : (result.jobId ? `job=${result.jobId}` : 'no-id');
46
+ const cause = result.error ? ` reason=${result.error}` : '';
47
+
48
+ log(`${result.status} ${where}${stage} ${id}${cause}`);
49
+ };
50
+ }
51
+
52
+ module.exports = {
53
+ createRuntimeResultConsumer,
54
+ };
@@ -0,0 +1,542 @@
1
+ // ======================================================
2
+ // Callback handlers — contract (T1):
3
+ //
4
+ // Каждый handler возвращает:
5
+ // { ok: true, artifact: { buildId, path } } — успех
6
+ // { ok: false, retryable: true, reason: '<cause>' } — временная ошибка (→ failStage)
7
+ // { ok: false, retryable: false, reason: '<cause>' } — терминальная ошибка (журнал, NO READY)
8
+ //
9
+ // Handler НЕ пишет PG status='ready' — это делает completeStage()
10
+ // после валидации результата и version gate.
11
+ // ======================================================
12
+
13
+ const path = require('path');
14
+ const fs = require('fs');
15
+ const { lazyHostBinding, hostBinding } = require('../host/host-bindings');
16
+ // §32.30: former host requires resolve lazily through the host-bindings seam.
17
+ const state = lazyHostBinding('state');
18
+ const audio = lazyHostBinding('media.audio');
19
+ const image = lazyHostBinding('media.image');
20
+ const video = lazyHostBinding('media.video');
21
+ // §32.30: the artifact root (former process.env.OUTPUT_DIR read) resolves
22
+ // through the composition-root host-bindings seam at call time.
23
+ const artifactRoot = () => hostBinding('artifactRoot');
24
+ // S-4: filename grammar composed from the canonical owner (bytes unchanged)
25
+ const artifactNaming = require('@animastor/generation').artifactNaming;
26
+ // O-2: persistence arrives ONLY through the PersistencePort (host adapter:
27
+ // storage/runtime-persistence-adapter) — the storage barrel, the scene-assets
28
+ // and IU repositories left this file. The Redis asset-registry and the
29
+ // audio-path composition are consumed as persistence OPERATIONS through the
30
+ // same port (their implementations stay host-side).
31
+ const persist = require('../runtime/persistence-port').persist;
32
+ const runtimeScheduler = require('../runtime/runtime-scheduler');
33
+ const dispatchEngine = require('../runtime/dispatch-engine');
34
+ // O-3: scene content arrives ONLY through the SceneDataPort (host adapter:
35
+ // storage/scene-data-adapter) — the Book Model facade (../book) left this
36
+ // file. loadBook is consumed as `sceneData('loadBook')(bookId)` at the
37
+ // auto-slide call site.
38
+ // O-3: scene content arrives ONLY through the SceneDataPort (host adapter:
39
+ // storage/scene-data-adapter) — the Book Model facade (../book) left this
40
+ // file. loadBook is consumed as `sceneData('loadBook')(bookId)` at the
41
+ // auto-slide call site.
42
+ const sceneData = require('../runtime/scene-data-port').sceneDataOp;
43
+ // O-4: placeholder audio arrives ONLY through the PlaceholderAudioPort
44
+ // (host adapter: storage/placeholder-audio-adapter) — the ffmpeg/fs host
45
+ // service (../services/placeholder-audio) left this file.
46
+ const placeholderAudioOp = require('../runtime/placeholder-audio-port').placeholderAudioOp;
47
+ // O-5: progress events arrive ONLY through the ProgressEventsPort (host
48
+ // adapter: storage/progress-events-adapter) — the Redis pub/sub host
49
+ // service (../services/progress-pubsub) left this file.
50
+ const progressEventsOp = require('../runtime/progress-events-port').progressEventsOp;
51
+ const { log, warn, error, logEvent } = require('./scene-utils');
52
+
53
+ // Stage constants (replaces removed scene-state-machine.js)
54
+ const Stage = { AUDIO: 'audio', IMAGE: 'image', VIDEO: 'video' };
55
+
56
+ // S-2: Stage handler map — derived from concrete handlers.
57
+ // Used by orchestrator.completeStage() to resolve the callback for a stage.
58
+ const STAGE_HANDLERS = {
59
+ audio: handleAudioCompleted,
60
+ image: handleImageCompleted,
61
+ video: handleVideoCompleted,
62
+ };
63
+
64
+ /**
65
+ * Get the completion handler for a stage.
66
+ * @param {string} stage
67
+ * @returns {Function | undefined}
68
+ */
69
+ function getStageHandler(stage) {
70
+ return STAGE_HANDLERS[stage];
71
+ }
72
+
73
+ async function updateSceneChunks(redis, bookId, chapterId, sceneId, updates) {
74
+ const prefix = `animastor:chunk:${bookId}_${chapterId}_${sceneId}_`;
75
+ let cursor = '0';
76
+ do {
77
+ const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', `${prefix}*`, 'COUNT', 50);
78
+ cursor = nextCursor;
79
+ for (const key of keys) {
80
+ try {
81
+ const raw = await redis.get(key);
82
+ if (raw) {
83
+ const chunk = JSON.parse(raw);
84
+ Object.assign(chunk, updates);
85
+ await redis.set(key, JSON.stringify(chunk));
86
+ }
87
+ } catch (e) {
88
+ warn(`Failed to update chunk ${key}: ${e.message}`);
89
+ }
90
+ }
91
+ } while (cursor !== '0');
92
+ }
93
+
94
+ async function handleAudioCompleted(redis, bookId, chapterId, sceneId, buildId) {
95
+ // T1: возвращает { ok, retryable, reason, artifact }
96
+ // artifact = { buildId, path } при ok:true
97
+ log(`AUDIO_CALLBACK: ${bookId}/${chapterId}/${sceneId}`);
98
+
99
+ const assetStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
100
+ const audioAllowed = assetStates && (
101
+ assetStates.audio === state.AssetState.GENERATING ||
102
+ assetStates.audio === state.AssetState.PENDING ||
103
+ assetStates.audio === state.AssetState.DIRTY
104
+ );
105
+
106
+ if (!audioAllowed) {
107
+ warn(`AUDIO_CALLBACK: Invalid per-asset state: ${assetStates?.audio || 'unknown'} (expected GENERATING/PENDING)`);
108
+ log(`🔻 AUDIO callback rejected (invalid per-asset state): ${bookId}/${chapterId}/${sceneId}`);
109
+ return { ok: false, retryable: false, reason: 'invalid_asset_state' };
110
+ }
111
+
112
+ const isReady = await audio.isSceneAudioReady(buildId, bookId, chapterId, sceneId, require('music-metadata'));
113
+ if (!isReady) {
114
+ error(`Audio not ready after completion: ${bookId}/${chapterId}/${sceneId}`);
115
+ const scene = { book_id: bookId, chapter_id: chapterId, scene_id: sceneId };
116
+ await logEvent(redis, scene, 'AUDIO_FAILED', 'audio_ready', {
117
+ reason: 'not_ready_validation'
118
+ });
119
+ log(`🔻 AUDIO callback: audio not ready: ${bookId}/${chapterId}/${sceneId}`);
120
+ return { ok: false, retryable: true, reason: 'audio_not_ready' };
121
+ }
122
+
123
+ const scene = { book_id: bookId, chapter_id: chapterId, scene_id: sceneId };
124
+ await logEvent(redis, scene, 'AUDIO_COMPLETED', 'audio_ready', {
125
+ buildId
126
+ });
127
+
128
+ const audioPath = persist('filesystem.getSceneAudioPath')(
129
+ artifactRoot(), buildId, bookId, chapterId, sceneId
130
+ );
131
+
132
+ try {
133
+ await persist('registry.registerSceneAudioRedis')(redis, bookId, chapterId, sceneId, {
134
+ canonicalPath: audioPath,
135
+ ready: true
136
+ });
137
+ log(`CACHE-MANIFEST: audio recorded for ${bookId}/${chapterId}/${sceneId}`);
138
+ } catch (err) {
139
+ warn(`Failed to register audio asset: ${err.message}`);
140
+ }
141
+
142
+ // T1.10: PG markReady вынесен в completeStage после version gate
143
+ // T1.10: PG markReady is now called in completeStage() after version gate
144
+
145
+ let realDuration = 0;
146
+ try {
147
+ const mm = require('music-metadata');
148
+ try {
149
+ const metadata = await mm.parseFile(audioPath);
150
+ realDuration = metadata.format.duration || 0;
151
+ } catch {}
152
+ await placeholderAudioOp('replacePlaceholderWithRealAudio')(
153
+ bookId, chapterId, sceneId, buildId, audioPath, realDuration
154
+ );
155
+ } catch (err) {
156
+ warn(`Failed to replace placeholder audio: ${err.message}`);
157
+ }
158
+
159
+ // ── IU TIMING RECALCULATION ──
160
+ // TEMPORARY (research): source of truth for unit timings is now the ACTUAL
161
+ // raw audio chunk durations (.chunk-durations.json written by trackChunkDurations
162
+ // during merge), not a text-length-proportional split of the merged duration.
163
+ // Chunks carry unit_id (set during generation), so we sum per-unit durations
164
+ // and build cumulative boundaries. Falls back to the old proportional split
165
+ // only when no per-chunk data is available (e.g. pure-dialogue merged chunks).
166
+ if (realDuration > 0) {
167
+ try {
168
+ const units = await persist('iu.getImageUnitsForScene')(buildId, bookId, chapterId, sceneId);
169
+ if (units && units.length > 0) {
170
+ const metaPath = audioPath.replace(/\.mp3$/, '.chunk-durations.json');
171
+ const factual = fs.existsSync(metaPath) ? JSON.parse(fs.readFileSync(metaPath, 'utf8')) : null;
172
+ // The final cleanup trims trailing silence only from the LAST chunk
173
+ // of the scene (tail-only mode). To match the merged file exactly,
174
+ // that last chunk must be counted by its tail duration, all others
175
+ // by their raw duration (their tail becomes an internal pause).
176
+ const lastChunkIdx = factual && factual.records && factual.records.length > 0
177
+ ? Math.max(...factual.records.map(r => r.chunk_index))
178
+ : null;
179
+ const perUnit = factual && factual.records && factual.records.length > 0
180
+ ? factual.records
181
+ .filter(r => r.unit_id)
182
+ .reduce((acc, r) => {
183
+ const isLast = r.chunk_index === lastChunkIdx;
184
+ const dur = isLast && r.tail_duration_sec
185
+ ? r.tail_duration_sec
186
+ : r.raw_duration_sec;
187
+ acc[r.unit_id] = (acc[r.unit_id] || 0) + dur;
188
+ return acc;
189
+ }, {})
190
+ : null;
191
+
192
+ const oldSceneDur = units[0].scene_duration_sec || 0;
193
+ const diff = Math.abs(realDuration - oldSceneDur);
194
+
195
+ if (perUnit && Object.keys(perUnit).length > 0) {
196
+ const durLabel = oldSceneDur > 0
197
+ ? `${oldSceneDur.toFixed(2)}s → factual ${realDuration.toFixed(2)}s`
198
+ : `first-time factual (${realDuration.toFixed(2)}s)`;
199
+ const sumFactual = Object.values(perUnit).reduce((s, v) => s + v, 0);
200
+ log(`[IU-RECALC] ${bookId}/${chapterId}/${sceneId}: ${durLabel}, applying ACTUAL chunk timings to ${units.length} IUs (${Object.keys(perUnit).length} units with audio chunks; last-chunk tail fix applied, Σ=${sumFactual.toFixed(2)}s)`);
201
+ let cursorMs = 0;
202
+ for (const u of units) {
203
+ const actualSec = perUnit[u.unit_id] ?? null;
204
+ let startMs = cursorMs;
205
+ let endMs;
206
+ let estSec;
207
+ if (actualSec != null && actualSec > 0) {
208
+ endMs = cursorMs + Math.round(actualSec * 1000);
209
+ estSec = parseFloat(actualSec.toFixed(3));
210
+ } else {
211
+ // No audio chunk for this unit (e.g. typography) — keep
212
+ // the unit at zero width: same start, zero duration.
213
+ endMs = cursorMs;
214
+ estSec = 0;
215
+ }
216
+ cursorMs = endMs;
217
+ await persist('iu.upsertImageUnit')(buildId, bookId, chapterId, sceneId, u.unit_id, {
218
+ scene_order: u.scene_order || 0,
219
+ text: u.text,
220
+ text_length: u.text_length || 0,
221
+ text_proportion: 0,
222
+ scene_duration_sec: realDuration,
223
+ estimated_duration_sec: estSec,
224
+ scene_audio_file: u.scene_audio_file || artifactNaming.sceneAudioName(bookId, chapterId, sceneId),
225
+ start_ms: startMs,
226
+ end_ms: endMs,
227
+ });
228
+ }
229
+ log(`[IU-RECALC] ✓ ${bookId}/${chapterId}/${sceneId}: ACTUAL unit timings applied (Σ=${Object.values(perUnit).reduce((s, v) => s + v, 0).toFixed(2)}s, merged=${realDuration.toFixed(2)}s)`);
230
+ } else if (diff > 1.0) {
231
+ const durLabel = oldSceneDur > 0
232
+ ? `${oldSceneDur.toFixed(2)}s → ${realDuration.toFixed(2)}s (Δ=${diff.toFixed(2)}s)`
233
+ : `first-time calculation (${realDuration.toFixed(2)}s)`;
234
+ log(`[IU-RECALC] ${bookId}/${chapterId}/${sceneId}: ${durLabel}, recalculating ${units.length} IU timings (proportional fallback — no per-chunk unit data)`);
235
+ const totalTextLen = units.reduce((s, u) => s + (u.text_length || 0), 0);
236
+ let cursorMs = 0;
237
+ const sceneDurationMs = Math.round(realDuration * 1000);
238
+ for (const u of units) {
239
+ const proportion = totalTextLen > 0 ? (u.text_length || 0) / totalTextLen : 1 / units.length;
240
+ const durMs = Math.max(200, Math.round(realDuration * proportion * 1000));
241
+ const startMs = cursorMs;
242
+ let endMs = cursorMs + durMs;
243
+ if (endMs > sceneDurationMs) endMs = sceneDurationMs;
244
+ cursorMs = endMs;
245
+ await persist('iu.upsertImageUnit')(buildId, bookId, chapterId, sceneId, u.unit_id, {
246
+ scene_order: u.scene_order || 0,
247
+ text: u.text,
248
+ text_length: u.text_length || 0,
249
+ text_proportion: parseFloat(proportion.toFixed(6)),
250
+ scene_duration_sec: realDuration,
251
+ estimated_duration_sec: parseFloat((realDuration * proportion).toFixed(3)),
252
+ scene_audio_file: u.scene_audio_file || artifactNaming.sceneAudioName(bookId, chapterId, sceneId),
253
+ start_ms: startMs,
254
+ end_ms: endMs,
255
+ });
256
+ }
257
+ log(`[IU-RECALC] ✓ ${bookId}/${chapterId}/${sceneId}: ${units.length} IU timings updated to ${realDuration.toFixed(2)}s total (proportional fallback)`);
258
+ } else {
259
+ log(`[IU-RECALC] ${bookId}/${chapterId}/${sceneId}: duration unchanged (${realDuration.toFixed(2)}s ≈ ${oldSceneDur.toFixed(2)}s), skipping`);
260
+ }
261
+ }
262
+ } catch (err) {
263
+ warn(`[IU-RECALC] Failed to recalculate IU timings: ${err.message}`);
264
+ }
265
+ }
266
+
267
+ await progressEventsOp('publishProgress')(redis, bookId, { layer: 'audio', chapterId, sceneId });
268
+ log(`AUDIO_CALLBACK: ${bookId}/${chapterId}/${sceneId} -> READY check passed`);
269
+
270
+ return {
271
+ ok: true,
272
+ artifact: { buildId, path: audioPath }
273
+ };
274
+ }
275
+
276
+ async function handleImageCompleted(redis, bookId, chapterId, sceneId, buildId) {
277
+ // T1: возвращает { ok, retryable, reason, artifact }
278
+ log(`IMAGE_CALLBACK: ${bookId}/${chapterId}/${sceneId}`);
279
+
280
+ const sceneImage = image.resolveCanonicalSceneImage(
281
+ '/data/output', buildId, bookId, chapterId, sceneId
282
+ );
283
+
284
+ // Per-asset check
285
+ const assetStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
286
+ const imageAllowed = assetStates && (
287
+ assetStates.image === state.AssetState.GENERATING ||
288
+ assetStates.image === state.AssetState.PENDING ||
289
+ assetStates.image === state.AssetState.DIRTY
290
+ );
291
+
292
+ if (!imageAllowed) {
293
+ warn(`IMAGE_CALLBACK: Invalid per-asset state: ${assetStates?.image || 'unknown'} — expected GENERATING/PENDING`);
294
+ log(`🔻 IMAGE callback rejected (invalid per-asset state): ${bookId}/${chapterId}/${sceneId}`);
295
+ return { ok: false, retryable: false, reason: 'invalid_asset_state' };
296
+ }
297
+
298
+ if (!sceneImage) {
299
+ error(`Scene image not found after completion: ${bookId}/${chapterId}/${sceneId}`);
300
+ const scene = { book_id: bookId, chapter_id: chapterId, scene_id: sceneId };
301
+ await logEvent(redis, scene, 'IMAGE_FAILED', 'image_generating', {
302
+ reason: 'not_found'
303
+ });
304
+ log(`🔻 IMAGE callback: image not found: ${bookId}/${chapterId}/${sceneId}`);
305
+ // T1.7: не throw, а ok:false — completeStage обработает
306
+ return { ok: false, retryable: true, reason: 'image_not_found' };
307
+ }
308
+
309
+ const imageInfo = await image.getImageMetadata(sceneImage);
310
+
311
+ try {
312
+ await persist('registry.registerSceneImageRedis')(redis, bookId, chapterId, sceneId, {
313
+ path: sceneImage,
314
+ width: imageInfo?.width || null,
315
+ height: imageInfo?.height || null,
316
+ ready: true
317
+ });
318
+ log(`CACHE-MANIFEST: image recorded for ${bookId}/${chapterId}/${sceneId}`);
319
+ } catch (err) {
320
+ warn(`Failed to register image asset: ${err.message}`);
321
+ }
322
+
323
+ const scene = { book_id: bookId, chapter_id: chapterId, scene_id: sceneId };
324
+ await logEvent(redis, scene, 'IMAGE_COMPLETED', 'image_ready', {
325
+ buildId,
326
+ path: sceneImage
327
+ });
328
+
329
+ // R4.1: Clear dirty unit IDs
330
+ try {
331
+ const dirtyIds = await persist('sceneAssets.getDirtyUnitIds')(bookId, chapterId, sceneId);
332
+ if (dirtyIds && dirtyIds.length > 0) {
333
+ const buildDir = path.join(artifactRoot(), buildId);
334
+ const stillPending = [];
335
+ for (const uid of dirtyIds) {
336
+ const pngPath = path.join(buildDir, artifactNaming.sceneImageName(bookId, chapterId, sceneId, uid));
337
+ if (!fs.existsSync(pngPath)) {
338
+ stillPending.push(uid);
339
+ }
340
+ }
341
+ if (stillPending.length === 0) {
342
+ await persist('sceneAssets.clearDirtyUnitIds')(bookId, chapterId, sceneId);
343
+ log(`[DIRTY-UNITS-CLEARED] ${bookId}/${chapterId}/${sceneId}: all ${dirtyIds.length} dirty unit(s) completed, cleared`);
344
+ try {
345
+ const iuPrefix = artifactNaming.iuImagePrefix(bookId, chapterId, sceneId);
346
+ const allFiles = fs.existsSync(buildDir) ? fs.readdirSync(buildDir) : [];
347
+ const iuFileCount = allFiles.filter(f => f.startsWith(iuPrefix) && f.endsWith('.png')).length;
348
+ if (iuFileCount > 0) {
349
+ const progKey = `animastor:iu-progress:${bookId}:${chapterId}:${sceneId}:image`;
350
+ await redis.set(progKey, String(iuFileCount), 'EX', 14400);
351
+ log(`[IU-COUNTER-RESET] ${bookId}/${chapterId}/${sceneId}: counter set to ${iuFileCount}`);
352
+ }
353
+ } catch (counterErr) {
354
+ warn(`Failed to reset IU progress counter: ${counterErr.message}`);
355
+ }
356
+ } else {
357
+ await persist('sceneAssets.setDirtyUnitIds')(bookId, chapterId, sceneId, stillPending);
358
+ log(`[DIRTY-UNITS-PARTIAL] ${bookId}/${chapterId}/${sceneId}: ${stillPending.length}/${dirtyIds.length} dirty units still pending: ${stillPending.join(', ')}`);
359
+ }
360
+ }
361
+ } catch (e) {
362
+ warn(`Failed to update dirty_unit_ids in IMAGE_CALLBACK: ${e.message}`);
363
+ }
364
+
365
+ try {
366
+ const scenePrefix = artifactNaming.iuScanPrefix(bookId, chapterId, sceneId);
367
+ let cursor = '0';
368
+ do {
369
+ const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', `animastor:iu-in-flight:${scenePrefix}*`, 'COUNT', 50);
370
+ cursor = nextCursor;
371
+ if (keys.length > 0) {
372
+ await redis.del(...keys);
373
+ log(`[IU-IN-FLIGHT-CLEARED] ${bookId}/${chapterId}/${sceneId}: cleared ${keys.length} in-flight markers`);
374
+ }
375
+ } while (cursor !== '0');
376
+ } catch (e) {
377
+ warn(`Failed to clear in-flight markers in IMAGE_CALLBACK: ${e.message}`);
378
+ }
379
+
380
+ await updateSceneChunks(redis, bookId, chapterId, sceneId, { image: true, image_status: 'ready' });
381
+ log(`IMAGE_CALLBACK: ${bookId}/${chapterId}/${sceneId} -> READY check passed`);
382
+
383
+ return {
384
+ ok: true,
385
+ artifact: { buildId, path: sceneImage }
386
+ };
387
+ }
388
+
389
+ async function handleVideoCompleted(redis, bookId, chapterId, sceneId, buildId) {
390
+ // T1: возвращает { ok, retryable, reason, artifact }
391
+ log(`VIDEO_CALLBACK: ${bookId}/${chapterId}/${sceneId}`);
392
+
393
+ const videoPath = `/data/output/${buildId}/${bookId}_${chapterId}_${sceneId}.mp4`;
394
+ const { valid, duration, metadata } = video.validateVideoFile(videoPath);
395
+
396
+ const assetStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
397
+ const videoAllowed = assetStates && (
398
+ assetStates.video === state.AssetState.GENERATING ||
399
+ assetStates.video === state.AssetState.PENDING ||
400
+ assetStates.video === state.AssetState.DIRTY
401
+ );
402
+
403
+ if (!videoAllowed) {
404
+ warn(`VIDEO_CALLBACK: Invalid per-asset state: ${assetStates?.video || 'unknown'} (expected GENERATING/PENDING)`);
405
+ log(`🔻 VIDEO callback rejected (invalid per-asset state): ${bookId}/${chapterId}/${sceneId}`);
406
+ return { ok: false, retryable: false, reason: 'invalid_asset_state' };
407
+ }
408
+
409
+ if (!valid) {
410
+ error(`Video not valid after completion: ${bookId}/${chapterId}/${sceneId}`);
411
+ const scene = { book_id: bookId, chapter_id: chapterId, scene_id: sceneId };
412
+ await logEvent(redis, scene, 'VIDEO_FAILED', 'video_generating', {
413
+ reason: 'invalid'
414
+ });
415
+ log(`🔻 VIDEO callback: video invalid: ${bookId}/${chapterId}/${sceneId}`);
416
+ return { ok: false, retryable: true, reason: 'video_invalid' };
417
+ }
418
+
419
+ try {
420
+ await persist('registry.registerSceneVideoRedis')(redis, bookId, chapterId, sceneId, {
421
+ path: videoPath,
422
+ duration: duration || null,
423
+ width: metadata?.width || null,
424
+ height: metadata?.height || null,
425
+ ready: true
426
+ });
427
+ log(`CACHE-MANIFEST: video recorded for ${bookId}/${chapterId}/${sceneId}`);
428
+ } catch (err) {
429
+ warn(`Failed to register video asset: ${err.message}`);
430
+ }
431
+
432
+ const scene = { book_id: bookId, chapter_id: chapterId, scene_id: sceneId };
433
+ await logEvent(redis, scene, 'VIDEO_COMPLETED', 'video_ready', {
434
+ buildId,
435
+ path: videoPath,
436
+ duration: duration || 0
437
+ });
438
+
439
+ await video.updateSceneVideoStatus(redis, bookId, chapterId, sceneId, 'ready');
440
+ await updateSceneChunks(redis, bookId, chapterId, sceneId, { video: true, video_status: 'ready' });
441
+ await progressEventsOp('publishProgress')(redis, bookId, { layer: 'video', chapterId, sceneId });
442
+
443
+ try {
444
+ await persist('sceneAssets.clearDirtyFlag')(bookId, chapterId, sceneId);
445
+ log(`[DIRTY-FLAG-CLEARED] ${bookId}/${chapterId}/${sceneId}: is_dirty=FALSE`);
446
+ } catch (e) {
447
+ warn(`Failed to clear dirty flag: ${e.message}`);
448
+ }
449
+
450
+ await runtimeScheduler.removeSceneFromActiveIndex(redis, bookId, chapterId, sceneId);
451
+ log(`SCENE COMPLETE: ${bookId}/${chapterId}/${sceneId} - removed from active index`);
452
+
453
+ try {
454
+ const bookData = sceneData('loadBook')(bookId);
455
+ const sceneWindow = require('../runtime/scene-window');
456
+ const slide = await sceneWindow.trySlideWindowOnComplete(redis, bookId, bookData, buildId);
457
+ if (slide && slide.started > 0) {
458
+ log(`SCENE-COMPLETE auto-slide: started=${slide.started} remaining=${slide.remaining}`);
459
+ } else if (slide && slide.remaining === 0 && slide.started === 0) {
460
+ log(`SCENE-COMPLETE auto-slide: scope fully complete`);
461
+ try {
462
+ await progressEventsOp('publishProgress')(redis, bookId, { type: 'generation_complete' });
463
+ } catch (_) {}
464
+ }
465
+ } catch (e) {
466
+ warn(`SCENE-COMPLETE auto-slide failed: ${e.message}`);
467
+ }
468
+
469
+ return {
470
+ ok: true,
471
+ artifact: { buildId, path: videoPath }
472
+ };
473
+ }
474
+
475
+ async function completeSceneWithoutVideo(redis, loadedBook, bookId, chapterId, sceneId, buildId) {
476
+ log(`Completing scene without video: ${bookId}/${chapterId}/${sceneId}`);
477
+
478
+ // M5: setAssetState(READY) + syncLinearState moved to orchestrator.completeStageWithoutVideo
479
+ // This function is called THROUGH the facade, which handles state BEFORE calling here.
480
+
481
+
482
+ try {
483
+ await persist('sceneAssets.clearDirtyFlag')(bookId, chapterId, sceneId);
484
+ } catch (e) {
485
+ warn(`Failed to clear dirty flag in completeSceneWithoutVideo: ${e.message}`);
486
+ }
487
+
488
+ await runtimeScheduler.removeSceneFromActiveIndex(redis, bookId, chapterId, sceneId);
489
+ log(`Scene complete (no video): ${bookId}/${chapterId}/${sceneId} - removed from active index`);
490
+
491
+ try {
492
+ const sceneWindow = require('../runtime/scene-window');
493
+ const slide = await sceneWindow.trySlideWindowOnComplete(redis, bookId, loadedBook, buildId);
494
+ if (slide && slide.started > 0) {
495
+ log(`SCENE-COMPLETE auto-slide: started=${slide.started} remaining=${slide.remaining}`);
496
+ } else if (slide && slide.remaining === 0 && slide.started === 0) {
497
+ log(`SCENE-COMPLETE auto-slide (no-video): scope fully complete`);
498
+ try { await progressEventsOp('publishProgress')(redis, bookId, { type: 'generation_complete' }); } catch (_) {}
499
+ }
500
+ } catch (e) {
501
+ warn(`SCENE-COMPLETE auto-slide failed: ${e.message}`);
502
+ }
503
+ }
504
+
505
+ async function completeSceneWithoutImage(redis, loadedBook, bookId, chapterId, sceneId, buildId) {
506
+ log(`Completing scene without image: ${bookId}/${chapterId}/${sceneId}`);
507
+ // M5: setAssetState(READY) + syncLinearState moved to orchestrator.completeStageWithoutImage
508
+ // This function is called THROUGH the facade, which handles state BEFORE calling here.
509
+ log(`Scene complete (no image): ${bookId}/${chapterId}/${sceneId}`);
510
+
511
+ try {
512
+ await persist('sceneAssets.clearDirtyFlag')(bookId, chapterId, sceneId);
513
+ } catch (e) {
514
+ warn(`Failed to clear dirty flag in completeSceneWithoutImage: ${e.message}`);
515
+ }
516
+
517
+ await runtimeScheduler.removeSceneFromActiveIndex(redis, bookId, chapterId, sceneId);
518
+
519
+ try {
520
+ const sceneWindow = require('../runtime/scene-window');
521
+ const slide = await sceneWindow.trySlideWindowOnComplete(redis, bookId, loadedBook, buildId);
522
+ if (slide && slide.started > 0) {
523
+ log(`SCENE-COMPLETE auto-slide: started=${slide.started} remaining=${slide.remaining}`);
524
+ } else if (slide && slide.remaining === 0 && slide.started === 0) {
525
+ log(`SCENE-COMPLETE auto-slide (no-image): scope fully complete`);
526
+ try { await progressEventsOp('publishProgress')(redis, bookId, { type: 'generation_complete' }); } catch (_) {}
527
+ }
528
+ } catch (e) {
529
+ warn(`SCENE-COMPLETE auto-slide failed: ${e.message}`);
530
+ }
531
+ }
532
+
533
+ module.exports = {
534
+ updateSceneChunks,
535
+ handleAudioCompleted,
536
+ handleImageCompleted,
537
+ handleVideoCompleted,
538
+ completeSceneWithoutVideo,
539
+ completeSceneWithoutImage,
540
+ getStageHandler,
541
+ STAGE_HANDLERS,
542
+ };