@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,246 @@
1
+ // ======================================================
2
+ // PLAYER ROUTES — PLAYBACK QUEUE (book chunks + assets-state)
3
+ // ======================================================
4
+ // Byte-for-byte relocation from the host routes/book/chunks-routes.cjs
5
+ // (Player route split, then physical move to packages/animastor-player — no
6
+ // behavior change):
7
+ // - GET /api/v1/book/:bookId/chunks — the playback queue
8
+ // (dedup per scene, cover_chunk_id, chunk_positions);
9
+ // - GET /api/v1/book/:bookId/assets-state — readiness counters.
10
+ //
11
+ // assets-state is a SHARED endpoint: the Generate page gates on it as much
12
+ // as the Player does (dirty-unit counters, layer config) — recorded as an
13
+ // intentional mixed responsibility (audit §3.4.5). PG scene-assets repo and
14
+ // Redis chunk reads stay host-side infra (a full playback-projection port is
15
+ // deliberately premature); the pure IU math stays host-side
16
+ // (routes/book/iu-progress-utils.cjs — kept pure, injected as the
17
+ // computeIuReady port). Consumers: web generateStore/playbackStore,
18
+ // Android, tests.
19
+
20
+ const path = require('path');
21
+ const fs = require('fs');
22
+ const naming = require('./artifact-naming.cjs');
23
+
24
+ module.exports = function(app, ctx, deps) {
25
+ const { getAllChunks, getBookWindowStatus, layerConfig, sceneAssetsRepo, computeIuReady } = deps;
26
+
27
+ // ── BOOK CHUNKS (playback queue) ─────────────────────────────────────
28
+ app.get('/api/v1/book/:bookId/chunks', async (req, res) => {
29
+ const allIds = await getAllChunks(req.params.bookId);
30
+ const windowStatus = await getBookWindowStatus(req.params.bookId);
31
+
32
+ // Build chunk_positions map + find cover_chunk_id in one pass so the
33
+ // client never needs N individual getChunkStoryboard calls (F9 audit).
34
+ let coverChunkId = null;
35
+ const chunkPositions = {};
36
+
37
+ // Deduplicate: the playback queue needs exactly one entry per scene.
38
+ // TTS pipeline may create multiple chunks per scene (_0001, _0002,
39
+ // _0003) for long text segments — they all serve the same merged
40
+ // audio file. Keep only the first chunk (lowest chunk_index) per
41
+ // (chapter_id, scene_id).
42
+ const dedupedIds = [];
43
+ const seenScenes = new Set();
44
+
45
+ if (allIds.length > 0) {
46
+ try {
47
+ const keys = allIds.map(id => `animastor:chunk:${id}`);
48
+ const raw = await ctx.redis.mget(keys);
49
+ for (let i = 0; i < allIds.length; i++) {
50
+ if (!raw[i]) {
51
+ // Chunk without metadata — keep it (defensive)
52
+ if (!seenScenes.has('__unknown__')) {
53
+ seenScenes.add('__unknown__');
54
+ dedupedIds.push(allIds[i]);
55
+ }
56
+ continue;
57
+ }
58
+ try {
59
+ const c = JSON.parse(raw[i]);
60
+ const sceneKey = `${c.chapter_id || ''}:${c.scene_id || ''}`;
61
+
62
+ chunkPositions[allIds[i]] = {
63
+ chapter_id: c.chapter_id || null,
64
+ scene_id: c.scene_id || null,
65
+ };
66
+ if (c.scene_type === 'cover') {
67
+ coverChunkId = allIds[i];
68
+ }
69
+
70
+ // Only add to deduped list if this scene hasn't been seen yet
71
+ if (!seenScenes.has(sceneKey)) {
72
+ seenScenes.add(sceneKey);
73
+ dedupedIds.push(allIds[i]);
74
+ }
75
+ } catch (_) {}
76
+ }
77
+ } catch (err) {
78
+ console.warn('[CHUNKS] Failed to load chunk metadata:', err.message);
79
+ // Fallback: return all IDs (no dedup)
80
+ dedupedIds.push(...allIds);
81
+ }
82
+ }
83
+
84
+ res.json({
85
+ chunk_ids: dedupedIds,
86
+ total: dedupedIds.length,
87
+ cover_chunk_id: coverChunkId,
88
+ chunk_positions: chunkPositions,
89
+ ...windowStatus
90
+ });
91
+ });
92
+
93
+ // ── ASSETS STATE (readiness — shared with Generator gating) ──────────
94
+ app.get('/api/v1/book/:bookId/assets-state', async (req, res) => {
95
+ try {
96
+ const { bookId } = req.params;
97
+ const { scope, chapter_id, scene_id } = req.query;
98
+
99
+ const allChunkIds = await getAllChunks(bookId);
100
+ const totalChunks = allChunkIds.length;
101
+
102
+ const chunkById = new Map();
103
+ if (allChunkIds.length > 0) {
104
+ try {
105
+ const raw = await ctx.redis.mget(allChunkIds.map(id => `animastor:chunk:${id}`));
106
+ for (let i = 0; i < allChunkIds.length; i++) {
107
+ if (!raw[i]) continue;
108
+ try {
109
+ const c = JSON.parse(raw[i]);
110
+ if (!c.audio_status) c.audio_status = 'pending';
111
+ if (!c.image_status) c.image_status = c.image ? 'ready' : 'pending';
112
+ if (!c.video_status) c.video_status = 'pending';
113
+ chunkById.set(allChunkIds[i], c);
114
+ } catch (_) {}
115
+ }
116
+ } catch (mgetErr) {
117
+ for (const cid of allChunkIds) {
118
+ try { const c = await ctx.getChunk(cid); if (c) chunkById.set(cid, c); } catch (_) {}
119
+ }
120
+ }
121
+ }
122
+
123
+ let filteredIds = allChunkIds;
124
+ if ((scope === 'current_chapter' || scope === 'chapter') && chapter_id) {
125
+ filteredIds = allChunkIds.filter(cid => chunkById.get(cid)?.chapter_id === chapter_id);
126
+ } else if ((scope === 'current_scene' || scope === 'scene') && chapter_id && scene_id) {
127
+ filteredIds = allChunkIds.filter(cid => {
128
+ const c = chunkById.get(cid);
129
+ return c?.chapter_id === chapter_id && c?.scene_id === scene_id;
130
+ });
131
+ }
132
+
133
+ let audioReady = 0, audioReadyReal = 0, imageReady = 0, videoReady = 0;
134
+ let audioError = 0, imageError = 0, videoError = 0;
135
+ let hasAudio = false, hasImage = false, hasVideo = false;
136
+
137
+ const isErr = (s) => s === 'error' || s === 'failed';
138
+ for (const cid of filteredIds) {
139
+ const chunk = chunkById.get(cid);
140
+ if (!chunk) continue;
141
+ if (chunk.audio_status === 'ready' || chunk.audio_status === 'placeholder') {
142
+ audioReady++; hasAudio = true;
143
+ if (chunk.audio_status === 'ready') audioReadyReal++;
144
+ } else if (isErr(chunk.audio_status)) { audioError++; }
145
+ if (chunk.image_status === 'ready') { imageReady++; hasImage = true; }
146
+ else if (isErr(chunk.image_status)) { imageError++; }
147
+ if (chunk.video_status === 'ready') { videoReady++; hasVideo = true; }
148
+ else if (isErr(chunk.video_status)) { videoError++; }
149
+ }
150
+
151
+ let scopeIuTotal = 0, scopeIuReady = 0, coverIuTotal = 0, coverIuReady = 0;
152
+ try {
153
+ let buildId = chunkById.get(filteredIds[0])?.build_id;
154
+ if (!buildId) {
155
+ for (const cid of allChunkIds) {
156
+ const b = chunkById.get(cid)?.build_id;
157
+ if (b) { buildId = b; break; }
158
+ }
159
+ }
160
+
161
+ if (buildId) {
162
+ const uniqueScenes = new Map();
163
+ for (const cid of filteredIds) {
164
+ const chunk = chunkById.get(cid);
165
+ if (chunk?.chapter_id && chunk?.scene_id) {
166
+ uniqueScenes.set(`${chunk.chapter_id}:${chunk.scene_id}`, {
167
+ chapter_id: chunk.chapter_id, scene_id: chunk.scene_id,
168
+ });
169
+ }
170
+ }
171
+
172
+ for (const { chapter_id: ch, scene_id: sc } of uniqueScenes.values()) {
173
+ const rows = await ctx.iuRepo.getImageUnitsForScene(buildId, bookId, ch, sc);
174
+ if (rows.length > 0) {
175
+ scopeIuTotal += rows.length;
176
+ scopeIuReady += await computeIuReady(ctx.redis, sceneAssetsRepo, bookId, ch, sc, rows.length);
177
+ }
178
+ }
179
+
180
+ const layerCfg = await layerConfig.get(ctx.redis, bookId);
181
+ const imagesEnabled = layerCfg?.image_enabled !== false;
182
+ if (imagesEnabled) {
183
+ // Cover lookup reads through the Player boundary
184
+ // (playerModel → Canonical Book Model).
185
+ const coverCh = ctx.playerModel.loadBook(bookId)?.chapters?.find(ch => ch.type === 'cover');
186
+ if (coverCh && coverCh.scenes && coverCh.scenes.length > 0) {
187
+ const coverScene = coverCh.scenes[0];
188
+ // chapter_id for modern lazy-book chapters,
189
+ // `chapter` for legacy parse.js chapters.
190
+ const coverChapterId = coverCh.chapter_id ?? coverCh.chapter;
191
+ const coverSceneId = coverScene.scene_id;
192
+ const rows = await ctx.iuRepo.getImageUnitsForScene(buildId, bookId, coverChapterId, coverSceneId);
193
+ if (rows.length > 0) {
194
+ coverIuTotal = rows.length;
195
+ coverIuReady = await computeIuReady(ctx.redis, sceneAssetsRepo, bookId, coverChapterId, coverSceneId, rows.length);
196
+ } else {
197
+ const allUnits = [...(coverScene.units || [])];
198
+ for (const db of (coverScene.dialogue_blocks || [])) {
199
+ if (db.units) allUnits.push(...db.units);
200
+ }
201
+ coverIuTotal = allUnits.length;
202
+ if (coverIuTotal > 0) {
203
+ coverIuReady = await computeIuReady(ctx.redis, sceneAssetsRepo, bookId, coverChapterId, coverSceneId, allUnits.length);
204
+ }
205
+ }
206
+
207
+ if (coverIuReady < coverIuTotal && buildId) {
208
+ try {
209
+ const buildDir = path.join(ctx.outputRoot, buildId);
210
+ if (fs.existsSync(buildDir)) {
211
+ const iuPrefix = naming.iuImagePrefix(bookId, coverChapterId, coverSceneId);
212
+ const pngFiles = fs.readdirSync(buildDir).filter(f => f.startsWith(iuPrefix) && f.endsWith('.png'));
213
+ if (pngFiles.length >= coverIuTotal) coverIuReady = coverIuTotal;
214
+ }
215
+ } catch (_) {}
216
+ }
217
+ }
218
+ }
219
+ }
220
+ } catch (_) {}
221
+
222
+ res.json({
223
+ book_id: bookId, scope: scope || 'book', total_chunks: totalChunks,
224
+ audio_ready: audioReady, audio_ready_real: audioReadyReal,
225
+ image_ready: imageReady, video_ready: videoReady,
226
+ has_audio: hasAudio, has_image: hasImage, has_video: hasVideo,
227
+ all_audio_ready: audioReadyReal === filteredIds.length && filteredIds.length > 0,
228
+ all_image_ready: imageReady === filteredIds.length && filteredIds.length > 0,
229
+ all_video_ready: videoReady === filteredIds.length && filteredIds.length > 0,
230
+ has_assets: audioReady > 0 || imageReady > 0 || videoReady > 0,
231
+ scope_total: filteredIds.length,
232
+ scope_audio_ready: audioReady, scope_audio_ready_real: audioReadyReal,
233
+ scope_image_ready: imageReady, scope_video_ready: videoReady,
234
+ scope_all_audio_ready: audioReadyReal === filteredIds.length && filteredIds.length > 0,
235
+ scope_all_image_ready: imageReady === filteredIds.length && filteredIds.length > 0,
236
+ scope_all_video_ready: videoReady === filteredIds.length && filteredIds.length > 0,
237
+ scope_iu_total: scopeIuTotal, scope_iu_ready: scopeIuReady,
238
+ cover_iu_total: coverIuTotal, cover_iu_ready: coverIuReady,
239
+ audio_error: audioError, image_error: imageError, video_error: videoError,
240
+ });
241
+ } catch (err) {
242
+ console.error('[ASSETS-STATE] Error:', err.message);
243
+ res.status(500).json({ error: err.message });
244
+ }
245
+ });
246
+ };
@@ -0,0 +1,52 @@
1
+ // ======================================================
2
+ // @animastor/player — Player Model facade over the Canonical Book Model
3
+ // ======================================================
4
+ // The Player (playback media serving + playback data reads) must reach book
5
+ // content ONLY through this seam: Player → Player Model → Canonical Book
6
+ // Model. It must never load books directly from host implementation
7
+ // details (raw book loaders, PostgreSQL, Redis, runtime/orchestration
8
+ // internals, provider implementations).
9
+ //
10
+ // ── Allowed dependencies ────────────────────────────────────────────────
11
+ // - Canonical Book Model facade (@animastor/vbook-runtime/book-model.cjs)
12
+ // - node builtins
13
+ // Everything else (host modules, orchestration, runtime, Redis, PG/storage,
14
+ // providers, services, config) is FORBIDDEN — enforced by
15
+ // backend/tests/architecture/phase6-editor-player.test.js (T1/T3, re-aimed
16
+ // to this package) and player-route-split.test.js (P2/P6).
17
+ //
18
+ // ── Contract (read-only) ────────────────────────────────────────────────
19
+ // loadBook(bookId, { mode }) — playback book content; 'full' (canonical
20
+ // bundle) by default, 'lazy' (canonical-
21
+ // first, draft fallback) for in-progress
22
+ // books. Delegates 1:1 to the Book Model.
23
+ // getBookIdentity(bookId) — identity-only read (manifest), no content.
24
+ // getBookManifest(bookId) — manifest.json only.
25
+ //
26
+ // The Player is read-only by design: it never mutates the canonical model
27
+ // (that is the Editor boundary) and never writes runtime state (that is the
28
+ // runtime/orchestration layer).
29
+
30
+ const bookModel = require('@animastor/vbook-runtime/book-model.cjs');
31
+
32
+ /**
33
+ * Create the Player model facade.
34
+ * @param {{ bookModel?: object }} deps - Book Model injection (defaults to the canonical facade)
35
+ */
36
+ function createPlayerModel(deps = {}) {
37
+ const model = deps.bookModel || bookModel;
38
+
39
+ return {
40
+ loadBook(bookId, options) {
41
+ return model.loadBook(bookId, options);
42
+ },
43
+ getBookIdentity(bookId) {
44
+ return model.getBookIdentity(bookId);
45
+ },
46
+ getBookManifest(bookId) {
47
+ return model.getBookManifest(bookId);
48
+ },
49
+ };
50
+ }
51
+
52
+ module.exports = { createPlayerModel };
@@ -0,0 +1,94 @@
1
+ // ======================================================
2
+ // @animastor/player — PLAYER ROUTES (playback HTTP contour)
3
+ // ======================================================// The playback contour, physically extracted from the host backend
4
+ // (Player route split 4d1f6f0e → packages/animastor-player move; see
5
+ // docs/architecture/PLAYER_ROUTE_SPLIT_CHECKLIST.md).
6
+ //
7
+ // Serves (byte-identical to the pre-split handlers — URLs, headers, status
8
+ // codes and response bodies unchanged):
9
+ // scene media: /api/v1/scene/:b/:ch/:sc/audio|video|image (+ Range)
10
+ // scene data: /api/v1/scene/:b/:ch/:sc/status|storyboard|waveform|timings
11
+ // iu media: /api/v1/iu-image/*, /api/v1/preview/*
12
+ // chunk-keyed: /api/v1/chunk/:id (status), /api/v1/chunk/:id/audio|image|video|storyboard
13
+ // playback queue: /api/v1/book/:bookId/chunks, /api/v1/book/:bookId/assets-state
14
+ //
15
+ // Dependencies arrive EXCLUSIVELY through deps (composition root — host
16
+ // backend.cjs):
17
+ // playerModel — Phase 6 facade over the Canonical Book Model (book reads)
18
+ // outputRoot — artifact root (config.OUTPUT_DIR injected; no direct
19
+ // config reads in the player contour)
20
+ // playerPorts — { assertBookAccess, computeVideoStartMs, computeWaveform }
21
+ // host implementations (auth middleware, video-timeline,
22
+ // waveform-service) injected as ports; the contour must
23
+ // NOT import generation/host implementation modules
24
+ // (workflows/, video-timeline, middleware, waveform-service)
25
+ // and must not receive the video-timeline MODULE — only the
26
+ // port functions (final boundary audit: the module seam was
27
+ // narrowed away; scene-data reads ports via playerPorts)
28
+ // redis/getChunk/getAllChunks/iuRepo — runtime state (infrastructural,
29
+ // host-side; a full playback-projection port is
30
+ // deliberately premature — recorded in the split checklist)
31
+ // image/state/activeScenes/placeholderAudio/layerConfig — host
32
+ // generation-pipeline seams (documented intentional deps)
33
+ // bookProjections — the two pure VBook-runtime read projections
34
+ // (findSceneRuntimeData / collectSceneUnits); the package
35
+ // never receives the whole book module surface (loaders/
36
+ // writers stay host-side; book CONTENT reads go through
37
+ // playerModel only)
38
+ //
39
+ // Usage:
40
+ // const { createPlayerRoutes } = require('@animastor/player');
41
+ // createPlayerRoutes(app, redis, deps);
42
+ //
43
+ // Guards: backend/tests/architecture/player-route-split.test.js +
44
+ // backend/tests/architecture/phase6-editor-player.test.js (re-aimed T4/T5/T6).
45
+
46
+ const { createPlayerShared } = require('./player-shared.cjs');
47
+
48
+ module.exports = function(app, redis, deps) {
49
+ const {
50
+ state, image, utils,
51
+ getChunk, getAllChunks, getBookWindowStatus,
52
+ activeScenes, placeholderAudio, layerConfig, iuRepo,
53
+ sceneAssetsRepo, playerModel,
54
+ // Player-route seams (composition root provides them):
55
+ outputRoot, // = config.OUTPUT_DIR (injected)
56
+ playerPorts, // { assertBookAccess, computeVideoStartMs, computeWaveform }
57
+ computeIuReady, // pure IU math (host routes/book/iu-progress-utils.cjs)
58
+ bookProjections, // { findSceneRuntimeData, collectSceneUnits } — the two
59
+ // pure VBook-runtime read projections; the whole book
60
+ // module surface is NOT handed to the package
61
+ } = deps;
62
+ const { log } = utils;
63
+
64
+ if (!bookProjections || typeof bookProjections.findSceneRuntimeData !== 'function'
65
+ || typeof bookProjections.collectSceneUnits !== 'function') {
66
+ throw new Error('createPlayerRoutes: bookProjections { findSceneRuntimeData, collectSceneUnits } is a required port');
67
+ }
68
+
69
+ const ctx = createPlayerShared({
70
+ playerModel,
71
+ playerPorts,
72
+ outputRoot,
73
+ redis,
74
+ getChunk,
75
+ iuRepo,
76
+ log,
77
+ });
78
+
79
+ require('./scene-media.cjs')(app, ctx);
80
+ require('./scene-data.cjs')(app, ctx, {
81
+ image,
82
+ // Pure read projections over the Canonical Book Model (VBook runtime);
83
+ // the whole book module surface is NOT handed to the player contour.
84
+ bookProjections,
85
+ });
86
+ require('./iu-media.cjs')(app, ctx, {
87
+ image, state, activeScenes, placeholderAudio,
88
+ });
89
+ require('./playback-queue.cjs')(app, ctx, {
90
+ getAllChunks, getBookWindowStatus, layerConfig, sceneAssetsRepo, computeIuReady,
91
+ });
92
+
93
+ log('[ROUTES] Player routes loaded');
94
+ };
@@ -0,0 +1,230 @@
1
+ // ======================================================
2
+ // @animastor/player — SHARED CONTOUR HELPERS (seam)
3
+ // ======================================================
4
+ // Dependency-injection seam shared by every player route sub-module.
5
+ // The playback contour must reach host infrastructure ONLY through:
6
+ // - deps.playerModel (Phase 6 facade — book content reads)
7
+ // - deps.outputRoot (artifact root; replaces direct config.OUTPUT_DIR
8
+ // reads — booksRoot injection precedent fb411c61)
9
+ // - deps.playerPorts ({ assertBookAccess, computeVideoStartMs,
10
+ // computeWaveform } — host implementations injected
11
+ // by the composition root; the player must not
12
+ // import auth middleware, video-timeline (which
13
+ // knows workflows/), or waveform-service directly)
14
+ // - deps.redis / deps.getChunk / ... (runtime state — infrastructural,
15
+ // stays host-side; a full playback-projection port
16
+ // is deliberately premature at this stage)
17
+ //
18
+ // Grammar for artifact filenames comes from artifact-naming.cjs — never
19
+ // reconstructed ad-hoc in route handlers.
20
+ //
21
+ // Static dependency rules are guarded by
22
+ // backend/tests/architecture/player-route-split.test.js and Phase 6
23
+ // T3-style scans (re-aimed to this package).
24
+
25
+ const path = require('path');
26
+ const fs = require('fs');
27
+ const naming = require('./artifact-naming.cjs');
28
+
29
+ /**
30
+ * Create the shared player-route context.
31
+ *
32
+ * @param {object} deps routeDeps subset (see player-routes.cjs)
33
+ * - outputRoot: absolute artifact root (config.OUTPUT_DIR injected by
34
+ * the composition root). Semantics preserved: joined as
35
+ * path.join(outputRoot, buildId, filename) — absolute/relative behavior
36
+ * identical to the previous OUTPUT_DIR joins.
37
+ * - playerPorts: { assertBookAccess, computeVideoStartMs, computeWaveform }
38
+ */
39
+ function createPlayerShared(deps) {
40
+ const { playerModel, playerPorts, redis, getChunk, iuRepo, log } = deps;
41
+ const outputRoot = deps.outputRoot;
42
+ const ports = playerPorts || {};
43
+
44
+ // ── Build id resolution ────────────────────────────────────────────
45
+ // manifest.json is the single source of truth for build_id (Phase 6:
46
+ // the read goes through the Player boundary, not a raw loader). The
47
+ // requested value is used only as a fallback when the manifest can't
48
+ // be read; the client build_id is a cache key, never an address.
49
+ function getEffectiveBuildId(bookId, requestedBuildId, logFn) {
50
+ const _log = logFn || (() => {});
51
+ try {
52
+ const loadedBook = playerModel.loadBook(bookId);
53
+ if (loadedBook && loadedBook.manifest && loadedBook.manifest.build_id) {
54
+ const manifestBuildId = loadedBook.manifest.build_id;
55
+ if (requestedBuildId && manifestBuildId !== requestedBuildId) {
56
+ _log(`buildId resolved: "${requestedBuildId}" → "${manifestBuildId}" for ${bookId}`);
57
+ }
58
+ return manifestBuildId;
59
+ }
60
+ } catch (_) {}
61
+ return requestedBuildId || 'default';
62
+ }
63
+
64
+ // ── Artifact paths (naming grammar seam) ─────────────────────────────
65
+ function buildDir(buildId) {
66
+ return path.join(outputRoot, buildId);
67
+ }
68
+ function sceneAudioPath(buildId, bookId, chapterId, sceneId) {
69
+ return path.join(buildDir(buildId), naming.sceneAudioName(bookId, chapterId, sceneId));
70
+ }
71
+ function sceneVideoPath(buildId, bookId, chapterId, sceneId) {
72
+ return path.join(buildDir(buildId), naming.sceneVideoName(bookId, chapterId, sceneId));
73
+ }
74
+ function sceneImagePath(buildId, bookId, chapterId, sceneId) {
75
+ return path.join(buildDir(buildId), naming.sceneImageName(bookId, chapterId, sceneId));
76
+ }
77
+
78
+ // ── Book ownership guard for chunk-keyed routes ──────────────────────
79
+ // The target book comes from the chunk record, not the URL, so it is
80
+ // verified in-handler after the chunk is loaded. Pre-auth passes
81
+ // through (existing behaviour); authenticated requests must own the
82
+ // book. Returns true (and responds 403) when access is denied.
83
+ // The host middleware implementation arrives via playerPorts
84
+ // .assertBookAccess (middleware/auth-context.checkBookAccess injected
85
+ // at the composition root — the player package must not own auth).
86
+ async function rejectIfChunkBookDenied(req, res, bookId) {
87
+ if (!ports.assertBookAccess) return false; // no auth host → legacy open behaviour
88
+ const ws = await ports.assertBookAccess(req, bookId);
89
+ if (!ws) {
90
+ res.status(403).json({ error: 'Access denied: not a member of the book\'s workspace' });
91
+ return true;
92
+ }
93
+ return false;
94
+ }
95
+
96
+ // ── Video file resolution ──────────────────────────────────────────
97
+ // The merged `scene.mp4` (result of group concat for the player) takes
98
+ // priority; otherwise the first group file `_gN.mp4` (scene
99
+ // mid-generation). Returns null when none exists.
100
+ function resolveSceneVideoFile(buildDirectory, bookId, chapterId, sceneId) {
101
+ const mergedPath = path.join(buildDirectory, naming.sceneVideoName(bookId, chapterId, sceneId));
102
+ if (fs.existsSync(mergedPath)) {
103
+ log(`[VIDEO-SERVE] ${bookId}/${chapterId}/${sceneId}: merged scene.mp4 → ${path.basename(mergedPath)}`);
104
+ return mergedPath;
105
+ }
106
+ let files = [];
107
+ try {
108
+ files = fs.readdirSync(buildDirectory).filter(f =>
109
+ f.startsWith(naming.sceneArtifactPrefix(bookId, chapterId, sceneId))
110
+ && f.endsWith('.mp4')
111
+ && f !== naming.sceneVideoName(bookId, chapterId, sceneId)
112
+ );
113
+ } catch (_) {}
114
+ if (files.length === 0) {
115
+ log(`[VIDEO-SERVE] ${bookId}/${chapterId}/${sceneId}: no video files found`);
116
+ return null;
117
+ }
118
+ files.sort((a, b) => {
119
+ const na = parseInt((a.match(/_g(\d+)/) || [0, 0])[1], 10);
120
+ const nb = parseInt((b.match(/_g(\d+)/) || [0, 0])[1], 10);
121
+ return na - nb;
122
+ });
123
+ log(`[VIDEO-SERVE] ${bookId}/${chapterId}/${sceneId}: no merged file, serving first group → ${files[0]}`);
124
+ return path.join(buildDirectory, files[0]);
125
+ }
126
+
127
+ // ── Range streaming ─────────────────────────────────────────────────
128
+ // Stream a media file with HTTP Range support (206 Partial Content).
129
+ // The browser <audio>/<video> engine seeks (currentTime = X) by issuing
130
+ // Range requests; without 206 handling the seek silently fails and
131
+ // playback restarts from position 0.
132
+ function streamFileWithRange(req, res, filePath, contentType) {
133
+ const stat = fs.statSync(filePath);
134
+ const fileSize = stat.size;
135
+ // Content is served per build_id URL, but the backend REGENERATES
136
+ // files IN PLACE (same build_id → same URL, new bytes). So responses
137
+ // are cacheable but MUST be revalidated: ETag/Last-Modified +
138
+ // If-None-Match / If-Range let a browser media cache serve repeat
139
+ // plays and resume buffered ranges WITHOUT re-downloading the whole
140
+ // 20-43 MB file, while a regenerated file still propagates (changed
141
+ // ETag → full fresh 200).
142
+ const etag = `"${fileSize.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;
143
+ const lastModified = stat.mtime.toUTCString();
144
+ const lastModifiedSec = Math.floor(stat.mtimeMs / 1000) * 1000;
145
+ res.setHeader('Content-Type', contentType);
146
+ res.setHeader('Accept-Ranges', 'bytes');
147
+ res.setHeader('ETag', etag);
148
+ res.setHeader('Last-Modified', lastModified);
149
+ res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
150
+
151
+ const range = req.headers.range;
152
+ if (!range) {
153
+ // Conditional GET: 304 when the cached entity is still current.
154
+ const inm = (req.headers['if-none-match'] || '').split(',').map((s) => s.trim());
155
+ if (inm.includes(etag) || inm.includes('*')) {
156
+ res.status(304).end();
157
+ return;
158
+ }
159
+ const ims = req.headers['if-modified-since'];
160
+ if (ims && Date.parse(ims) >= lastModifiedSec) {
161
+ res.status(304).end();
162
+ return;
163
+ }
164
+ res.setHeader('Content-Length', fileSize);
165
+ fs.createReadStream(filePath).pipe(res);
166
+ return;
167
+ }
168
+
169
+ const match = /^bytes=(\d*)-(\d*)$/.exec(range);
170
+ if (!match) {
171
+ res.status(416).setHeader('Content-Range', `bytes */${fileSize}`).end();
172
+ return;
173
+ }
174
+ let start = match[1] ? parseInt(match[1], 10) : 0;
175
+ let end = match[2] ? parseInt(match[2], 10) : fileSize - 1;
176
+ if (match[1] === '' && match[2] !== '') {
177
+ // Suffix range "bytes=-N" — last N bytes.
178
+ start = Math.max(0, fileSize - parseInt(match[2], 10));
179
+ end = fileSize - 1;
180
+ }
181
+ if (start >= fileSize || start > end) {
182
+ res.status(416).setHeader('Content-Range', `bytes */${fileSize}`).end();
183
+ return;
184
+ }
185
+ // If-Range: the client's cached entity must still match ours (ETag
186
+ // or date) — otherwise the Range is ignored and the full 200 entity
187
+ // is served. Media players send this to resume buffered ranges
188
+ // without re-downloading; a regenerated file gets the full body.
189
+ let ifRangeOk = true;
190
+ const ifRange = req.headers['if-range'];
191
+ if (ifRange) {
192
+ if (ifRange.startsWith('"') || ifRange.startsWith('W/')) {
193
+ ifRangeOk = ifRange === etag;
194
+ } else {
195
+ ifRangeOk = Date.parse(ifRange) === lastModifiedSec;
196
+ }
197
+ }
198
+ if (!ifRangeOk) {
199
+ res.setHeader('Content-Length', fileSize);
200
+ fs.createReadStream(filePath).pipe(res);
201
+ return;
202
+ }
203
+ end = Math.min(end, fileSize - 1);
204
+ res.status(206);
205
+ res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
206
+ res.setHeader('Content-Length', end - start + 1);
207
+ fs.createReadStream(filePath, { start, end }).pipe(res);
208
+ }
209
+
210
+ return {
211
+ getEffectiveBuildId,
212
+ buildDir,
213
+ sceneAudioPath,
214
+ sceneVideoPath,
215
+ sceneImagePath,
216
+ rejectIfChunkBookDenied,
217
+ resolveSceneVideoFile,
218
+ streamFileWithRange,
219
+ // direct access to the injected seams (read-only for sub-modules)
220
+ get playerModel() { return playerModel; },
221
+ get playerPorts() { return ports; },
222
+ get redis() { return redis; },
223
+ get getChunk() { return getChunk; },
224
+ get iuRepo() { return iuRepo; },
225
+ get outputRoot() { return outputRoot; },
226
+ get log() { return log; },
227
+ };
228
+ }
229
+
230
+ module.exports = { createPlayerShared };