@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 +41 -0
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/package.json +48 -0
- package/src/artifact-naming.cjs +79 -0
- package/src/index.cjs +49 -0
- package/src/iu-media.cjs +294 -0
- package/src/playback-queue.cjs +246 -0
- package/src/player-model.cjs +52 -0
- package/src/player-routes.cjs +94 -0
- package/src/player-shared.cjs +230 -0
- package/src/scene-data.cjs +419 -0
- package/src/scene-media.cjs +103 -0
|
@@ -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 };
|