@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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-09-08
4
+
5
+ ### Release preparation (npm publication)
6
+
7
+ - `@animastor/vbook-runtime` dependency switched from the monorepo
8
+ `file:` link to the published registry range `^0.1.0` (0.1.0 is live on
9
+ npm) — `npm install @animastor/player` now resolves cleanly on a fresh
10
+ environment. The host backend is unaffected: it injects the bookModel,
11
+ so the package's default facade instance is inert inside the backend
12
+ process; the monorepo `file:` links at `backend/package.json` are
13
+ unchanged.
14
+ - Consumer metadata completed: keywords, homepage, README installation
15
+ section + Node >= 18 requirement + absolute doc links (relative
16
+ `../../docs/...` links do not resolve on npmjs.com).
17
+ - `files` narrowed to production content (`src/` + metadata) — tests stay
18
+ in the repository checkout and run via `npm test`, matching the
19
+ repo-wide package standard (no tests in published tarballs).
20
+
21
+ ### Physical extraction (route split 4d1f6f0e → boundary audit → move)
22
+
23
+ - The playback HTTP contour physically moved from the backend host into
24
+ `src/` of this package: the registrar (`player-routes.cjs`), shared
25
+ contour helpers (`player-shared.cjs`), scene media (`scene-media.cjs`),
26
+ scene data (`scene-data.cjs`), IU media (`iu-media.cjs`), playback queue
27
+ (`playback-queue.cjs`), the artifact-naming grammar
28
+ (`artifact-naming.cjs`) and the Player Model facade (`player-model.cjs`,
29
+ from the host `backend/src/player/index.cjs`) — final boundary audit
30
+ MOVE list.
31
+ - Behavior, URLs, headers (Range / 206 / ETag / If-Range / 304 / 416),
32
+ status codes and response bodies unchanged (17 frozen endpoints, pinned
33
+ by `backend/tests/architecture/player-route-split.test.js` P1).
34
+ - Legacy backend copies deleted: `backend/src/routes/player/`,
35
+ `backend/src/player/`, and the empty `routes/book/chunks-routes.cjs`
36
+ registrar stub.
37
+ - Every host dependency arrives as an injected port (composition-root
38
+ contract in the README); the package's only dependency is
39
+ `@animastor/vbook-runtime` (Player Model facade).
40
+ - Behavior tests moved into the package: `test/scene-timings.test.js`,
41
+ `test/scene-audio-range.test.js`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Animastor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # @animastor/player
2
+
3
+ Animastor **Player** — the playback HTTP contour of the Animastor backend:
4
+ scene media serving (HTTP Range / 206 / ETag / If-Range streaming), scene
5
+ data (status / storyboard / timings / waveform), IU media (iu-image /
6
+ preview / chunk storyboard), the playback queue (chunk dedup,
7
+ `cover_chunk_id`, `chunk_positions`) and the assets-state readiness
8
+ projection. Includes the `createPlayerModel` facade over the Canonical Book
9
+ Model and the read-side artifact-naming grammar.
10
+
11
+ **Status: EXTRACTED (0.1.0).** The playback contour physically lives in this
12
+ package (`src/`); the host backend consumes it only through the package
13
+ entrypoint. See
14
+ [`docs/architecture/PLAYER_ROUTE_SPLIT_CHECKLIST.md`](https://github.com/Animastor/animastor/blob/main/docs/architecture/PLAYER_ROUTE_SPLIT_CHECKLIST.md)
15
+ (physical move COMPLETE) and
16
+ [`docs/architecture/PLAYER_PACKAGE_EXTRACTION_READINESS_AUDIT.md`](https://github.com/Animastor/animastor/blob/main/docs/architecture/PLAYER_PACKAGE_EXTRACTION_READINESS_AUDIT.md).
17
+
18
+ ## Requirements
19
+
20
+ - Node.js >= 18
21
+ - [`@animastor/vbook-runtime`](https://www.npmjs.com/package/@animastor/vbook-runtime)
22
+ (installed automatically as the only runtime dependency — the Player Model
23
+ facade over its Canonical Book Model)
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ npm install @animastor/player
29
+ ```
30
+
31
+ ## Public API
32
+
33
+ The package exposes a single entrypoint — `require('@animastor/player')`
34
+ returns `{ createPlayerModel, createPlayerRoutes }`. Deep imports
35
+ (`@animastor/player/src/...`) are intentionally not exported.
36
+
37
+ ## Boundary
38
+
39
+ The package imports **nothing** from the backend host: no config, no
40
+ generation implementation, no `workflows/`, no middleware, no
41
+ waveform/video-timeline host modules, no Redis/PG internals, no backend
42
+ storage. Book content is reached only through the injected `playerModel`
43
+ (VBook boundary — the facade over `@animastor/vbook-runtime`'s Book Model).
44
+ Every host dependency arrives as an injected port at registration time.
45
+
46
+ ## Standalone use (repository checkout)
47
+
48
+ ```bash
49
+ npm install # dep: @animastor/vbook-runtime (dev: mocha + chai)
50
+ npm test # 17 behavioral tests, no host needed
51
+ ```
52
+
53
+ ## Host integration (composition root)
54
+
55
+ ```js
56
+ const { createPlayerModel, createPlayerRoutes } = require('@animastor/player');
57
+
58
+ const playerModel = createPlayerModel({ bookModel });
59
+ createPlayerRoutes(app, redis, {
60
+ playerModel, // VBook boundary (book content reads)
61
+ outputRoot: config.OUTPUT_DIR, // artifact root (no config reads inside)
62
+ playerPorts: { // host-owned implementations
63
+ assertBookAccess, // auth middleware
64
+ computeVideoStartMs, // ffprobe + workflows alignment tax
65
+ computeWaveform, // ffmpeg
66
+ },
67
+ computeIuReady, // pure IU-progress math (host)
68
+ bookProjections: { findSceneRuntimeData, collectSceneUnits }, // pure VBook reads
69
+ redis, getChunk, getAllChunks, getBookWindowStatus,
70
+ iuRepo, sceneAssetsRepo, layerConfig,
71
+ image, state, activeScenes, placeholderAudio,
72
+ utils: { log },
73
+ });
74
+ ```
75
+
76
+ ## Frozen HTTP surface (17 endpoints)
77
+
78
+ ```
79
+ GET /api/v1/chunk/:id GET /api/v1/chunk/:id/audio
80
+ GET /api/v1/chunk/:id/image GET /api/v1/chunk/:id/video
81
+ GET /api/v1/chunk/:id/storyboard GET /api/v1/scene/:b/:ch/:sc/audio
82
+ GET /api/v1/scene/:b/:ch/:sc/video GET /api/v1/scene/:b/:ch/:sc/image
83
+ GET /api/v1/scene/:b/:ch/:sc/status GET /api/v1/scene/:b/:ch/:sc/storyboard
84
+ GET /api/v1/scene/:b/:ch/:sc/waveform GET /api/v1/scene/:b/:ch/:sc/timings
85
+ PUT /api/v1/scene/:b/:ch/:sc/timings GET /api/v1/iu-image/:b/:ch/:sc/:iuId
86
+ GET /api/v1/preview/:b/:ch/:sc/:iuId GET /api/v1/book/:bookId/chunks
87
+ GET /api/v1/book/:bookId/assets-state
88
+ ```
89
+
90
+ Guards: [`backend/tests/architecture/player-route-split.test.js`](https://github.com/Animastor/animastor/blob/main/backend/tests/architecture/player-route-split.test.js) (P1–P9) and
91
+ [`backend/tests/architecture/phase6-editor-player.test.js`](https://github.com/Animastor/animastor/blob/main/backend/tests/architecture/phase6-editor-player.test.js) (T1–T7).
92
+
93
+ ## License
94
+
95
+ MIT
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@animastor/player",
3
+ "version": "0.1.0",
4
+ "description": "Animastor Player — playback HTTP contour: scene media serving (Range/206/ETag/If-Range), scene data (status/storyboard/timings/waveform), IU media, playback queue and assets-state. Reads book content only through the injected playerModel (VBook boundary); every host dependency arrives as an injected port — no config, no generation, no workflows, no Redis/PG/Redis internals in the package.",
5
+ "keywords": [
6
+ "animastor",
7
+ "player",
8
+ "playback",
9
+ "http-range",
10
+ "media-streaming"
11
+ ],
12
+ "main": "src/index.cjs",
13
+ "exports": {
14
+ ".": "./src/index.cjs"
15
+ },
16
+ "files": [
17
+ "src/",
18
+ "README.md",
19
+ "LICENSE",
20
+ "CHANGELOG.md"
21
+ ],
22
+ "scripts": {
23
+ "test": "mocha --exit test/*.test.js"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "license": "MIT",
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/Animastor/animastor.git",
35
+ "directory": "packages/animastor-player"
36
+ },
37
+ "homepage": "https://github.com/Animastor/animastor/tree/main/packages/animastor-player#readme",
38
+ "bugs": {
39
+ "url": "https://github.com/Animastor/animastor/issues"
40
+ },
41
+ "dependencies": {
42
+ "@animastor/vbook-runtime": "^0.1.0"
43
+ },
44
+ "devDependencies": {
45
+ "chai": "^6.2.2",
46
+ "mocha": "^11.7.5"
47
+ }
48
+ }
@@ -0,0 +1,79 @@
1
+ // ======================================================
2
+ // @animastor/player — ARTIFACT NAMING GRAMMAR (seam)
3
+ // ======================================================
4
+ // The single place where the playback contour reconstructs media artifact
5
+ // filenames. Generation WRITES these files (audio-orchestrator, video-
6
+ // orchestrator, image pipeline, filesystem-store) and the Player contour
7
+ // READS them; the grammar itself is an undeclared cross-domain contract
8
+ // (Player extraction audit §4, risk R2).
9
+ //
10
+ // Owner of the grammar today: the generation/storage writers, canonical
11
+ // definitions in storage/filesystem-store.js (makeSceneAudioFilename,
12
+ // makeChunkAudioFilename, makeIUImageFilename, makePreviewFilename) plus the
13
+ // video group grammar `${prefix}_gN.mp4` (video-orchestrator suffixes).
14
+ // The Player must NOT import generation implementations directly — this
15
+ // module re-exposes the grammar through an explicit, dependency-free
16
+ // utility contract (@animastor/player/src/artifact-naming.cjs — moved with
17
+ // the physical extraction). Generation keeps its own writer functions; the
18
+ // byte-identity pin below is the drift guard. Long-term home (optional):
19
+ // @animastor/vbook-runtime schema territory — out of scope here.
20
+ //
21
+ // NOTE: strings here MUST stay byte-identical to the writers. Guarded by
22
+ // backend/tests/architecture/player-route-split.test.js (P7 naming pins).
23
+
24
+ /**
25
+ * Scene audio: `${book}_${chapter}_${scene}.mp3`
26
+ * (mirrors filesystem-store.makeSceneAudioFilename / audio-orchestrator merge output)
27
+ */
28
+ function sceneAudioName(bookId, chapterId, sceneId) {
29
+ return `${bookId}_${chapterId}_${sceneId}.mp3`;
30
+ }
31
+
32
+ /** Scene video (merged, player-facing): `${prefix}.mp4` */
33
+ function sceneVideoName(bookId, chapterId, sceneId) {
34
+ return `${bookId}_${chapterId}_${sceneId}.mp4`;
35
+ }
36
+
37
+ /** Video group clip: `${prefix}_g${n}.mp4` (video-orchestrator suffix grammar) */
38
+ function sceneVideoGroupName(bookId, chapterId, sceneId, group) {
39
+ return `${bookId}_${chapterId}_${sceneId}_g${group}.mp4`;
40
+ }
41
+
42
+ /** Scene image: `${prefix}.png` */
43
+ function sceneImageName(bookId, chapterId, sceneId) {
44
+ return `${bookId}_${chapterId}_${sceneId}.png`;
45
+ }
46
+
47
+ /**
48
+ * IU image prefix glob: `${prefix}_iu` — IU images are
49
+ * `${prefix}_iu{iuId}.png` (mirrors filesystem-store.makeIUImageFilename;
50
+ * the route serves by exact id via iuImageName and lists by this prefix).
51
+ */
52
+ function iuImagePrefix(bookId, chapterId, sceneId) {
53
+ return `${bookId}_${chapterId}_${sceneId}_iu`;
54
+ }
55
+
56
+ /**
57
+ * IU image by exact URL id: `${prefix}_${iuId}.png`. The URL :iuId carries the
58
+ * `iu` prefix itself (frontend unitId: u.id ?? 'iu0001'), so this equals the
59
+ * writer's `${prefix}_iu${strippedId}.png` (filesystem-store
60
+ * .makeIUImageFilename with the ^iu-stripped id — see image/preview.js).
61
+ */
62
+ function iuImageName(bookId, chapterId, sceneId, iuId) {
63
+ return `${bookId}_${chapterId}_${sceneId}_${iuId}.png`;
64
+ }
65
+
66
+ /** Prefix shared by every scene artifact (audio/video/image/iu/preview). */
67
+ function sceneArtifactPrefix(bookId, chapterId, sceneId) {
68
+ return `${bookId}_${chapterId}_${sceneId}`;
69
+ }
70
+
71
+ module.exports = {
72
+ sceneAudioName,
73
+ sceneVideoName,
74
+ sceneVideoGroupName,
75
+ sceneImageName,
76
+ iuImagePrefix,
77
+ iuImageName,
78
+ sceneArtifactPrefix,
79
+ };
package/src/index.cjs ADDED
@@ -0,0 +1,49 @@
1
+ // ======================================================
2
+ // @animastor/player — PACKAGE ENTRYPOINT (public API)
3
+ // ======================================================
4
+ // The playback HTTP contour of the Animastor backend, physically extracted
5
+ // from the host (backend/src/routes/player/ + backend/src/player/ — deleted
6
+ // by the extraction commit; docs/architecture/PLAYER_ROUTE_SPLIT_CHECKLIST.md
7
+ // §PHYSICAL MOVE COMPLETE).
8
+ //
9
+ // The package owns: route registration, Range/206/ETag/If-Range media
10
+ // streaming, scene status/storyboard/timings handlers, IU media, the
11
+ // playback queue (chunk dedup, cover_chunk_id, chunk_positions) and the
12
+ // assets-state readiness projection, plus the artifact-naming grammar
13
+ // (read-side expression of the generation⇄player filename contract).
14
+ //
15
+ // The package imports NOTHING from the backend host: no config, no
16
+ // generation implementation, no workflows/, no middleware, no waveform/
17
+ // video-timeline host modules, no Redis/PG internals, no backend storage.
18
+ // Book content is reached ONLY through the injected playerModel (the
19
+ // VBook boundary — the facade itself lives here over the Book Model of
20
+ // @animastor/vbook-runtime); the two pure read projections arrive as the
21
+ // injected bookProjections.
22
+ //
23
+ // Host dependencies (ports/adapters — injected by the composition root):
24
+ // playerModel { loadBook(bookId, opts?), getBookIdentity(bookId),
25
+ // getBookManifest(bookId) } (VBook boundary)
26
+ // outputRoot string — artifact root (host config injected)
27
+ // playerPorts { assertBookAccess(req, bookId) → workspace|null,
28
+ // computeVideoStartMs(ius, buildId, bookId, ch, sc,
29
+ // outputRoot) → Promise<boolean>,
30
+ // computeWaveform(audioPath) → Promise<number[]> }
31
+ // computeIuReady (redis, sceneAssetsRepo, bookId, ch, sc, total)
32
+ // bookProjections { findSceneRuntimeData(loadedBook, ch, sc),
33
+ // collectSceneUnits(scenePayload) } (pure VBook reads)
34
+ // redis/getChunk/getAllChunks/getBookWindowStatus (runtime state)
35
+ // iuRepo/sceneAssetsRepo/layerConfig/image/state/activeScenes/
36
+ // placeholderAudio/utils ({ log }) (host seams)
37
+ //
38
+ // Public API:
39
+ // const { createPlayerModel, createPlayerRoutes } = require('@animastor/player');
40
+ // const playerModel = createPlayerModel({ bookModel });
41
+ // createPlayerRoutes(app, redis, routeDeps);
42
+ //
43
+ // Guards: backend/tests/architecture/player-route-split.test.js (P1–P9),
44
+ // backend/tests/architecture/phase6-editor-player.test.js (T1–T7).
45
+
46
+ const { createPlayerModel } = require('./player-model.cjs');
47
+ const createPlayerRoutes = require('./player-routes.cjs');
48
+
49
+ module.exports = { createPlayerModel, createPlayerRoutes };
@@ -0,0 +1,294 @@
1
+ // ======================================================
2
+ // PLAYER ROUTES — IU MEDIA (iu-image / preview / chunk storyboard+status)
3
+ // ======================================================
4
+ // Byte-for-byte relocation from the host routes/generation-routes.cjs
5
+ // (Player route split, then physical move to packages/animastor-player — no
6
+ // behavior change). The preview route may
7
+ // GENERATE a preview via the injected image.getOrCreatePreview dep
8
+ // (host image-pipeline internal — documented hidden write, audit §4 R3).
9
+ // Consumers: web Edit zoom/Navigate thumbs/player IU, Android Repository,
10
+ // tests.
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, deps) {
17
+ const { image, state, activeScenes, placeholderAudio } = deps;
18
+
19
+ // ── Chunk status (playback readiness, auto-redispatch repair) ────────
20
+ app.get('/api/v1/chunk/:id', async (req, res) => {
21
+ try {
22
+ const c = await ctx.getChunk(req.params.id);
23
+ if (!c) return res.json({ status: 'processing' });
24
+ if (await ctx.rejectIfChunkBookDenied(req, res, c.book_id)) return;
25
+
26
+ const buildDir = ctx.buildDir(c.build_id);
27
+ const audioPath = path.join(buildDir, naming.sceneAudioName(c.book_id, c.chapter_id, c.scene_id));
28
+ const imagePath = path.join(buildDir, naming.sceneImageName(c.book_id, c.chapter_id, c.scene_id));
29
+ const videoPath = path.join(buildDir, naming.sceneVideoName(c.book_id, c.chapter_id, c.scene_id));
30
+
31
+ // Auto-redispatch if audio flag says ready but file is missing
32
+ if (c.audio && !fs.existsSync(audioPath)) {
33
+ try {
34
+ const phResult = await placeholderAudio.ensurePlaceholderAudio(
35
+ c.build_id || 'default', c.book_id, c.chapter_id, c.scene_id
36
+ );
37
+ const phOk = !!phResult && (phResult.created || (phResult.reason === 'already_exists' && phResult.path));
38
+ if (phOk && fs.existsSync(audioPath)) {
39
+ ctx.log(`Placeholder audio on-demand for ${req.params.id} — keeping chunk ready`);
40
+ c.audio_status = 'placeholder';
41
+ await ctx.redis.set(`animastor:chunk:${req.params.id}`, JSON.stringify(c));
42
+ } else {
43
+ // Don't reset c.audio = false — the chunk has audio:
44
+ // true, and audio_ready is computed as !!(c.audio ||
45
+ // fileExists). Resetting permanently breaks the
46
+ // scene for the player.
47
+ ctx.log(`Placeholder audio generation returned ${JSON.stringify(phResult)} for ${req.params.id} — keeping chunk as-is`);
48
+ }
49
+ } catch (phErr) {
50
+ ctx.log(`Placeholder audio check failed for ${req.params.id}: ${phErr.message} — keeping chunk as-is`);
51
+ if (c.chapter_id && c.scene_id) {
52
+ const jobKey = `animastor:job:${c.book_id}_${c.chapter_id}_${c.scene_id}_0001:audio`;
53
+ await ctx.redis.del(jobKey);
54
+ await activeScenes.addActiveScene(ctx.redis, c.book_id, c.chapter_id, c.scene_id);
55
+ }
56
+ }
57
+ }
58
+
59
+ // Reverse: file exists but flag says not ready
60
+ if (!c.audio && fs.existsSync(audioPath)) {
61
+ ctx.log(`Audio file exists for ${req.params.id} — updating flag`);
62
+ c.audio = true;
63
+ c.audio_status = 'ready';
64
+ await ctx.redis.set(`animastor:chunk:${req.params.id}`, JSON.stringify(c));
65
+ }
66
+
67
+ // Auto-redispatch for image
68
+ if (c.image && !fs.existsSync(imagePath)) {
69
+ const iuPrefix = naming.iuImagePrefix(c.book_id, c.chapter_id, c.scene_id);
70
+ let hasIuFiles = false;
71
+ try {
72
+ const dirFiles = fs.readdirSync(buildDir);
73
+ hasIuFiles = dirFiles.some(f => f.startsWith(iuPrefix) && f.endsWith('.png'));
74
+ } catch {}
75
+ if (!hasIuFiles) {
76
+ ctx.log(`Missing image file for ${req.params.id} — resetting state for redispatch`);
77
+ c.image = false;
78
+ await ctx.redis.set(`animastor:chunk:${req.params.id}`, JSON.stringify(c));
79
+ if (c.chapter_id && c.scene_id) {
80
+ const jobKey = `animastor:job:${c.book_id}_${c.chapter_id}_${c.scene_id}_0002:image`;
81
+ await ctx.redis.del(jobKey);
82
+ await activeScenes.addActiveScene(ctx.redis, c.book_id, c.chapter_id, c.scene_id);
83
+ }
84
+ }
85
+ }
86
+
87
+ const audioReady = !!(c.audio || fs.existsSync(audioPath));
88
+ let imageReady = !!c.image;
89
+ if (!imageReady && c.chapter_id && c.scene_id) {
90
+ // Check per-asset state for image readiness
91
+ try {
92
+ const assetStates = await state.getAssetStates(ctx.redis, c.book_id, c.chapter_id, c.scene_id);
93
+ if (assetStates.image === state.AssetState.READY || assetStates.image === state.AssetState.PLACEHOLDER) {
94
+ imageReady = true;
95
+ c.image = true;
96
+ await ctx.redis.set(`animastor:chunk:${req.params.id}`, JSON.stringify(c));
97
+ }
98
+ } catch (_) {}
99
+ if (!imageReady) {
100
+ imageReady = fs.existsSync(imagePath);
101
+ if (!imageReady) {
102
+ const iuPrefix = naming.iuImagePrefix(c.book_id, c.chapter_id, c.scene_id);
103
+ try {
104
+ const dirFiles = fs.readdirSync(buildDir);
105
+ imageReady = dirFiles.some(f => f.startsWith(iuPrefix) && f.endsWith('.png'));
106
+ } catch {}
107
+ }
108
+ }
109
+ }
110
+ const videoReady = !!(c.video && fs.existsSync(videoPath));
111
+ const allReady = audioReady;
112
+
113
+ res.json({
114
+ status: allReady ? 'ready' : 'processing', image_ready: imageReady,
115
+ audio_ready: audioReady, video_ready: videoReady,
116
+ audio_status: c.audio_status || 'pending',
117
+ video_status: c.video_status || 'pending',
118
+ scene_type: c.scene_type || 'narration',
119
+ scene_id: c.scene_id,
120
+ chapter_id: c.chapter_id,
121
+ });
122
+ } catch (err) {
123
+ console.error('❌ CHUNK STATUS ERROR:', err.message);
124
+ res.status(500).json({ error: 'Internal error fetching chunk status' });
125
+ }
126
+ });
127
+
128
+ // ── Chunk-keyed storyboard (Android legacy) ──────────────────────────
129
+ app.get('/api/v1/chunk/:id/storyboard', async (req, res) => {
130
+ try {
131
+ const { id } = req.params;
132
+ const c = await ctx.getChunk(id);
133
+ if (!c) return res.status(404).json({ error: 'chunk not found' });
134
+ if (await ctx.rejectIfChunkBookDenied(req, res, c.book_id)) return;
135
+
136
+ const { build_id, book_id, chapter_id, scene_id } = c;
137
+ const dir = ctx.buildDir(build_id);
138
+ if (!fs.existsSync(dir)) return res.status(404).json({ error: 'build directory not found' });
139
+
140
+ let ius = [];
141
+ try {
142
+ const pgRows = await ctx.iuRepo.getImageUnitsForScene(build_id, book_id, chapter_id, scene_id);
143
+ if (pgRows && pgRows.length > 0) {
144
+ ius = pgRows.map(r => ({
145
+ unit_id: r.unit_id, scene_id: r.scene_id, text: r.text,
146
+ text_proportion: r.text_proportion, estimated_duration_sec: r.estimated_duration_sec,
147
+ audio_file: r.scene_audio_file,
148
+ // Note: start_ms=0 maps to null here, but unlike the
149
+ // timings GET route this is self-corrected by the pgMap
150
+ // merge below (rows with end_ms>0 override the nulls).
151
+ start_ms: r.start_ms != null && (Number(r.start_ms) || 0) > 0 ? Number(r.start_ms) : null,
152
+ end_ms: r.end_ms != null && (Number(r.end_ms) || 0) > 0 ? Number(r.end_ms) : null,
153
+ }));
154
+ }
155
+ } catch (dbErr) {
156
+ console.warn('[STORYBOARD] PG read failed, falling back to book data:', dbErr.message);
157
+ }
158
+
159
+ if (ius.length === 0) {
160
+ try {
161
+ const b = ctx.playerModel.loadBook(book_id);
162
+ if (b) {
163
+ for (const ch of b.chapters || []) {
164
+ if (ch.chapter_id !== chapter_id) continue;
165
+ for (const sc of ch.scenes || []) {
166
+ if (sc.scene_id !== scene_id) continue;
167
+ let order = 0;
168
+ for (const u of sc.units || []) {
169
+ ius.push({ unit_id: u.id, scene_id, text: u.text, text_proportion: 0, estimated_duration_sec: 0, audio_file: null, start_ms: null, end_ms: null, _order: order });
170
+ order++;
171
+ }
172
+ for (const db of sc.dialogue_blocks || []) {
173
+ for (const u of db.units || []) {
174
+ ius.push({ unit_id: u.id, scene_id, text: u.text, text_proportion: 0, estimated_duration_sec: 0, audio_file: null, start_ms: null, end_ms: null, _order: order });
175
+ order++;
176
+ }
177
+ }
178
+ const totalTextLen = ius.reduce((s, i) => s + (i.text || '').length, 0);
179
+ ius.sort((a, b) => a._order - b._order);
180
+ for (const iu of ius) {
181
+ iu.text_proportion = totalTextLen > 0 ? (iu.text || '').length / totalTextLen : 1;
182
+ delete iu._order;
183
+ delete iu._text;
184
+ }
185
+ break;
186
+ }
187
+ }
188
+ }
189
+ } catch (bookErr) {
190
+ console.warn('[STORYBOARD] Book data fallback failed:', bookErr.message);
191
+ }
192
+ }
193
+
194
+ const needsDuration = ius.every(iu => !iu.estimated_duration_sec || iu.estimated_duration_sec === 0);
195
+ if (needsDuration && ius.length > 0) {
196
+ const sceneDuration = await image.getSceneDuration(build_id, book_id, chapter_id, scene_id);
197
+ if (sceneDuration > 0) {
198
+ for (const iu of ius) {
199
+ iu.estimated_duration_sec = parseFloat((sceneDuration * (iu.text_proportion || 0)).toFixed(3));
200
+ }
201
+ }
202
+ // Compute timing boundaries (start_ms/end_ms) from cumulative durations
203
+ const sceneDurationMs = Math.round(sceneDuration * 1000);
204
+ let cursorMs = 0;
205
+ for (const iu of ius) {
206
+ const durMs = Math.max(200, Math.round((iu.estimated_duration_sec || 1) * 1000));
207
+ iu._start_ms = cursorMs;
208
+ let endMs = cursorMs + durMs;
209
+ if (sceneDurationMs > 0 && endMs > sceneDurationMs) endMs = sceneDurationMs;
210
+ iu._end_ms = endMs;
211
+ cursorMs = endMs;
212
+ }
213
+ for (const [idx, iu] of ius.entries()) {
214
+ try {
215
+ await ctx.iuRepo.upsertImageUnit(build_id, book_id, chapter_id, scene_id, iu.unit_id, {
216
+ scene_order: idx, text: iu.text, text_length: (iu.text || '').length,
217
+ text_proportion: iu.text_proportion || 0, scene_duration_sec: sceneDuration || 0,
218
+ estimated_duration_sec: iu.estimated_duration_sec || 0,
219
+ scene_audio_file: naming.sceneAudioName(book_id, chapter_id, scene_id),
220
+ start_ms: iu._start_ms != null ? iu._start_ms : null,
221
+ end_ms: iu._end_ms != null ? iu._end_ms : null,
222
+ });
223
+ } catch (pgErr) {
224
+ console.warn('[STORYBOARD] Failed to persist IU to PG:', pgErr.message);
225
+ }
226
+ }
227
+ }
228
+
229
+ try {
230
+ const pgRows = await ctx.iuRepo.getImageUnitsForScene(build_id, book_id, chapter_id, scene_id);
231
+ if (pgRows && pgRows.length > 0) {
232
+ const pgMap = {};
233
+ for (const r of pgRows) {
234
+ if ((r.start_ms || 0) > 0 || (r.end_ms || 0) > 0) pgMap[r.unit_id] = r;
235
+ }
236
+ for (const iu of ius) {
237
+ const pg = pgMap[iu.unit_id];
238
+ if (pg) { iu.start_ms = pg.start_ms; iu.end_ms = pg.end_ms; }
239
+ }
240
+ }
241
+ } catch (dbErr) {
242
+ console.warn('[STORYBOARD] DB timing merge failed:', dbErr.message);
243
+ }
244
+
245
+ // Server-computed playback duration per IU so clients never
246
+ // re-derive it. Rule: real interval (end-start) if positive, else
247
+ // estimated_duration_sec, else a 2000ms default. Mirrors the
248
+ // former client fallbackDurationMs().
249
+ for (const iu of ius) {
250
+ const real = (iu.start_ms != null && iu.end_ms != null) ? (iu.end_ms - iu.start_ms) : 0;
251
+ if (real > 0) {
252
+ iu.duration_ms = real;
253
+ } else if (iu.estimated_duration_sec && iu.estimated_duration_sec > 0) {
254
+ iu.duration_ms = Math.round(iu.estimated_duration_sec * 1000);
255
+ } else {
256
+ iu.duration_ms = 2000;
257
+ }
258
+ }
259
+
260
+ res.json({ chunk_id: id, book_id, chapter_id, scene_id, build_id, scene_type: c.scene_type || 'narration', ius });
261
+ } catch (err) {
262
+ res.status(500).json({ error: err.message });
263
+ }
264
+ });
265
+
266
+ // ── IU image ─────────────────────────────────────────────────────────
267
+ app.get('/api/v1/iu-image/:bookId/:chapterId/:sceneId/:iuId', async (req, res) => {
268
+ try {
269
+ const { bookId, chapterId, sceneId, iuId } = req.params;
270
+ const buildId = ctx.getEffectiveBuildId(bookId, req.query.build_id, ctx.log);
271
+ const imagePath = path.join(ctx.buildDir(buildId), naming.iuImageName(bookId, chapterId, sceneId, iuId));
272
+ if (!fs.existsSync(imagePath)) return res.status(404).json({ error: 'IU image not found' });
273
+ res.setHeader('Content-Type', 'image/png');
274
+ fs.createReadStream(imagePath).pipe(res);
275
+ } catch (err) {
276
+ res.status(500).json({ error: err.message });
277
+ }
278
+ });
279
+
280
+ // ── Preview (may generate via injected image.getOrCreatePreview) ─────
281
+ app.get('/api/v1/preview/:bookId/:chapterId/:sceneId/:iuId', async (req, res) => {
282
+ try {
283
+ const { bookId, chapterId, sceneId, iuId } = req.params;
284
+ const buildId = ctx.getEffectiveBuildId(bookId, req.query.build_id, ctx.log);
285
+ const result = await image.getOrCreatePreview(bookId, chapterId, sceneId, iuId, buildId);
286
+ if (!result) return res.status(404).json({ error: 'IU image not found, cannot generate preview' });
287
+ res.setHeader('Content-Type', 'image/png');
288
+ res.setHeader('X-Preview-Created', String(result.created));
289
+ fs.createReadStream(result.path).pipe(res);
290
+ } catch (err) {
291
+ res.status(500).json({ error: err.message });
292
+ }
293
+ });
294
+ };