@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
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 };
|
package/src/iu-media.cjs
ADDED
|
@@ -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
|
+
};
|