@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,827 @@
1
+ // ======================================================
2
+ // Scene Window Service - v2.0.0 (scope-aware)
3
+ // ======================================================
4
+ // Slides the generation window through scenes in a scope-aware way.
5
+ // - BOOK_SCENE_TOTAL(bookId) : end index (exclusive)
6
+ // - BOOK_SCENE_NEXT(bookId) : next scene index to start
7
+ // - BOOK_WINDOW_START(bookId) : start index of current window
8
+ //
9
+ // When /regenerate is called with scope:
10
+ // - sets BOOK_SCENE_TOTAL = scopeBounds.endIndex
11
+ // - sets BOOK_SCENE_NEXT = scopeBounds.startIndex
12
+ // - sets BOOK_WINDOW_START = scopeBounds.startIndex
13
+ // - slideWindow then iterates within that range
14
+ //
15
+ // Auto-slide on completion (called from scene-orchestrator) reads
16
+ // the persisted scope, checks the current window, and slides forward
17
+ // if the window is complete.
18
+
19
+ const { lazyHostBinding } = require('../host/host-bindings');
20
+ // §32.30: former host requires resolve lazily through the host-bindings seam.
21
+ const config = lazyHostBinding('config');
22
+ // S-2: per-asset stale shape derived from media registry
23
+ const { listMediaTypes: _mediaTypes } = require('@animastor/generation').mediaRegistry;
24
+ // O-3: scene content arrives ONLY through the SceneDataPort (host adapter:
25
+ // storage/scene-data-adapter) — the Book Model facade (../book) left this
26
+ // file. loadBook/collectScenes are consumed as `sceneData(op)(...)` at the
27
+ // call sites.
28
+ const sceneData = require('./scene-data-port').sceneDataOp;
29
+ const state = lazyHostBinding('state');
30
+ // S-4: filename grammar composed from the canonical owner (bytes unchanged)
31
+ const artifactNaming = require('@animastor/generation').artifactNaming;
32
+ // S-5: orchestration-owned behavior (FSM facade writers) reaches runtime only
33
+ // via the composition-root-injected seam registry — never a direct import.
34
+ const orchestrationSeams = lazyHostBinding('seams');
35
+ const activeScenes = require('./active-scenes-index');
36
+ const audio = lazyHostBinding('media.audio');
37
+ const genScope = lazyHostBinding('genScope');
38
+ // O-2: persistence arrives ONLY through the PersistencePort (host adapter:
39
+ // storage/runtime-persistence-adapter) — the scene-assets repository and the
40
+ // database handle left this file. SQL lives host-side; this file consumes
41
+ // state operations.
42
+ const persist = require('./persistence-port').persist;
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('./placeholder-audio-port').placeholderAudioOp;
47
+ // O-7: the audio scene FSM is driven ONLY through the AudioFsmPort (host
48
+ // adapter: storage/audio-fsm-adapter) — the audio-orchestrator host
49
+ // service left this file.
50
+ const audioFsmOp = require('./audio-fsm-port').audioFsmOp;
51
+ const fs = require('fs');
52
+ const path = require('path');
53
+
54
+ const { WINDOW_SIZE, OUTPUT_DIR } = config;
55
+
56
+ const BOOK_SCENE_KEY_PREFIX = 'animastor:book-scenes';
57
+ const BOOK_SCENE_TOTAL = (bookId) => `${BOOK_SCENE_KEY_PREFIX}:${bookId}:total`;
58
+ const BOOK_SCENE_NEXT = (bookId) => `${BOOK_SCENE_KEY_PREFIX}:${bookId}:next-index`;
59
+ const BOOK_WINDOW_START = (bookId) => `${BOOK_SCENE_KEY_PREFIX}:${bookId}:window-start`;
60
+
61
+ const CANCEL_FLAG_PREFIX = 'animastor:generation:cancel';
62
+ const cancelKey = (bookId) => `${CANCEL_FLAG_PREFIX}:${bookId}`;
63
+
64
+ const logPrefix = '[WINDOW]';
65
+
66
+ function log(msg) {
67
+ console.log(`${logPrefix} ${msg}`);
68
+ }
69
+
70
+ function warn(msg) {
71
+ console.warn(`${logPrefix} ⚠️ ${msg}`);
72
+ }
73
+
74
+ function error(msg) {
75
+ console.error(`${logPrefix} ❌ ${msg}`);
76
+ }
77
+
78
+ async function initSceneWindow(redis, scenes, loadedBook, buildId, bookId, scopeInfo) {
79
+ const scope = scopeInfo || (await genScope.getScope(redis, bookId));
80
+ const bounds = genScope.scopeBounds(scope, scenes);
81
+ const totalScenes = bounds.endIndex;
82
+ log(`Initializing window for ${scenes.length} scenes, scope=${scope?.scope || 'whole_book'} bounds=[${bounds.startIndex},${bounds.endIndex}) (window=${WINDOW_SIZE})`);
83
+
84
+ await redis.set(BOOK_SCENE_TOTAL(bookId), totalScenes);
85
+ await redis.set(BOOK_SCENE_NEXT(bookId), bounds.startIndex);
86
+ await redis.set(BOOK_WINDOW_START(bookId), bounds.startIndex);
87
+
88
+ let started = 0;
89
+ for (let i = bounds.startIndex; i < bounds.endIndex && i < WINDOW_SIZE + bounds.startIndex && i < scenes.length; i++) {
90
+ if (await isCancelled(redis, bookId)) {
91
+ log(`initSceneWindow cancelled after starting ${started} scenes`);
92
+ break;
93
+ }
94
+ await startScene(redis, scenes[i], buildId, bookId);
95
+ started++;
96
+ }
97
+ if (started > 0) {
98
+ const newNext = bounds.startIndex + started;
99
+ await redis.set(BOOK_SCENE_NEXT(bookId), newNext);
100
+ await redis.set(BOOK_WINDOW_START(bookId), bounds.startIndex);
101
+ }
102
+
103
+ log(`Window initialized: ${started}/${totalScenes - bounds.startIndex} scenes started`);
104
+ return started;
105
+ }
106
+
107
+ /**
108
+ * Set the window bounds for a scope. Call this when /regenerate is invoked
109
+ * with a new scope, BEFORE the initial slide.
110
+ */
111
+ async function setWindowBounds(redis, bookId, scopeInfo, allScenes) {
112
+ const bounds = genScope.scopeBounds(scopeInfo, allScenes);
113
+ await redis.set(BOOK_SCENE_TOTAL(bookId), bounds.endIndex);
114
+ await redis.set(BOOK_SCENE_NEXT(bookId), bounds.startIndex);
115
+ await redis.set(BOOK_WINDOW_START(bookId), bounds.startIndex);
116
+ return bounds;
117
+ }
118
+
119
+ /**
120
+ * Check if generation was cancelled for this book.
121
+ */
122
+ /**
123
+ * Check if a scene state is a terminal ready state (content complete).
124
+ */
125
+ function _isTerminalState(st) {
126
+ return st === 'video_ready' || st === 'failed';
127
+ }
128
+
129
+ /**
130
+ * Unified file status checker — single source of truth for checking
131
+ * what files exist on disk for a given scene.
132
+ *
133
+ * Phase 6 (R6.2): All four consumers (checkSceneContentCache,
134
+ * restoreChunkStatusForScene, reconcileWindowStatuses, recoverIuImagesFromDisk)
135
+ * use this function instead of duplicating fs.* calls.
136
+ *
137
+ * @returns {{ audio: { exists: boolean, isReal: boolean },
138
+ * image: { exists: boolean },
139
+ * video: { exists: boolean } }}
140
+ */
141
+ async function getSceneFilesStatus(buildDir, bookId, chapterId, sceneId) {
142
+ const result = {
143
+ audio: { exists: false, isReal: false },
144
+ image: { exists: false },
145
+ video: { exists: false }
146
+ };
147
+
148
+ if (!fs.existsSync(buildDir)) return result;
149
+
150
+ // Check audio file
151
+ const audioPath = path.join(buildDir, artifactNaming.sceneAudioName(bookId, chapterId, sceneId));
152
+ result.audio.exists = fs.existsSync(audioPath);
153
+
154
+ // Check IU image files (.png)
155
+ try {
156
+ const files = fs.readdirSync(buildDir);
157
+ const imagePrefix = artifactNaming.iuImagePrefix(bookId, chapterId, sceneId);
158
+ result.image.exists = files.some(f => f.startsWith(imagePrefix) && f.endsWith('.png'));
159
+ } catch (_) {}
160
+
161
+ // Check video file
162
+ const videoPath = path.join(buildDir, artifactNaming.sceneVideoName(bookId, chapterId, sceneId));
163
+ result.video.exists = fs.existsSync(videoPath);
164
+
165
+ return result;
166
+ }
167
+
168
+ /**
169
+ * Check scene content cache — returns advisory info about what files
170
+ * exist on disk and whether content is stale by version.
171
+ *
172
+ * Phase 3 (R3.3): This function NO LONGER makes decisions about skipping dispatch.
173
+ * It only returns information. The caller (orchestrator) decides what to do.
174
+ *
175
+ * @returns {{ audioOnDisk: boolean, imageOnDisk: boolean, videoOnDisk: boolean,
176
+ * staleByVersion: boolean, valid: boolean }}
177
+ */
178
+ async function checkSceneContentCache(redis, buildId, bookId, chapterId, sceneId) {
179
+ const buildDir = path.join(OUTPUT_DIR, buildId);
180
+ const result = {
181
+ audioOnDisk: false,
182
+ imageOnDisk: false,
183
+ videoOnDisk: false,
184
+ staleByVersion: false,
185
+ valid: false,
186
+ };
187
+
188
+ if (!fs.existsSync(buildDir)) return result;
189
+
190
+ // Read layer config
191
+ const layerKey = `animastor:layer-config:${bookId}`;
192
+ const layerRaw = await redis.get(layerKey);
193
+ let audioEnabled = true;
194
+ let imageEnabled = true;
195
+ let videoEnabled = true;
196
+ if (layerRaw) {
197
+ try {
198
+ const lc = JSON.parse(layerRaw);
199
+ audioEnabled = lc.audio_enabled !== false;
200
+ imageEnabled = lc.image_enabled !== false;
201
+ videoEnabled = lc.video_enabled !== false;
202
+ } catch (_) {}
203
+ }
204
+
205
+ // Use unified file status
206
+ const fileStatus = await getSceneFilesStatus(buildDir, bookId, chapterId, sceneId);
207
+
208
+ // Check audio file — distinguish real TTS from placeholder
209
+ if (audioEnabled) {
210
+ result.audioOnDisk = fileStatus.audio.exists;
211
+ if (result.audioOnDisk) {
212
+ try {
213
+ const hasReal = await placeholderAudioOp('hasRealAudio')(bookId, chapterId, sceneId, buildId);
214
+ if (!hasReal) result.audioOnDisk = false; // placeholder doesn't count as valid content
215
+ } catch (_) {
216
+ result.audioOnDisk = false;
217
+ }
218
+ }
219
+ } else {
220
+ result.audioOnDisk = true;
221
+ }
222
+
223
+ // Check for IU image files
224
+ if (imageEnabled) {
225
+ result.imageOnDisk = fileStatus.image.exists;
226
+ if (!result.imageOnDisk && videoEnabled) {
227
+ result.imageOnDisk = fileStatus.video.exists; // video as fallback
228
+ }
229
+ } else {
230
+ result.imageOnDisk = true;
231
+ }
232
+
233
+ // Check video file
234
+ if (videoEnabled) {
235
+ result.videoOnDisk = fileStatus.video.exists;
236
+ } else {
237
+ result.videoOnDisk = true;
238
+ }
239
+
240
+ // R15: Version-based staleness check (O-2: via PersistencePort —
241
+ // sceneVersions.getSceneVersions returns the scenes-table row shape)
242
+ try {
243
+ const sceneRows = await persist('sceneVersions.getSceneVersions')(bookId, chapterId, sceneId);
244
+ if (sceneRows.length > 0) {
245
+ const sv = sceneRows[0];
246
+ const aAsset = await persist('sceneAssets.getAsset')(bookId, chapterId, sceneId, 'audio', buildId)
247
+ || await persist('sceneAssets.getAsset')(bookId, chapterId, sceneId, 'audio');
248
+
249
+ if (aAsset && aAsset.scene_content_version != null && sv.content_version != null &&
250
+ aAsset.scene_content_version < sv.content_version) {
251
+ log(`[VERSION-STALE] ${bookId}/${chapterId}/${sceneId}: audio content_version=${aAsset.scene_content_version} < scene content_version=${sv.content_version}`);
252
+ result.staleByVersion = true;
253
+ }
254
+ if (aAsset && aAsset.scene_audio_config_version != null && sv.audio_config_version != null &&
255
+ aAsset.scene_audio_config_version < sv.audio_config_version) {
256
+ log(`[VERSION-STALE] ${bookId}/${chapterId}/${sceneId}: audio_config_version=${aAsset.scene_audio_config_version} < scene audio_config_version=${sv.audio_config_version}`);
257
+ result.staleByVersion = true;
258
+ }
259
+ }
260
+ } catch (_) {}
261
+
262
+ // Scene is valid if all enabled layers have content AND content is not stale by version
263
+ result.valid = result.audioOnDisk && result.imageOnDisk && result.videoOnDisk && !result.staleByVersion;
264
+
265
+ return result;
266
+ }
267
+
268
+ /**
269
+ * Д.3 (M3): Check if each asset type is stale by PG version.
270
+ * Returns { audio: boolean, image: boolean, video: boolean } where true means
271
+ * the asset's content_version is behind the scene's current version and needs regen.
272
+ * Used by restoreChunkStatusForScene and reconcileWindowStatuses to prevent disk-based
273
+ * 'ready' writes from overriding a pending force-regen.
274
+ */
275
+ async function _checkAssetVersionStale(bookId, chapterId, sceneId, buildId) {
276
+ const stale = Object.fromEntries(_mediaTypes().map(t => [t, false]));
277
+ try {
278
+ const sceneRows = await persist('sceneVersions.getSceneVersions')(bookId, chapterId, sceneId);
279
+ if (sceneRows.length === 0) return stale;
280
+ const sv = sceneRows[0];
281
+
282
+ const checkAsset = async (assetType) => {
283
+ const asset = await persist('sceneAssets.getAsset')(bookId, chapterId, sceneId, assetType, buildId)
284
+ || await persist('sceneAssets.getAsset')(bookId, chapterId, sceneId, assetType);
285
+ if (!asset) return false;
286
+ if (asset.scene_content_version != null && sv.content_version != null &&
287
+ asset.scene_content_version < sv.content_version) {
288
+ log(`[VERSION-STALE] ${bookId}/${chapterId}/${sceneId}: ${assetType} content_version=${asset.scene_content_version} < scene=${sv.content_version}`);
289
+ return true;
290
+ }
291
+ if (asset.scene_audio_config_version != null && sv.audio_config_version != null &&
292
+ asset.scene_audio_config_version < sv.audio_config_version) {
293
+ log(`[VERSION-STALE] ${bookId}/${chapterId}/${sceneId}: ${assetType} audio_config_version=${asset.scene_audio_config_version} < scene=${sv.audio_config_version}`);
294
+ return true;
295
+ }
296
+ return false;
297
+ };
298
+
299
+ stale.audio = await checkAsset('audio');
300
+ stale.image = await checkAsset('image');
301
+ stale.video = await checkAsset('video');
302
+ } catch (_) {}
303
+ return stale;
304
+ }
305
+
306
+ /**
307
+ * After markDirtyScenes resets chunk metadata, restore it based on
308
+ * which files actually exist on disk so /assets-state reports correct counts.
309
+ *
310
+ * Д.3 (M3): Only writes 'ready' if the asset version in PG is current.
311
+ * If the content_version has been bumped (force-regen), stale files on disk
312
+ * do NOT override the pending state — the scene stays pending for regeneration.
313
+ */
314
+ async function restoreChunkStatusForScene(redis, buildId, bookId, chapterId, sceneId) {
315
+ const buildDir = path.join(OUTPUT_DIR, buildId);
316
+ if (!fs.existsSync(buildDir)) return;
317
+
318
+ // Д.3: Check PG version staleness before writing chunk statuses.
319
+ // If the asset is stale by version, don't mark chunks as 'ready' even
320
+ // if files exist on disk — the scene needs regeneration.
321
+ const versionStale = await _checkAssetVersionStale(bookId, chapterId, sceneId, buildId);
322
+
323
+ const fileStatus = await getSceneFilesStatus(buildDir, bookId, chapterId, sceneId);
324
+ let audioIsReal = false;
325
+ if (fileStatus.audio.exists && !versionStale.audio) {
326
+ try { audioIsReal = await placeholderAudioOp('hasRealAudio')(bookId, chapterId, sceneId, buildId); }
327
+ catch (_) {}
328
+ }
329
+
330
+ const prefix = `animastor:chunk:${bookId}_${chapterId}_${sceneId}_`;
331
+ let cursor = '0';
332
+ do {
333
+ const scan = await redis.scan(cursor, 'MATCH', `${prefix}*`, 'COUNT', 100);
334
+ cursor = scan[0];
335
+ for (const key of scan[1]) {
336
+ const raw = await redis.get(key);
337
+ if (!raw) continue;
338
+ const data = JSON.parse(raw);
339
+
340
+ if (fileStatus.audio.exists && !versionStale.audio) {
341
+ data.audio = true;
342
+ data.audio_status = audioIsReal ? 'ready' : 'placeholder';
343
+ }
344
+ if (fileStatus.image.exists && !versionStale.image) {
345
+ data.image = true;
346
+ data.image_status = 'ready';
347
+ }
348
+ if (fileStatus.video.exists && !versionStale.video) {
349
+ data.video = true;
350
+ data.video_status = 'ready';
351
+ }
352
+ await redis.set(key, JSON.stringify(data));
353
+ }
354
+ } while (cursor !== '0');
355
+ }
356
+
357
+ /**
358
+ * Check if generation was cancelled for this book.
359
+ */
360
+ async function isCancelled(redis, bookId) {
361
+ const val = await redis.get(cancelKey(bookId));
362
+ return val === 'true' || val === '1';
363
+ }
364
+
365
+ /**
366
+ * Clear the cancel flag for this book.
367
+ */
368
+ async function clearCancelFlag(redis, bookId) {
369
+ await redis.del(cancelKey(bookId));
370
+ }
371
+
372
+ /**
373
+ * Set the cancel flag for this book.
374
+ */
375
+ async function setCancelFlag(redis, bookId) {
376
+ await redis.set(cancelKey(bookId), 'true');
377
+ }
378
+
379
+ async function isWindowComplete(redis, bookId) {
380
+ const nextIdx = parseInt(await redis.get(BOOK_SCENE_NEXT(bookId)) || '0', 10);
381
+ if (nextIdx === 0) return false;
382
+ const windowStart = parseInt(await redis.get(BOOK_WINDOW_START(bookId)) || '0', 10);
383
+ const bookData = sceneData('loadBook')(bookId);
384
+ if (!bookData) return false;
385
+ const scenes = sceneData('collectScenes')(bookData);
386
+
387
+ const workerHealth = require('./worker-health');
388
+ // VISIBILITY: workspace-aware availability — a foreign workspace's private
389
+ // video worker must not make this book's window wait for video that will
390
+ // never be served for it (and the owner's private worker DOES count).
391
+ let bookWorkspaceId = null;
392
+ // §32.30 (PW-2 closure): the former lazy require of the pinned host
393
+ // transport ('./gpu-dispatcher') is replaced by the composition-root
394
+ // host-binding; optional-load semantics + system-pool fallback stay
395
+ // host-owned (the host wrapper reproduces the exact former behavior).
396
+ try {
397
+ const resolveWorkspaceForBook = lazyHostBinding('resolveWorkspaceForBook');
398
+ bookWorkspaceId = await resolveWorkspaceForBook(bookId);
399
+ } catch (_) { /* system pool availability only */ }
400
+ const hasVideo = await workerHealth.isAvailable(redis, 'video', bookWorkspaceId);
401
+
402
+ // Read layer config so we know which layers are actually enabled.
403
+ // If a layer is disabled, we don't require it to be 'ready'.
404
+ const layerKey = `animastor:layer-config:${bookId}`;
405
+ const layerRaw = await redis.get(layerKey);
406
+ let imageEnabled = true;
407
+ let videoEnabled = true;
408
+ if (layerRaw) {
409
+ try {
410
+ const lc = JSON.parse(layerRaw);
411
+ imageEnabled = lc.image_enabled !== false;
412
+ videoEnabled = lc.video_enabled !== false;
413
+ } catch (_) {}
414
+ }
415
+
416
+ for (let i = windowStart; i < nextIdx && i < scenes.length; i++) {
417
+ const s = scenes[i];
418
+ // T8: используем per-asset state вместо scene-state
419
+ const assetStates = await state.getAssetStates(redis, bookId, s.chapter_id, s.scene_id);
420
+ if (!assetStates) {
421
+ log(`Window incomplete: ${s.chapter_id}/${s.scene_id} has no asset states`);
422
+ return false;
423
+ }
424
+
425
+ const audioOk = assetStates.audio === state.AssetState.READY ||
426
+ assetStates.audio === state.AssetState.PLACEHOLDER;
427
+
428
+ // If images are disabled by layer config, skip image readiness check
429
+ const imageOk = !imageEnabled ||
430
+ assetStates.image === state.AssetState.READY ||
431
+ assetStates.image === state.AssetState.PLACEHOLDER;
432
+
433
+ // If video is disabled by layer config, skip video readiness check
434
+ let videoOk = true;
435
+ if (hasVideo && videoEnabled) {
436
+ videoOk = assetStates.video === state.AssetState.READY;
437
+ }
438
+
439
+ if (!audioOk || !imageOk || !videoOk) {
440
+ log(`Window incomplete: ${s.chapter_id}/${s.scene_id} audio=${audioOk} image=${imageOk} video=${videoOk}`);
441
+ return false;
442
+ }
443
+ }
444
+ log(`Window complete: all ${nextIdx - windowStart} scenes have audio+image${hasVideo && videoEnabled ? '+video' : ''} ready`);
445
+ return true;
446
+ }
447
+
448
+ /**
449
+ * Try to slide window forward if all current window scenes have audio + image complete.
450
+ */
451
+ async function trySlideWindowOnComplete(redis, bookId, loadedBook, buildId) {
452
+ if (await isCancelled(redis, bookId)) {
453
+ log(`trySlideWindowOnComplete: generation cancelled for ${bookId}`);
454
+ return { started: 0, remaining: 0, reason: 'cancelled' };
455
+ }
456
+
457
+ // O-5: selective-task gate via the ProgressEventsPort (host adapter:
458
+ // storage/progress-events-adapter) — the generation-progress host
459
+ // service require left this call site (lazy resolution preserved).
460
+ if (await require('./progress-events-port').progressEventsOp('hasActiveTasks')(redis, bookId)) {
461
+ return { started: 0, remaining: 0, reason: 'task_managed' };
462
+ }
463
+
464
+ // Reconcile chunk statuses against actual files on disk before checking
465
+ // window completeness. This catches stale 'pending' statuses that may
466
+ // have survived from a previous Cancel→Generate cycle — allowing the
467
+ // window to slide without waiting for the next scheduler tick.
468
+ await reconcileWindowStatuses(redis, bookId, buildId);
469
+
470
+ if (await isWindowComplete(redis, bookId)) {
471
+ const result = await slideWindow(redis, bookId, loadedBook, buildId);
472
+ // After sliding, do a final reconciliation pass so /assets-state
473
+ // reports correct counts immediately. This catches any chunk statuses
474
+ // that startScene reset to 'pending' even though files exist on disk.
475
+ await reconcileWindowStatuses(redis, bookId, buildId);
476
+ return result;
477
+ }
478
+ const total = parseInt(await redis.get(BOOK_SCENE_TOTAL(bookId)) || '0', 10);
479
+ const nextIdx = parseInt(await redis.get(BOOK_SCENE_NEXT(bookId)) || '0', 10);
480
+ return { started: 0, remaining: Math.max(0, total - nextIdx), reason: 'window_incomplete' };
481
+ }
482
+
483
+ async function slideWindow(redis, bookId, loadedBook, buildId) {
484
+ if (await isCancelled(redis, bookId)) {
485
+ log(`Slide skipped: generation cancelled for ${bookId}`);
486
+ return { started: 0, remaining: 0, reason: 'cancelled' };
487
+ }
488
+
489
+ const total = parseInt(await redis.get(BOOK_SCENE_TOTAL(bookId)) || '0', 10);
490
+ let nextIdx = parseInt(await redis.get(BOOK_SCENE_NEXT(bookId)) || '0', 10);
491
+
492
+ if (nextIdx >= total) {
493
+ log(`All ${total} scenes already started in this scope`);
494
+ return { started: 0, remaining: 0 };
495
+ }
496
+
497
+ const bookData = loadedBook || sceneData('loadBook')(bookId);
498
+ const scenes = sceneData('collectScenes')(bookData);
499
+ let started = 0;
500
+ const windowStart = nextIdx;
501
+
502
+ while (nextIdx < total && started < WINDOW_SIZE) {
503
+ if (await isCancelled(redis, bookId)) {
504
+ log(`Slide interrupted: generation cancelled for ${bookId} after starting ${started} scenes`);
505
+ break;
506
+ }
507
+
508
+ const scene = scenes[nextIdx];
509
+ if (!scene) {
510
+ warn(`Scene at index ${nextIdx} not found`);
511
+ nextIdx++;
512
+ continue;
513
+ }
514
+
515
+ // T8: scene-state removed — use per-asset states
516
+ const assetStates = await state.getAssetStates(redis, bookId, scene.chapter_id, scene.scene_id);
517
+ const allReady = assetStates.audio === state.AssetState.READY &&
518
+ assetStates.image === state.AssetState.READY &&
519
+ assetStates.video === state.AssetState.READY;
520
+ const allFailed = assetStates.audio === state.AssetState.FAILED &&
521
+ assetStates.image === state.AssetState.FAILED &&
522
+ assetStates.video === state.AssetState.FAILED;
523
+
524
+ if (allReady || allFailed) {
525
+ log(`Scene ${scene.chapter_id}/${scene.scene_id} already in terminal state, window slot skipped`);
526
+ nextIdx++;
527
+ started++;
528
+ continue;
529
+ }
530
+
531
+ // Check cache advisory for valid content.
532
+ const cacheInfo = await checkSceneContentCache(redis, buildId, bookId, scene.chapter_id, scene.scene_id);
533
+ if (cacheInfo.valid) {
534
+ log(`Scene ${scene.chapter_id}/${scene.scene_id} has valid content, promoting`);
535
+ await orchestrationSeams.getOrchestrationSeam('setSceneAllReady')(redis, bookId, scene.chapter_id, scene.scene_id, buildId);
536
+ await restoreChunkStatusForScene(redis, buildId, bookId, scene.chapter_id, scene.scene_id);
537
+ nextIdx++;
538
+ started++;
539
+ continue;
540
+ }
541
+
542
+ // No valid content — remove from active index for restart
543
+ const scheduler = require('./runtime-scheduler');
544
+ await scheduler.removeSceneFromActiveIndex(redis, bookId, scene.chapter_id, scene.scene_id);
545
+
546
+ const didStart = await startScene(redis, scene, buildId, bookId);
547
+ if (didStart) {
548
+ started++;
549
+ }
550
+ nextIdx++;
551
+ }
552
+
553
+ await redis.set(BOOK_SCENE_NEXT(bookId), nextIdx);
554
+ await redis.set(BOOK_WINDOW_START(bookId), windowStart);
555
+ log(`Window slid: started ${started} more scenes (next index=${nextIdx}/${total}, windowStart=${windowStart})`);
556
+ return { started, remaining: total - nextIdx };
557
+ }
558
+
559
+ /**
560
+ * Reconcile chunk statuses against actual file existence for all scenes in a book.
561
+ * Scans chunk metadata and updates 'pending' → 'ready' where files exist on disk.
562
+ * This ensures /assets-state returns correct counts even if completion handlers
563
+ * were missed (e.g. after Cancel→Generate cycles).
564
+ */
565
+ async function reconcileWindowStatuses(redis, bookId, buildId) {
566
+ const buildDir = path.join(OUTPUT_DIR, buildId);
567
+ if (!fs.existsSync(buildDir)) return { reconciled: 0 };
568
+
569
+ // B7: SCAN вместо keys() — не блокируем Redis на больших датасетах
570
+ const chunkKeys = [];
571
+ let cursor = '0';
572
+ do {
573
+ const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', `animastor:chunk:${bookId}_*`, 'COUNT', 200);
574
+ cursor = nextCursor;
575
+ chunkKeys.push(...keys);
576
+ } while (cursor !== '0');
577
+ let reconciled = 0;
578
+
579
+ // Track which scenes we've already checked (avoid duplicate PG queries)
580
+ const versionCache = new Map();
581
+
582
+ for (const key of chunkKeys) {
583
+ const raw = await redis.get(key);
584
+ if (!raw) continue;
585
+ const data = JSON.parse(raw);
586
+ if (!data.chapter_id || !data.scene_id) continue;
587
+
588
+ let changed = false;
589
+
590
+ // Use unified file status
591
+ const fileStatus = await getSceneFilesStatus(buildDir, data.book_id, data.chapter_id, data.scene_id);
592
+
593
+ // Д.3: Check PG version staleness (cache per scene to avoid N+1 queries)
594
+ const sceneKey = `${data.book_id}:${data.chapter_id}:${data.scene_id}`;
595
+ if (!versionCache.has(sceneKey)) {
596
+ versionCache.set(sceneKey, await _checkAssetVersionStale(data.book_id, data.chapter_id, data.scene_id, buildId));
597
+ }
598
+ const stale = versionCache.get(sceneKey);
599
+
600
+ // Check audio file — distinguish real TTS from placeholder
601
+ if (data.audio_status === 'pending' || data.audio_status === 'placeholder') {
602
+ if (fileStatus.audio.exists && !stale.audio) {
603
+ data.audio = true;
604
+ try {
605
+ const hasReal = await placeholderAudioOp('hasRealAudio')(data.book_id, data.chapter_id, data.scene_id, buildId);
606
+ data.audio_status = hasReal ? 'ready' : 'placeholder';
607
+ } catch (_) {
608
+ data.audio_status = 'placeholder';
609
+ }
610
+ changed = true;
611
+ }
612
+ }
613
+
614
+ // Check for IU image files
615
+ if (data.image_status === 'pending') {
616
+ if (fileStatus.image.exists && !stale.image) {
617
+ data.image = true;
618
+ data.image_status = 'ready';
619
+ changed = true;
620
+ }
621
+ }
622
+
623
+ // Check video file
624
+ if (data.video_status === 'pending') {
625
+ if (fileStatus.video.exists && !stale.video) {
626
+ data.video = true;
627
+ data.video_status = 'ready';
628
+ changed = true;
629
+ }
630
+ }
631
+
632
+ if (changed) {
633
+ // key is the full Redis key (animastor:chunk:bookId_chapterId_sceneId_index)
634
+ await redis.set(key, JSON.stringify(data));
635
+ reconciled++;
636
+ }
637
+ }
638
+
639
+ if (reconciled > 0) {
640
+ log(`Reconciled ${reconciled} chunk statuses for book ${bookId}`);
641
+ }
642
+ return { reconciled };
643
+ }
644
+
645
+ async function startScene(redis, s, buildId, bookId) {
646
+ if (await isCancelled(redis, bookId)) {
647
+ log(`startScene skipped: generation cancelled for ${bookId}`);
648
+ return false;
649
+ }
650
+
651
+ const chapterId = s.chapter_id;
652
+ const sceneId = s.scene_id;
653
+
654
+ log(`Starting scene: ${bookId}/${chapterId}/${sceneId}`);
655
+
656
+ // Before dispatching to GPU, check cache advisory for valid content on disk.
657
+ // The orchestrator decides whether to skip GPU dispatch based on the advisory.
658
+ const cacheInfo = await checkSceneContentCache(redis, buildId, bookId, chapterId, sceneId);
659
+ if (cacheInfo.valid) {
660
+ log(`Scene ${bookId}/${chapterId}/${sceneId}: valid content on disk, skipping GPU dispatch`);
661
+ // M5: Facade owns READY + syncLinearState
662
+ // S-5: via the injected orchestration seam
663
+ await orchestrationSeams.getOrchestrationSeam('setSceneAllReady')(redis, bookId, chapterId, sceneId, buildId);
664
+ // Restore chunk metadata that was reset by markDirtyScenes so the progress
665
+ // endpoint (/assets-state) correctly counts this scene as ready.
666
+ await restoreChunkStatusForScene(redis, buildId, bookId, chapterId, sceneId);
667
+ return true;
668
+ }
669
+
670
+ // Check layer config: if audio is disabled, skip to IMAGE_PENDING
671
+ const layerKey = `animastor:layer-config:${bookId}`;
672
+ const layerRaw = await redis.get(layerKey);
673
+ let audioDisabled = false;
674
+ if (layerRaw) {
675
+ try {
676
+ const layerCfg = JSON.parse(layerRaw);
677
+ audioDisabled = layerCfg.audio_enabled === false;
678
+ } catch (e) {
679
+ warn(`Failed to parse layer config for ${bookId}: ${e.message}`);
680
+ }
681
+ }
682
+
683
+ // T8: setScenePending через фасад — always succeeds (per-asset state write)
684
+ // S-5: via the injected orchestration seam
685
+ if (audioDisabled) {
686
+ await orchestrationSeams.getOrchestrationSeam('setScenePending')(redis, bookId, chapterId, sceneId, 'image');
687
+ } else {
688
+ await orchestrationSeams.getOrchestrationSeam('setScenePending')(redis, bookId, chapterId, sceneId, 'audio');
689
+ }
690
+
691
+ // M5: Facade owns PLACEHOLDER + syncLinearState
692
+ // S-5: via the injected orchestration seam
693
+ if (audioDisabled) {
694
+ await orchestrationSeams.getOrchestrationSeam('setScenePlaceholder')(redis, bookId, chapterId, sceneId);
695
+ }
696
+
697
+ // T8: scene-state removed — build_id derived from manifest
698
+ const stateKey = `animastor:chunk:${bookId}_${chapterId}_${sceneId}_0001`;
699
+ const raw = await redis.get(stateKey);
700
+ if (raw) {
701
+ const data = JSON.parse(raw);
702
+ data.build_id = buildId;
703
+ await redis.set(stateKey, JSON.stringify(data));
704
+ }
705
+
706
+ const segments = audio.buildSegments(s);
707
+ const expectedChunkCount = segments.length;
708
+ for (let i = 0; i < expectedChunkCount; i++) {
709
+ const chunkIndex = i + 1;
710
+ const chunkId = audio.makeChunkId(chapterId, sceneId, chunkIndex, bookId);
711
+ const chunkKey = `animastor:chunk:${chunkId}`;
712
+ const existingChunk = await redis.get(chunkKey);
713
+ if (existingChunk) {
714
+ const data = JSON.parse(existingChunk);
715
+ data.audio = false;
716
+ data.audio_status = 'pending';
717
+ data.image = false;
718
+ data.video = false;
719
+ data.video_status = 'pending';
720
+ data.build_id = buildId;
721
+ // 🔧 FIX: Always update expected_chunk_count for existing chunks too.
722
+ // The import creates chunk _0001 with expected_chunk_count=1, but
723
+ // buildSegments may produce more segments (e.g. 9). Without this
724
+ // update, chunk _0001 retains expected_chunk_count=1, causing
725
+ // triggerAudioMerge to merge prematurely (with only 1 chunk)
726
+ // instead of waiting for all chunks to arrive.
727
+ data.expected_chunk_count = expectedChunkCount;
728
+ data.padded_text = segments[i]?.padded || false;
729
+ await redis.set(chunkKey, JSON.stringify(data));
730
+ } else {
731
+ const chunkData = {
732
+ build_id: buildId,
733
+ book_id: bookId,
734
+ chapter_id: chapterId,
735
+ scene_id: sceneId,
736
+ chunk_index: String(chunkIndex).padStart(4, '0'),
737
+ expected_chunk_count: expectedChunkCount,
738
+ scene_type: s.scene_type || 'narration',
739
+ scene_order: s.scene_order || 0,
740
+ audio: false,
741
+ audio_status: 'pending',
742
+ image: false,
743
+ image_status: 'pending',
744
+ padded_text: segments[i]?.padded || false
745
+ };
746
+ await redis.set(chunkKey, JSON.stringify(chunkData));
747
+ await redis.sadd(`animastor:chunks:${bookId}`, chunkId);
748
+ }
749
+ }
750
+
751
+ // If audio is disabled, mark all chunks as audio-ready (placeholder timing)
752
+ if (audioDisabled) {
753
+ for (let i = 0; i < expectedChunkCount; i++) {
754
+ const chunkIndex = i + 1;
755
+ const chunkId = audio.makeChunkId(chapterId, sceneId, chunkIndex, bookId);
756
+ const chunkKey = `animastor:chunk:${chunkId}`;
757
+ const existingChunk = await redis.get(chunkKey);
758
+ if (existingChunk) {
759
+ const data = JSON.parse(existingChunk);
760
+ data.audio = true;
761
+ data.audio_status = 'placeholder';
762
+ await redis.set(chunkKey, JSON.stringify(data));
763
+ }
764
+ }
765
+ }
766
+
767
+ const addResult = await activeScenes.addActiveScene(redis, bookId, chapterId, sceneId);
768
+ log(`Scene queued: ${bookId}/${chapterId}/${sceneId}`);
769
+
770
+ // Only when audio is enabled — if disabled, we already set chunks as ready above
771
+ if (audioDisabled) return addResult.added;
772
+
773
+ // Placeholder audio: создаётся синхронно (await), ДО того как сцена попадает в scheduler.
774
+ // Задержка ~2s (ffmpeg silence generation) — не критична, но устраняет race condition
775
+ // между setImmediate и generateSceneAudio (placeholder re-created после удаления).
776
+ try {
777
+ const buildEffective = buildId || 'default';
778
+ const phResult = await placeholderAudioOp('ensurePlaceholderAudio')(buildEffective, bookId, chapterId, sceneId);
779
+ if (phResult.created) {
780
+ log(`Placeholder audio created for ${bookId}/${chapterId}/${sceneId} (${phResult.durationSec.toFixed(1)}s)`);
781
+ } else {
782
+ log(`Placeholder audio reused for ${bookId}/${chapterId}/${sceneId} (${phResult.reason})`);
783
+ }
784
+
785
+ // Update chunk metadata — audio_status = 'placeholder' (not 'ready')
786
+ for (let i = 0; i < expectedChunkCount; i++) {
787
+ const chunkIndex = i + 1;
788
+ const chunkId = audio.makeChunkId(chapterId, sceneId, chunkIndex, bookId);
789
+ const chunkKey = `animastor:chunk:${chunkId}`;
790
+ const chunk = await redis.get(chunkKey);
791
+ if (chunk) {
792
+ const data = JSON.parse(chunk);
793
+ data.audio = true;
794
+ data.audio_status = 'placeholder';
795
+ await redis.set(chunkKey, JSON.stringify(data));
796
+ }
797
+ }
798
+ // 🔧 AUDIO-ORCH: Установить phase = PLACEHOLDER_READY — единственный арбитр состояния
799
+ // O-7: через AudioFsmPort (host adapter: storage/audio-fsm-adapter).
800
+ await audioFsmOp('initPlaceholderReady')(redis, bookId, chapterId, sceneId, buildEffective, expectedChunkCount);
801
+ } catch (err) {
802
+ warn(`Placeholder audio failed for ${bookId}/${chapterId}/${sceneId}: ${err.message}`);
803
+ }
804
+
805
+ return addResult.added;
806
+ }
807
+
808
+ module.exports = {
809
+ initSceneWindow,
810
+ setWindowBounds,
811
+ slideWindow,
812
+ startScene,
813
+ isWindowComplete,
814
+ trySlideWindowOnComplete,
815
+ isCancelled,
816
+ clearCancelFlag,
817
+ setCancelFlag,
818
+ cancelKey,
819
+ getSceneFilesStatus,
820
+ checkSceneContentCache,
821
+ restoreChunkStatusForScene,
822
+ reconcileWindowStatuses,
823
+ WINDOW_SIZE,
824
+ BOOK_SCENE_TOTAL,
825
+ BOOK_SCENE_NEXT,
826
+ BOOK_WINDOW_START,
827
+ };