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