@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,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
|
+
};
|