@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.
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/package.json +51 -0
- package/src/host/host-bindings.js +138 -0
- package/src/index.js +99 -0
- package/src/orchestration/index.js +40 -0
- package/src/orchestration/orchestrator.js +630 -0
- package/src/orchestration/runtime-result-consumer.js +54 -0
- package/src/orchestration/scene-callbacks.js +542 -0
- package/src/orchestration/scene-orchestrator.js +601 -0
- package/src/orchestration/scene-restoration.js +129 -0
- package/src/orchestration/scene-utils.js +36 -0
- package/src/runtime/active-scenes-index.js +164 -0
- package/src/runtime/audio-fsm-port.js +187 -0
- package/src/runtime/circuit-breaker.js +588 -0
- package/src/runtime/counter-reconciliation.js +308 -0
- package/src/runtime/dispatch-engine.js +1768 -0
- package/src/runtime/failure-taxonomy.js +125 -0
- package/src/runtime/hub-cancel-port.js +122 -0
- package/src/runtime/layer-config-port.js +136 -0
- package/src/runtime/lease-manager.js +607 -0
- package/src/runtime/persistence-port.js +162 -0
- package/src/runtime/placeholder-audio-port.js +117 -0
- package/src/runtime/progress-events-port.js +121 -0
- package/src/runtime/reconciliation-engine.js +2405 -0
- package/src/runtime/retry-budget-manager.js +229 -0
- package/src/runtime/runtime-metrics.js +427 -0
- package/src/runtime/runtime-result-emitter.js +123 -0
- package/src/runtime/runtime-scheduler.js +629 -0
- package/src/runtime/scene-data-port.js +114 -0
- package/src/runtime/scene-window.js +827 -0
- package/src/runtime/video-fsm-port.js +189 -0
- package/src/runtime/worker-health.js +270 -0
- package/test/counter-reconciliation.test.js +164 -0
- package/test/mocks/redis-mock.js +221 -0
- 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
|
+
};
|