@animastor/player 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,419 @@
1
+ // ======================================================
2
+ // PLAYER ROUTES — SCENE DATA (status / storyboard / timings / waveform)
3
+ // ======================================================
4
+ // Byte-for-byte relocation of the scene-data handlers from the host
5
+ // routes/generation-routes.cjs (Player route split, then physical move to
6
+ // packages/animastor-player — no behavior change). Ports: computeVideoStartMs
7
+ // and computeWaveform arrive via
8
+ // playerPorts (host keeps ffprobe/ffmpeg + workflows knowledge); image
9
+ // .getSceneDuration / .getOrCreatePreview stay injected deps on the shared
10
+ // context (host image-pipeline internals the player must not import);
11
+ // bookProjections are the two pure VBook-runtime read projections injected by
12
+ // the registrar (the book module itself never reaches this file).
13
+ // Consumers: web playbackStore/EditPage/Navigate, Android Repository,
14
+ // test/scene-timings.test.js.
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const naming = require('./artifact-naming.cjs');
19
+
20
+ module.exports = function(app, ctx, deps) {
21
+ const { image, bookProjections } = deps;
22
+
23
+ // ── Scene status ────────────────────────────────────────────────────
24
+ app.get('/api/v1/scene/:bookId/:chapterId/:sceneId/status', async (req, res) => {
25
+ try {
26
+ const { bookId, chapterId, sceneId } = req.params;
27
+ const buildId = ctx.getEffectiveBuildId(bookId, req.query.build_id, ctx.log);
28
+ const buildDir = ctx.buildDir(buildId);
29
+ const audioPath = path.join(buildDir, naming.sceneAudioName(bookId, chapterId, sceneId));
30
+ const videoPath = path.join(buildDir, naming.sceneVideoName(bookId, chapterId, sceneId));
31
+ const imagePath = path.join(buildDir, naming.sceneImageName(bookId, chapterId, sceneId));
32
+
33
+ const audioReady = fs.existsSync(audioPath);
34
+ const videoReady = fs.existsSync(videoPath);
35
+
36
+ // Content version of the scene video: the file mtime (same source
37
+ // as the ETag in streamFileWithRange). build_id is immutable per
38
+ // book and regeneration replaces files IN PLACE (same URL, new
39
+ // bytes), so a client-side video cache keyed by URL alone would
40
+ // serve STALE video after a regeneration. The Android player
41
+ // appends this as ?v= to the video URL → the cache key changes
42
+ // exactly when the content changes (no wholesale cache wipe).
43
+ // 0 = no video file.
44
+ let videoVersion = 0;
45
+ if (videoReady) {
46
+ try {
47
+ videoVersion = Math.floor(fs.statSync(videoPath).mtimeMs);
48
+ } catch {}
49
+ }
50
+
51
+ // Image readiness: check for either the scene .png file or IU images
52
+ let imageReady = fs.existsSync(imagePath);
53
+ if (!imageReady && fs.existsSync(buildDir)) {
54
+ const iuPrefix = naming.iuImagePrefix(bookId, chapterId, sceneId);
55
+ try {
56
+ const dirFiles = fs.readdirSync(buildDir);
57
+ imageReady = dirFiles.some(f => f.startsWith(iuPrefix) && f.endsWith('.png'));
58
+ } catch {}
59
+ }
60
+
61
+ // Scene type from book JSON
62
+ let sceneType = 'narration';
63
+ try {
64
+ const b = ctx.playerModel.loadBook(bookId);
65
+ if (b) {
66
+ for (const ch of b.chapters || []) {
67
+ if (ch.chapter_id !== chapterId) continue;
68
+ for (const sc of ch.scenes || []) {
69
+ if (sc.scene_id === sceneId) {
70
+ sceneType = sc.type || sc.scene_type || 'narration';
71
+ break;
72
+ }
73
+ }
74
+ }
75
+ }
76
+ } catch {}
77
+
78
+ res.json({
79
+ book_id: bookId, chapter_id: chapterId, scene_id: sceneId,
80
+ build_id: buildId, scene_type: sceneType,
81
+ audio_ready: audioReady, video_ready: videoReady, image_ready: imageReady,
82
+ video_version: videoVersion,
83
+ });
84
+ } catch (err) {
85
+ res.status(500).json({ error: err.message });
86
+ }
87
+ });
88
+
89
+ // ── Scene storyboard (PG-first, book-JSON fallback, timing merge) ────
90
+ app.get('/api/v1/scene/:bookId/:chapterId/:sceneId/storyboard', async (req, res) => {
91
+ try {
92
+ const { bookId, chapterId, sceneId } = req.params;
93
+ const buildId = ctx.getEffectiveBuildId(bookId, req.query.build_id, ctx.log);
94
+
95
+ let ius = [];
96
+ try {
97
+ const pgRows = await ctx.iuRepo.getImageUnitsForScene(buildId, bookId, chapterId, sceneId);
98
+ // Only use PG rows if they have text — stale rows with null/empty
99
+ // text (e.g. after DELETE /cache) should fall back to book JSON
100
+ // which has the real text.
101
+ if (pgRows && pgRows.length > 0 && pgRows.some(r => r.text != null && r.text !== '')) {
102
+ ius = pgRows.map(r => ({
103
+ unit_id: r.unit_id, scene_id: r.scene_id, text: r.text,
104
+ text_proportion: r.text_proportion, estimated_duration_sec: r.estimated_duration_sec,
105
+ audio_file: r.scene_audio_file,
106
+ // Note: start_ms=0 maps to null here, but unlike the
107
+ // timings GET route this is self-corrected by the pgMap
108
+ // merge below (rows with end_ms>0 override the nulls).
109
+ start_ms: r.start_ms != null && (Number(r.start_ms) || 0) > 0 ? Number(r.start_ms) : null,
110
+ end_ms: r.end_ms != null && (Number(r.end_ms) || 0) > 0 ? Number(r.end_ms) : null,
111
+ }));
112
+ }
113
+ } catch (dbErr) {
114
+ console.warn('[SCENE STORYBOARD] PG read failed, falling back to book data:', dbErr.message);
115
+ }
116
+
117
+ if (ius.length === 0) {
118
+ try {
119
+ const b = ctx.playerModel.loadBook(bookId);
120
+ if (b) {
121
+ const sceneData = bookProjections.findSceneRuntimeData(b, chapterId, sceneId);
122
+ if (sceneData && sceneData.payload) {
123
+ const sceneUnits = bookProjections.collectSceneUnits(sceneData.payload);
124
+ let order = 0;
125
+ for (const u of sceneUnits) {
126
+ ius.push({ unit_id: u.id, scene_id: sceneId, text: u.text, text_proportion: 0, estimated_duration_sec: 0, audio_file: null, start_ms: null, end_ms: null, _order: order });
127
+ order++;
128
+ }
129
+ const totalTextLen = ius.reduce((s, i) => s + (i.text || '').length, 0);
130
+ ius.sort((a, b) => a._order - b._order);
131
+ for (const iu of ius) {
132
+ iu.text_proportion = totalTextLen > 0 ? (iu.text || '').length / totalTextLen : 1;
133
+ delete iu._order;
134
+ }
135
+ }
136
+ }
137
+ } catch (bookErr) {
138
+ console.warn('[SCENE STORYBOARD] Book data fallback failed:', bookErr.message);
139
+ }
140
+ }
141
+
142
+ const needsDuration = ius.every(iu => !iu.estimated_duration_sec || iu.estimated_duration_sec === 0);
143
+ if (needsDuration && ius.length > 0) {
144
+ const sceneDuration = await image.getSceneDuration(buildId, bookId, chapterId, sceneId);
145
+ if (sceneDuration > 0) {
146
+ for (const iu of ius) {
147
+ iu.estimated_duration_sec = parseFloat((sceneDuration * (iu.text_proportion || 0)).toFixed(3));
148
+ }
149
+ }
150
+ const sceneDurationMs = Math.round(sceneDuration * 1000);
151
+ let cursorMs = 0;
152
+ for (const iu of ius) {
153
+ const durMs = Math.max(200, Math.round((iu.estimated_duration_sec || 1) * 1000));
154
+ iu._start_ms = cursorMs;
155
+ let endMs = cursorMs + durMs;
156
+ if (sceneDurationMs > 0 && endMs > sceneDurationMs) endMs = sceneDurationMs;
157
+ iu._end_ms = endMs;
158
+ cursorMs = endMs;
159
+ }
160
+ for (const [idx, iu] of ius.entries()) {
161
+ try {
162
+ await ctx.iuRepo.upsertImageUnit(buildId, bookId, chapterId, sceneId, iu.unit_id, {
163
+ scene_order: idx, text: iu.text, text_length: (iu.text || '').length,
164
+ text_proportion: iu.text_proportion || 0, scene_duration_sec: sceneDuration || 0,
165
+ estimated_duration_sec: iu.estimated_duration_sec || 0,
166
+ scene_audio_file: naming.sceneAudioName(bookId, chapterId, sceneId),
167
+ start_ms: iu._start_ms != null ? iu._start_ms : null,
168
+ end_ms: iu._end_ms != null ? iu._end_ms : null,
169
+ });
170
+ } catch (pgErr) {
171
+ console.warn('[SCENE STORYBOARD] Failed to persist IU to PG:', pgErr.message);
172
+ }
173
+ }
174
+ }
175
+
176
+ try {
177
+ const pgRows = await ctx.iuRepo.getImageUnitsForScene(buildId, bookId, chapterId, sceneId);
178
+ if (pgRows && pgRows.length > 0) {
179
+ const pgMap = {};
180
+ for (const r of pgRows) {
181
+ if ((r.start_ms || 0) > 0 || (r.end_ms || 0) > 0) pgMap[r.unit_id] = r;
182
+ }
183
+ for (const iu of ius) {
184
+ const pg = pgMap[iu.unit_id];
185
+ if (pg) { iu.start_ms = pg.start_ms; iu.end_ms = pg.end_ms; }
186
+ }
187
+ }
188
+ } catch (dbErr) {
189
+ console.warn('[SCENE STORYBOARD] DB timing merge failed:', dbErr.message);
190
+ }
191
+
192
+ for (const iu of ius) {
193
+ const real = (iu.start_ms != null && iu.end_ms != null) ? (iu.end_ms - iu.start_ms) : 0;
194
+ if (real > 0) {
195
+ iu.duration_ms = real;
196
+ } else if (iu.estimated_duration_sec && iu.estimated_duration_sec > 0) {
197
+ iu.duration_ms = Math.round(iu.estimated_duration_sec * 1000);
198
+ } else {
199
+ iu.duration_ms = 2000;
200
+ }
201
+ }
202
+
203
+ // Per-unit positions on the WHOLE-SCENE VIDEO timeline. Players
204
+ // seek the scene video by video_start_ms (its real timeline,
205
+ // measured from the merged/group files) instead of start_ms (the
206
+ // audio timeline) — on LTX builds the video drifts ahead of
207
+ // audio, and seeking to start_ms lands in the previous unit.
208
+ // Model-agnostic: on exact-timed builds (e.g. Minimax H3) the
209
+ // measurement equals start_ms and is a no-op. Best-effort:
210
+ // failures leave video_start_ms absent.
211
+ try {
212
+ await ctx.playerPorts.computeVideoStartMs(ius, buildId, bookId, chapterId, sceneId, ctx.outputRoot);
213
+ } catch (tlErr) {
214
+ console.warn(`[SCENE STORYBOARD] video_start_ms failed for ${bookId}/${chapterId}/${sceneId}: ${tlErr.message}`);
215
+ }
216
+
217
+ res.json({ book_id: bookId, chapter_id: chapterId, scene_id: sceneId, build_id: buildId, ius });
218
+ } catch (err) {
219
+ res.status(500).json({ error: err.message });
220
+ }
221
+ });
222
+
223
+ // ── Scene waveform (ffmpeg peaks via injected port) ──────────────────
224
+ app.get('/api/v1/scene/:bookId/:chapterId/:sceneId/waveform', async (req, res) => {
225
+ try {
226
+ const { bookId, chapterId, sceneId } = req.params;
227
+ const buildId = ctx.getEffectiveBuildId(bookId, req.query.build_id, ctx.log);
228
+ const audioPath = ctx.sceneAudioPath(buildId, bookId, chapterId, sceneId);
229
+ if (!fs.existsSync(audioPath)) return res.status(404).json({ error: 'audio not ready' });
230
+ const peaks = await ctx.playerPorts.computeWaveform(audioPath);
231
+ const duration = await image.getSceneDuration(buildId, bookId, chapterId, sceneId);
232
+ res.json({ peaks, duration_sec: Math.round(duration * 1000) / 1000, peak_count: peaks.length });
233
+ } catch (err) {
234
+ res.status(500).json({ error: err.message });
235
+ }
236
+ });
237
+
238
+ // ── Scene timings (GET + PUT) ────────────────────────────────────────
239
+ app.get('/api/v1/scene/:bookId/:chapterId/:sceneId/timings', async (req, res) => {
240
+ try {
241
+ const { bookId, chapterId, sceneId } = req.params;
242
+ const buildId = ctx.getEffectiveBuildId(bookId, req.query.build_id, ctx.log);
243
+
244
+ let ius = [];
245
+ try {
246
+ const pgRows = await ctx.iuRepo.getImageUnitsForScene(buildId, bookId, chapterId, sceneId);
247
+ if (pgRows && pgRows.length > 0) {
248
+ ius = pgRows.map(r => ({
249
+ unit_id: r.unit_id, scene_order: r.scene_order || 0,
250
+ // start_ms=0 is VALID — the first unit of every scene
251
+ // starts at 0. Treating it as null made needsCompute
252
+ // true on every GET, and the recompute-all path
253
+ // (5a401fb) then wiped the user-saved timings right
254
+ // after PUT. Rows that were never timed are 0/0 and
255
+ // still fail the end>start check below.
256
+ start_ms: r.start_ms != null ? Number(r.start_ms) : null,
257
+ end_ms: r.end_ms != null ? Number(r.end_ms) : null,
258
+ estimated_duration_sec: r.estimated_duration_sec || 0,
259
+ text_proportion: r.text_proportion || 0,
260
+ }));
261
+ }
262
+ } catch (dbErr) {
263
+ console.warn('[TIMINGS] PG read failed, falling back to book data:', dbErr.message);
264
+ }
265
+
266
+ if (ius.length === 0) {
267
+ try {
268
+ const b = ctx.playerModel.loadBook(bookId);
269
+ if (b) {
270
+ for (const ch of b.chapters || []) {
271
+ if (ch.chapter_id !== chapterId) continue;
272
+ for (const sc of ch.scenes || []) {
273
+ if (sc.scene_id !== sceneId) continue;
274
+ let order = 0;
275
+ for (const u of sc.units || []) {
276
+ ius.push({ unit_id: u.id, scene_order: order, start_ms: null, end_ms: null, estimated_duration_sec: 0, text_proportion: 0, _text: u.text || '' });
277
+ order++;
278
+ }
279
+ for (const db of sc.dialogue_blocks || []) {
280
+ for (const u of db.units || []) {
281
+ ius.push({ unit_id: u.id, scene_order: order, start_ms: null, end_ms: null, estimated_duration_sec: 0, text_proportion: 0, _text: u.text || '' });
282
+ order++;
283
+ }
284
+ }
285
+ const totalTextLen = ius.reduce((s, i) => s + i._text.length, 0);
286
+ for (const iu of ius) {
287
+ iu.text_proportion = totalTextLen > 0 ? iu._text.length / totalTextLen : 1;
288
+ delete iu._order;
289
+ delete iu._text;
290
+ }
291
+ break;
292
+ }
293
+ }
294
+ }
295
+ } catch (bookErr) {
296
+ console.warn('[TIMINGS] Book data fallback failed:', bookErr.message);
297
+ }
298
+ }
299
+
300
+ if (ius.length === 0) return res.json({ units: [], total_duration_ms: 0 });
301
+
302
+ const sceneDuration = await image.getSceneDuration(buildId, bookId, chapterId, sceneId);
303
+ const sceneDurationMs = Math.round(sceneDuration * 1000);
304
+
305
+ const needsDuration = ius.every(iu => !iu.estimated_duration_sec || iu.estimated_duration_sec === 0);
306
+ if (needsDuration && ius.length > 0) {
307
+ if (sceneDuration > 0) {
308
+ for (const iu of ius) {
309
+ iu.estimated_duration_sec = parseFloat((sceneDuration * (iu.text_proportion || 0)).toFixed(3));
310
+ }
311
+ }
312
+ }
313
+
314
+ ius.sort((a, b) => a.scene_order - b.scene_order);
315
+
316
+ let cursorMs = 0;
317
+ const needsCompute = ius.some(iu => iu.start_ms == null || iu.end_ms == null || (Number(iu.end_ms) - Number(iu.start_ms)) <= 0);
318
+ // When needsCompute is true, always recompute ALL units from
319
+ // scratch using cumulative cursorMs. Trusting existing
320
+ // start_ms/end_ms values when some units are being recomputed
321
+ // creates inconsistent gaps: unit0 may be recomputed with the
322
+ // current scene_duration_sec, but unit1's "valid" timings from a
323
+ // different audio duration are kept unchanged, causing overlaps
324
+ // or gaps.
325
+ const units = ius.map(iu => {
326
+ if (!needsCompute && iu.start_ms != null && iu.end_ms != null && (Number(iu.end_ms) - Number(iu.start_ms)) > 0) {
327
+ cursorMs = iu.end_ms;
328
+ const clampedEndMs = sceneDurationMs > 0 ? Math.min(iu.end_ms, sceneDurationMs) : iu.end_ms;
329
+ return { unit_id: iu.unit_id, scene_order: iu.scene_order, start_ms: iu.start_ms, end_ms: clampedEndMs, estimated_duration_sec: iu.estimated_duration_sec, text_proportion: iu.text_proportion };
330
+ }
331
+ const durMs = Math.max(200, Math.round((iu.estimated_duration_sec || 1) * 1000));
332
+ const start = cursorMs;
333
+ let end = cursorMs + durMs;
334
+ if (sceneDurationMs > 0 && end > sceneDurationMs) end = sceneDurationMs;
335
+ cursorMs = end;
336
+ return { unit_id: iu.unit_id, scene_order: iu.scene_order, start_ms: start, end_ms: end, estimated_duration_sec: iu.estimated_duration_sec || 0, text_proportion: iu.text_proportion || 0 };
337
+ });
338
+
339
+ // Persist computed start_ms/end_ms back to PG so subsequent calls
340
+ // don't recompute from scratch.
341
+ if (needsCompute && units.length > 0) {
342
+ for (const u of units) {
343
+ try {
344
+ await ctx.iuRepo.upsertIuTiming(buildId, bookId, chapterId, sceneId, u.unit_id, u.start_ms, u.end_ms);
345
+ } catch (persistErr) {
346
+ console.warn('[TIMINGS] Failed to persist timing:', persistErr.message);
347
+ }
348
+ }
349
+ ctx.log(`[TIMINGS] Persisted ${units.length} timing boundaries for ${bookId}/${chapterId}/${sceneId}`);
350
+ }
351
+
352
+ const totalMs = units.reduce((sum, u) => sum + (u.end_ms - u.start_ms), 0);
353
+ res.json({ units, total_duration_ms: totalMs });
354
+ } catch (err) {
355
+ res.status(500).json({ error: err.message });
356
+ }
357
+ });
358
+
359
+ app.put('/api/v1/scene/:bookId/:chapterId/:sceneId/timings', async (req, res) => {
360
+ try {
361
+ const { bookId, chapterId, sceneId } = req.params;
362
+ const { build_id: rawBuildId, units } = req.body || {};
363
+ const build_id = ctx.getEffectiveBuildId(bookId, rawBuildId, ctx.log);
364
+ if (!units || !Array.isArray(units)) return res.status(400).json({ error: 'units array required' });
365
+
366
+ const rows = await ctx.iuRepo.getImageUnitsForScene(build_id, bookId, chapterId, sceneId);
367
+ const existingMap = {};
368
+ for (const r of rows) existingMap[r.unit_id] = r;
369
+
370
+ const sorted = [...units].sort((a, b) => (existingMap[a.unit_id]?.scene_order ?? 0) - (existingMap[b.unit_id]?.scene_order ?? 0));
371
+
372
+ const sceneDuration = await image.getSceneDuration(build_id, bookId, chapterId, sceneId);
373
+ const sceneDurationMs = Math.round(sceneDuration * 1000);
374
+
375
+ const recalculated = [];
376
+ let cursorMs = 0;
377
+
378
+ for (const unit of sorted) {
379
+ const preferredStart = unit.start_ms ?? 0;
380
+ const preferredEnd = unit.end_ms ?? 0;
381
+ let endMs = Math.max(preferredStart + 50, preferredEnd);
382
+ if (sceneDurationMs > 0 && endMs > sceneDurationMs) endMs = sceneDurationMs;
383
+ // Gapless boundary model: the handles are SHARED boundaries —
384
+ // the right handle of a unit is the left handle of the next
385
+ // one. When a unit would start AFTER the previous unit ended
386
+ // (a gap — e.g. the user dragged the shared boundary earlier
387
+ // and the next unit's start didn't follow), pull the start
388
+ // back to the previous unit's end so the timeline stays
389
+ // gapless and the next unit's left handle tracks the drag.
390
+ // Net effect: every non-first unit starts exactly where the
391
+ // previous one ends, so a client that reports a later start
392
+ // for a non-first unit (didn't cascade a shared boundary) is
393
+ // corrected server-side. cursorMs === 0 marks the FIRST unit
394
+ // — its left edge may intentionally sit after 0 (lead-in
395
+ // silence), so its preferred start is kept.
396
+ let startMs = Math.max(preferredStart, cursorMs);
397
+ if (cursorMs > 0 && preferredStart > cursorMs) {
398
+ startMs = cursorMs;
399
+ }
400
+ // Clamp the start so the interval is never zero-width or
401
+ // inverted (e.g. a handle dragged to the very end of the
402
+ // audio). A saved row with start >= end would make the next
403
+ // GET treat the whole scene as needsCompute and recompute ALL
404
+ // timings from text proportions — discarding the user's edits.
405
+ startMs = Math.min(startMs, Math.max(0, endMs - 50));
406
+ recalculated.push({ unit_id: unit.unit_id, start_ms: startMs, end_ms: endMs });
407
+ cursorMs = endMs;
408
+ }
409
+
410
+ for (const u of recalculated) {
411
+ await ctx.iuRepo.upsertIuTiming(build_id, bookId, chapterId, sceneId, u.unit_id, u.start_ms, u.end_ms);
412
+ }
413
+
414
+ res.json({ units: recalculated, recalculated: true });
415
+ } catch (err) {
416
+ res.status(500).json({ error: err.message });
417
+ }
418
+ });
419
+ };
@@ -0,0 +1,103 @@
1
+ // ======================================================
2
+ // PLAYER ROUTES — SCENE MEDIA (audio / video / image serving)
3
+ // ======================================================
4
+ // Byte-for-byte relocation of the media-serving handlers from the host
5
+ // routes/generation-routes.cjs (Player route split, then physical move to
6
+ // packages/animastor-player — no behavior change). Dependencies arrive
7
+ // exclusively through the shared
8
+ // context (player-shared.cjs): playerModel, outputRoot, playerPorts,
9
+ // naming grammar. Consumers: web playbackStore/EditPage, Android
10
+ // Repository, test/scene-audio-range.test.js.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const naming = require('./artifact-naming.cjs');
15
+
16
+ module.exports = function(app, ctx) {
17
+ // ── Chunk-keyed media (Android legacy, tests) ────────────────────────
18
+ app.get('/api/v1/chunk/:id/audio', async (req, res) => {
19
+ try {
20
+ const c = await ctx.getChunk(req.params.id);
21
+ if (!c) return res.status(404).json({ error: 'chunk not found' });
22
+ if (await ctx.rejectIfChunkBookDenied(req, res, c.book_id)) return;
23
+ const audioPath = ctx.sceneAudioPath(c.build_id, c.book_id, c.chapter_id, c.scene_id);
24
+ if (!fs.existsSync(audioPath)) return res.status(404).json({ error: 'audio not ready' });
25
+ res.setHeader('Content-Type', 'audio/mpeg');
26
+ fs.createReadStream(audioPath).pipe(res);
27
+ } catch (err) {
28
+ res.status(500).json({ error: err.message });
29
+ }
30
+ });
31
+
32
+ app.get('/api/v1/chunk/:id/image', async (req, res) => {
33
+ const c = await ctx.getChunk(req.params.id);
34
+ if (!c) return res.status(404).json({ error: 'chunk not found' });
35
+ if (await ctx.rejectIfChunkBookDenied(req, res, c.book_id)) return;
36
+ if (!c.image) return res.status(404).json({ error: 'image not ready' });
37
+ const dir = ctx.buildDir(c.build_id);
38
+ if (!fs.existsSync(dir)) return res.status(404).json({ error: 'build directory not found' });
39
+ const files = fs.readdirSync(dir).filter(f => f.startsWith(naming.sceneArtifactPrefix(c.book_id, c.chapter_id, c.scene_id)) && f.endsWith('.png'));
40
+ if (!files.length) return res.status(404).json({ error: 'no image files' });
41
+ const filePath = path.join(dir, files[0]);
42
+ res.setHeader('Content-Type', 'image/png');
43
+ fs.createReadStream(filePath).pipe(res);
44
+ });
45
+
46
+ app.get('/api/v1/chunk/:id/video', async (req, res) => {
47
+ try {
48
+ const c = await ctx.getChunk(req.params.id);
49
+ if (!c) return res.status(404).json({ error: 'chunk not found' });
50
+ if (await ctx.rejectIfChunkBookDenied(req, res, c.book_id)) return;
51
+ const dir = ctx.buildDir(c.build_id);
52
+ if (!fs.existsSync(dir)) return res.status(404).json({ error: 'build directory not found' });
53
+ const filePath = ctx.resolveSceneVideoFile(dir, c.book_id, c.chapter_id, c.scene_id);
54
+ if (!filePath) return res.status(404).json({ error: 'video not ready' });
55
+ ctx.streamFileWithRange(req, res, filePath, 'video/mp4');
56
+ } catch (err) {
57
+ res.status(500).json({ error: err.message });
58
+ }
59
+ });
60
+
61
+ // ── Scene-keyed media (web + Android) ────────────────────────────────
62
+ app.get('/api/v1/scene/:bookId/:chapterId/:sceneId/audio', async (req, res) => {
63
+ try {
64
+ const { bookId, chapterId, sceneId } = req.params;
65
+ const buildId = ctx.getEffectiveBuildId(bookId, req.query.build_id, ctx.log);
66
+ const audioPath = ctx.sceneAudioPath(buildId, bookId, chapterId, sceneId);
67
+ if (!fs.existsSync(audioPath)) return res.status(404).json({ error: 'audio not ready' });
68
+ ctx.streamFileWithRange(req, res, audioPath, 'audio/mpeg');
69
+ } catch (err) {
70
+ res.status(500).json({ error: err.message });
71
+ }
72
+ });
73
+
74
+ app.get('/api/v1/scene/:bookId/:chapterId/:sceneId/video', async (req, res) => {
75
+ try {
76
+ const { bookId, chapterId, sceneId } = req.params;
77
+ const buildId = ctx.getEffectiveBuildId(bookId, req.query.build_id, ctx.log);
78
+ const dir = ctx.buildDir(buildId);
79
+ if (!fs.existsSync(dir)) return res.status(404).json({ error: 'build directory not found' });
80
+ const filePath = ctx.resolveSceneVideoFile(dir, bookId, chapterId, sceneId);
81
+ if (!filePath) return res.status(404).json({ error: 'video not ready' });
82
+ ctx.streamFileWithRange(req, res, filePath, 'video/mp4');
83
+ } catch (err) {
84
+ res.status(500).json({ error: err.message });
85
+ }
86
+ });
87
+
88
+ app.get('/api/v1/scene/:bookId/:chapterId/:sceneId/image', async (req, res) => {
89
+ try {
90
+ const { bookId, chapterId, sceneId } = req.params;
91
+ const buildId = ctx.getEffectiveBuildId(bookId, req.query.build_id, ctx.log);
92
+ const dir = ctx.buildDir(buildId);
93
+ if (!fs.existsSync(dir)) return res.status(404).json({ error: 'build directory not found' });
94
+ const files = fs.readdirSync(dir).filter(f => f.startsWith(naming.sceneArtifactPrefix(bookId, chapterId, sceneId)) && f.endsWith('.png'));
95
+ if (!files.length) return res.status(404).json({ error: 'no image files' });
96
+ const filePath = path.join(dir, files[0]);
97
+ res.setHeader('Content-Type', 'image/png');
98
+ fs.createReadStream(filePath).pipe(res);
99
+ } catch (err) {
100
+ res.status(500).json({ error: err.message });
101
+ }
102
+ });
103
+ };