@torrent-tv/proxy 2.101.43 → 2.102.1
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/bin/cli.js +2 -1
- package/package.json +1 -1
- package/routes/api/sources/chapters/get.js +8 -0
- package/server.js +38 -3
- package/services/encode/EncodeRuns.js +5 -0
- package/services/media/ChapterOrchestrator.js +104 -0
- package/services/media/ContainerOrchestrator.js +20 -5
- package/services/media/container/Container.js +5 -0
- package/services/media/container/MatroskaContainer.js +4 -0
- package/services/media/container/Mp4Container.js +4 -0
- package/services/media/container/chapters.js +104 -0
- package/services/media/container/matroska-chapters.js +213 -0
- package/services/media/container/mp4-chapters.js +310 -0
- package/services/server/controllers/ChapterController.js +9 -0
- package/services/server/wire-outputs.js +2 -0
- package/services/torrent/worker/pool-adapter.js +10 -2
- package/services/transport/data-channel-handler.js +24 -1
- package/utils/wait-trace.js +68 -0
package/bin/cli.js
CHANGED
|
@@ -406,7 +406,8 @@ try {
|
|
|
406
406
|
// Late-bound for the same reason: what the connections carry beyond the
|
|
407
407
|
// film is counted by the data-channel handler, built further down.
|
|
408
408
|
serviceShare: () => dataChannelHandler?.serviceShare?.() ?? null,
|
|
409
|
-
onStateChanged: () => void stateReporter.changed()
|
|
409
|
+
onStateChanged: () => void stateReporter.changed(),
|
|
410
|
+
onChaptersChanged: (event) => dataChannelHandler?.publishChaptersChanged(event)
|
|
410
411
|
});
|
|
411
412
|
app = started.app;
|
|
412
413
|
actualPort = started.port;
|
package/package.json
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Snapshot only; chapter discovery never delays playback preparation. */
|
|
2
|
+
export function handleApiSourceChaptersGet(req, reply, { chapterController }) {
|
|
3
|
+
const sourceKey = String(req.params?.sourceKey ?? "");
|
|
4
|
+
const rawIndex = req.query?.fileIndex;
|
|
5
|
+
const fileIndex = typeof rawIndex === "string" && /^\d+$/.test(rawIndex) ? Number(rawIndex) : NaN;
|
|
6
|
+
const result = chapterController.get({ sourceKey, fileIndex });
|
|
7
|
+
return reply.header("Cache-Control", "no-store").code(result.status).send(result.body);
|
|
8
|
+
}
|
package/server.js
CHANGED
|
@@ -12,6 +12,7 @@ import fastifyHelmet from "@fastify/helmet";
|
|
|
12
12
|
import getPort from "get-port";
|
|
13
13
|
import { createRequire } from "node:module";
|
|
14
14
|
import { performance } from "node:perf_hooks";
|
|
15
|
+
import { SourceWaitTrace } from "./utils/wait-trace.js";
|
|
15
16
|
import { handleHealthGet } from "./routes/health/get.js";
|
|
16
17
|
import { handleMediaGet } from "./routes/media/get.js";
|
|
17
18
|
import { ProbeRequests } from "./services/media/ProbeRequests.js";
|
|
@@ -31,6 +32,9 @@ import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
|
|
|
31
32
|
import { handleApiSourceFingerprintGet } from "./routes/api/sources/fingerprint/get.js";
|
|
32
33
|
import { handleApiSourceContainerMetadataGet } from "./routes/api/sources/container-metadata/get.js";
|
|
33
34
|
import { handleApiSourceCoverGet } from "./routes/api/sources/cover/get.js";
|
|
35
|
+
import { handleApiSourceChaptersGet } from "./routes/api/sources/chapters/get.js";
|
|
36
|
+
import { ChapterOrchestrator } from "./services/media/ChapterOrchestrator.js";
|
|
37
|
+
import { ChapterController } from "./services/server/controllers/ChapterController.js";
|
|
34
38
|
import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
|
|
35
39
|
import { handleApiSourceViewerPost } from "./routes/api/sources/viewer/post.js";
|
|
36
40
|
import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
|
|
@@ -127,7 +131,7 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
|
|
|
127
131
|
* @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
|
|
128
132
|
*/
|
|
129
133
|
export async function startProxyServer({
|
|
130
|
-
host, port, transcodeAudio, ffmpegBin, memoryBytes, segmentFormat, stateDir, onSubtitleCues, diagnostics, budgetPolicy,
|
|
134
|
+
host, port, transcodeAudio, ffmpegBin, memoryBytes, segmentFormat, stateDir, onSubtitleCues, onChaptersChanged, diagnostics, budgetPolicy,
|
|
131
135
|
deliverySink = false,
|
|
132
136
|
// Whether a memory high-water earns a heap snapshot (`--heap-snapshots`).
|
|
133
137
|
heapSnapshots = false,
|
|
@@ -154,6 +158,8 @@ export async function startProxyServer({
|
|
|
154
158
|
bodyLimit: 256 * 1024 * 1024 // 256 MB
|
|
155
159
|
});
|
|
156
160
|
const forwardedSessionResponseStarts = new WeakMap();
|
|
161
|
+
const waitTrace = new SourceWaitTrace({ emit: value => logger.info(`[wait-trace] ${JSON.stringify(value)}`) });
|
|
162
|
+
waitTrace.install(app);
|
|
157
163
|
const logForwardedSessionResponseStage = (stage, req) => {
|
|
158
164
|
const startedAt = forwardedSessionResponseStarts.get(req);
|
|
159
165
|
const requestId = req.headers["x-ttv-forwarded-request-id"];
|
|
@@ -172,7 +178,7 @@ export async function startProxyServer({
|
|
|
172
178
|
await app.register(fastifyCors, {
|
|
173
179
|
origin: true,
|
|
174
180
|
methods: ["GET", "POST", "OPTIONS"],
|
|
175
|
-
allowedHeaders: ["Content-Type", "Range"]
|
|
181
|
+
allowedHeaders: ["Content-Type", "Range", "X-Ttv-Wait-Trace", "X-Ttv-Wait-Attempt", "X-Ttv-Wait-Operation"]
|
|
176
182
|
});
|
|
177
183
|
|
|
178
184
|
// Allow browser requests from an HTTPS page to this private-network proxy
|
|
@@ -217,6 +223,7 @@ export async function startProxyServer({
|
|
|
217
223
|
// 10 MB file took 12-23 s against 125 ms to hand it to the channel. The
|
|
218
224
|
// adapter keeps TorrentPool's interface, so nothing downstream changed.
|
|
219
225
|
const torrentPool = new WorkerTorrentPool({
|
|
226
|
+
onObserved: fields => waitTrace.source(fields.sourceKey, "torrent-ready", fields),
|
|
220
227
|
memoryBytes,
|
|
221
228
|
stateDir,
|
|
222
229
|
heapSnapshots,
|
|
@@ -224,6 +231,7 @@ export async function startProxyServer({
|
|
|
224
231
|
onSourceForgotten: ({ sourceKey }) => {
|
|
225
232
|
sourcePreparation?.forget(sourceKey);
|
|
226
233
|
mediaReads.forget(sourceKey);
|
|
234
|
+
chapters.forget(sourceKey);
|
|
227
235
|
probeReads.forget(sourceKey);
|
|
228
236
|
subtitles.forget(sourceKey);
|
|
229
237
|
keyframeTables.forget(sourceKey);
|
|
@@ -246,6 +254,8 @@ export async function startProxyServer({
|
|
|
246
254
|
// only once the worker thread is up and a piece has verified, which is long
|
|
247
255
|
// after the declarations below have run.
|
|
248
256
|
onPiecesArrived: ({ sourceKey, fileIndexes, arrived }) => {
|
|
257
|
+
waitTrace.source(sourceKey, "held-bytes-changed", { fileIndexes, arrived: arrived?.slice?.(0, 32),
|
|
258
|
+
arrivedCount: arrived?.length });
|
|
249
259
|
for (const fileIndex of fileIndexes) {
|
|
250
260
|
mediaReads.bytesChanged(sourceKey, fileIndex);
|
|
251
261
|
probeReads.bytesChanged(sourceKey, fileIndex);
|
|
@@ -382,6 +392,24 @@ export async function startProxyServer({
|
|
|
382
392
|
* @param {object} [known] - The torrent handle when the caller already has it.
|
|
383
393
|
* @returns {Promise<import("./services/media/SubtitleCues.js").HeldFile | null>}
|
|
384
394
|
*/
|
|
395
|
+
const chapters = new ChapterOrchestrator({
|
|
396
|
+
containers: containerOrchestrator,
|
|
397
|
+
subscribe: (sourceKey, fileIndex, listener) => mediaReads.subscribe(sourceKey, fileIndex, listener),
|
|
398
|
+
withdraw: (sourceKey, fileIndex) => {
|
|
399
|
+
mediaReads.retain(sourceKey, fileIndex, (_params, statement) => statement !== "chapters");
|
|
400
|
+
void downloadMaps.withdrawMetadata(sourceKey, fileIndex, "chapters").catch(error => logger.warn(`chapter demand withdrawal failed: ${error.message}`));
|
|
401
|
+
},
|
|
402
|
+
failed: (error) => logger.warn(`chapter request failed: ${error.message}`),
|
|
403
|
+
onChanged: (event) => onChaptersChanged?.(event),
|
|
404
|
+
fileFor: async (sourceKey, fileIndex) => {
|
|
405
|
+
if (!sourceRegistry.get(sourceKey)) return null;
|
|
406
|
+
const torrent = torrentPool.knownTorrent(sourceKey);
|
|
407
|
+
const file = torrent?.files?.[fileIndex];
|
|
408
|
+
if (!file) return torrent ? { invalid: true } : null;
|
|
409
|
+
return containerOver({ sourceKey, fileIndex });
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
const chapterController = new ChapterController({ sourceRegistry, chapters });
|
|
385
413
|
const heldFileFor = async (sourceKey, fileIndex, known = null) => {
|
|
386
414
|
let torrent = known;
|
|
387
415
|
if (!torrent) {
|
|
@@ -849,6 +877,7 @@ export async function startProxyServer({
|
|
|
849
877
|
} }),
|
|
850
878
|
label: String(file.name ?? ""),
|
|
851
879
|
fileOffset: file.offset,
|
|
880
|
+
onObserved: fields => waitTrace.source(sourceKey, "container-read", { fileIndex, requestId, ...fields }),
|
|
852
881
|
onTracks: (tracks) => contentsOf(torrent).noteVideo(fileIndex, tracks.some((track) => track.type === "video")),
|
|
853
882
|
onReadStart: () => ({ storage: mediaReads.revision(sourceKey, fileIndex), memory: mediaReads.memoryRevision(), demand: downloadMaps.epoch(sourceKey, fileIndex) }),
|
|
854
883
|
onReadResult: async (statement, result, revision) => {
|
|
@@ -859,7 +888,9 @@ export async function startProxyServer({
|
|
|
859
888
|
format: containerOrchestrator.known(sourceKey, fileIndex)?.formatName ?? "unrecognised" });
|
|
860
889
|
}
|
|
861
890
|
const finished = mediaReads.record(params, statement, result, revision.storage, revision.memory);
|
|
862
|
-
await downloadMaps.
|
|
891
|
+
if (statement === "chapters" && result.passive) await downloadMaps.withdrawMetadata(sourceKey, fileIndex, statement);
|
|
892
|
+
else await downloadMaps.metadata({ sourceKey, fileIndex, statement: requestId ? `${statement}:${requestId}` : statement, result,
|
|
893
|
+
...(statement === "chapters" ? { priority: 1, urgent: false, deadlineAt: null } : {}),
|
|
863
894
|
...(demand ? { priority: demand.priority, urgent: demand.urgent, deadlineAt: demand.deadlineAt, interval: demand, leadSeconds: demand.leadSeconds ?? 0 } : {}) });
|
|
864
895
|
if (finished && result.kind === "result") {
|
|
865
896
|
void downloadMaps.refresh(sourceKey, fileIndex).catch(error =>
|
|
@@ -894,6 +925,7 @@ export async function startProxyServer({
|
|
|
894
925
|
});
|
|
895
926
|
let sourcePreparation;
|
|
896
927
|
const outputParts = wireOutputs({
|
|
928
|
+
observeWait: (sourceKey, event, fields) => waitTrace.source(sourceKey, event, fields),
|
|
897
929
|
pinnedPreset,
|
|
898
930
|
indexMemory,
|
|
899
931
|
serviceShare,
|
|
@@ -1429,6 +1461,9 @@ export async function startProxyServer({
|
|
|
1429
1461
|
probeReads.needs(requestId, start, end);
|
|
1430
1462
|
}
|
|
1431
1463
|
}));
|
|
1464
|
+
app.get("/api/sources/:sourceKey/chapters", async (req, reply) =>
|
|
1465
|
+
handleApiSourceChaptersGet(req, reply, { chapterController })
|
|
1466
|
+
);
|
|
1432
1467
|
app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
|
|
1433
1468
|
// Off unless --delivery-sink was given; see the route for why it exists.
|
|
1434
1469
|
app.get("/api/delivery-sink", async (req, reply) =>
|
|
@@ -1106,6 +1106,7 @@ export class EncodeRuns {
|
|
|
1106
1106
|
// proven, and it is cleared up when the run ends.
|
|
1107
1107
|
// Told when this run closes its first piece; see the next stretch below.
|
|
1108
1108
|
let atFirstPiece = null;
|
|
1109
|
+
const observedStart = performance.now();
|
|
1109
1110
|
const run = new EncodeRun({
|
|
1110
1111
|
address: session.outputKey ?? session.id,
|
|
1111
1112
|
encoder: this.#host.videoEncoder,
|
|
@@ -1196,6 +1197,10 @@ export class EncodeRuns {
|
|
|
1196
1197
|
{ code: "ERR_INCOMPLETE_MEDIA" });
|
|
1197
1198
|
const published = this.#host.segmentFiles.publish(address, name, session.segmentFormat, { mediaRanges, bytes });
|
|
1198
1199
|
if (!published) throw new Error(`Closed piece #${index} could not be stored.`);
|
|
1200
|
+
if (index === safeIndex) this.#host.observeWait?.(session.file.sourceKey, "first-piece-published", {
|
|
1201
|
+
output: address, segment: index, startedAt: observedStart, endedAt: performance.now(),
|
|
1202
|
+
encoderWrittenAfterMs: Number.isFinite(writtenAt) ? writtenAt - run.startedAt : null,
|
|
1203
|
+
namedAfterMs: Number.isFinite(namedAt) ? namedAt - run.startedAt : null });
|
|
1199
1204
|
this.#leadingByOutput.get(address)?.delete(index);
|
|
1200
1205
|
// WHERE A LATE PIECE'S TIME WENT: the encoder writing it, this process
|
|
1201
1206
|
// getting to its name, or its publication waiting for the file after
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** Selected-file snapshots; retries belong to the shared media request queue. */
|
|
2
|
+
import { chapterResult } from "./container/chapters.js";
|
|
3
|
+
import { IndexMemoryUnavailable } from "./container/memory-unavailable.js";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
export class ChapterOrchestrator {
|
|
7
|
+
constructor({ containers, fileFor, subscribe = () => () => {}, withdraw = () => {}, now = Date.now, onChanged = () => {}, failed = () => {} }) {
|
|
8
|
+
Object.assign(this, { containers, fileFor, subscribe, withdraw, now, onChanged, failed });
|
|
9
|
+
this.files = new Map();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
get(sourceKey, fileIndex) {
|
|
13
|
+
for (const old of this.files.values()) if (old.expires < this.now()) this.forget(old.sourceKey, old.fileIndex);
|
|
14
|
+
const key = `${sourceKey}:${fileIndex}`;
|
|
15
|
+
let entry = this.files.get(key);
|
|
16
|
+
if (!entry) {
|
|
17
|
+
entry = { sourceKey, fileIndex, generation: randomUUID(), reading: null, requested: false, bytes: 0 };
|
|
18
|
+
entry.dispose = () => {
|
|
19
|
+
entry.allocation?.release(entry.bytes + (entry.candidate?.length ?? 0));
|
|
20
|
+
entry.allocation?.dispose();
|
|
21
|
+
entry.bytes = 0;
|
|
22
|
+
entry.candidate = null;
|
|
23
|
+
entry.buffer = null;
|
|
24
|
+
};
|
|
25
|
+
this.files.set(key, entry);
|
|
26
|
+
entry.unsubscribe = this.subscribe(sourceKey, fileIndex, (event) => {
|
|
27
|
+
if (event.kind === "cancelled") this.forget(sourceKey, fileIndex);
|
|
28
|
+
else if (!entry.requested && event.kind === "bytes-changed") this.warm(entry);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
entry.expires = this.now() + 3600000;
|
|
32
|
+
if (!entry.requested) this.warm(entry);
|
|
33
|
+
return { ...(entry.buffer ? JSON.parse(entry.buffer.toString("utf8")) : chapterResult("pending")), sourceKey, fileIndex,
|
|
34
|
+
generation: entry.generation, revision: entry.revision ?? 0 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
update(entry, result) {
|
|
38
|
+
if (this.files.get(`${entry.sourceKey}:${entry.fileIndex}`) !== entry) return;
|
|
39
|
+
let answer;
|
|
40
|
+
if (result.value?.version === 1) answer = result.value;
|
|
41
|
+
else if (result.kind === "needs-ranges") answer = chapterResult("pending", { missingRange: result.ranges[0] });
|
|
42
|
+
else if (result.kind === "needs-memory") answer = chapterResult("pending", { resource: "memory", requiredBytes: result.bytes });
|
|
43
|
+
else if (result.reason === "request-obsolete") return;
|
|
44
|
+
else if (["format-not-supported", "chapter-not-supported"].includes(result.reason)) {
|
|
45
|
+
answer = chapterResult("unsupported", { reason: result.chapterReason ?? result.reason });
|
|
46
|
+
} else answer = chapterResult("error", { reason: result.message ?? result.reason });
|
|
47
|
+
const buffer = result.value && entry.candidate ? entry.candidate : Buffer.from(JSON.stringify(answer));
|
|
48
|
+
const charged = buffer === entry.candidate ? buffer.length : 0;
|
|
49
|
+
if (entry.candidate && !charged) entry.allocation?.release(entry.candidate.length);
|
|
50
|
+
entry.candidate = null;
|
|
51
|
+
if (entry.buffer?.equals(buffer)) {
|
|
52
|
+
entry.allocation?.release(charged);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
entry.allocation?.release(entry.bytes);
|
|
56
|
+
entry.bytes = charged;
|
|
57
|
+
entry.buffer = buffer;
|
|
58
|
+
entry.revision = (entry.revision ?? 0) + 1;
|
|
59
|
+
this.onChanged({ sourceKey: entry.sourceKey, fileIndex: entry.fileIndex, revision: entry.revision });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
warm(entry) {
|
|
63
|
+
if (entry.reading) return;
|
|
64
|
+
entry.reading = (async () => {
|
|
65
|
+
const params = await this.fileFor(entry.sourceKey, entry.fileIndex);
|
|
66
|
+
if (!params || this.files.get(`${entry.sourceKey}:${entry.fileIndex}`) !== entry) return;
|
|
67
|
+
entry.requested = true;
|
|
68
|
+
if (params.invalid) return this.update(entry, { kind: "terminal", reason: "file-index-not-found" });
|
|
69
|
+
const publish = params.onReadResult;
|
|
70
|
+
entry.allocation ??= params.packetMemory?.forRecord?.(entry, "declarations");
|
|
71
|
+
params.acceptChapterResult = (value) => {
|
|
72
|
+
const serialized = JSON.stringify(value);
|
|
73
|
+
const bytes = Buffer.byteLength(serialized);
|
|
74
|
+
if (entry.allocation?.reserve(bytes) === false) throw new IndexMemoryUnavailable(bytes);
|
|
75
|
+
entry.candidate = Buffer.from(serialized);
|
|
76
|
+
};
|
|
77
|
+
params.isCurrent = () => {
|
|
78
|
+
if (entry.expires <= this.now()) this.forget(entry.sourceKey, entry.fileIndex);
|
|
79
|
+
return this.files.get(`${entry.sourceKey}:${entry.fileIndex}`) === entry;
|
|
80
|
+
};
|
|
81
|
+
params.onReadResult = async (statement, result, revision) => {
|
|
82
|
+
await publish?.(statement, result, revision);
|
|
83
|
+
this.update(entry, result);
|
|
84
|
+
};
|
|
85
|
+
await this.containers.inspect(params, "chapters");
|
|
86
|
+
})().catch((error) => {
|
|
87
|
+
this.failed(error);
|
|
88
|
+
if (this.files.get(`${entry.sourceKey}:${entry.fileIndex}`) === entry) {
|
|
89
|
+
entry.requested = false;
|
|
90
|
+
this.update(entry, { kind: "terminal", reason: "chapter-request-failed", message: error.message });
|
|
91
|
+
}
|
|
92
|
+
}).finally(() => { entry.reading = null; });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
forget(sourceKey, fileIndex) {
|
|
96
|
+
for (const [key, entry] of this.files) {
|
|
97
|
+
if (entry.sourceKey !== sourceKey || (fileIndex !== undefined && entry.fileIndex !== fileIndex)) continue;
|
|
98
|
+
this.files.delete(key);
|
|
99
|
+
entry.dispose();
|
|
100
|
+
entry.unsubscribe?.();
|
|
101
|
+
this.withdraw(entry.sourceKey, entry.fileIndex);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -20,6 +20,7 @@ import { BytesUnavailable, isUnavailable, strictReader } from "./container/unava
|
|
|
20
20
|
import { OSHASH_EDGE_BYTES, hasOshash, oshash } from "./oshash.js";
|
|
21
21
|
import { IndexMemoryUnavailable } from "./container/memory-unavailable.js";
|
|
22
22
|
import { OutsideReadableEdges } from "./container/work-tags.js";
|
|
23
|
+
import { ChapterUnsupported } from "./container/chapters.js";
|
|
23
24
|
import { logger } from "../../utils/logger.js";
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -139,7 +140,7 @@ export class ContainerOrchestrator {
|
|
|
139
140
|
|
|
140
141
|
/** Read one statement without converting missing bytes into an empty answer. */
|
|
141
142
|
async inspect(params, statement = "tracks") {
|
|
142
|
-
if (!["container", "tracks", "media-info", "keyframes", "packets", "source-navigation", "source-ranges", "fingerprint", "work-tags", "cover"].includes(statement)) {
|
|
143
|
+
if (!["container", "tracks", "media-info", "keyframes", "packets", "source-navigation", "source-ranges", "fingerprint", "work-tags", "cover", "chapters"].includes(statement)) {
|
|
143
144
|
throw new TypeError(`Unknown media statement: ${statement}`);
|
|
144
145
|
}
|
|
145
146
|
const key = `${params.sourceKey}:${params.fileIndex}`;
|
|
@@ -155,13 +156,17 @@ export class ContainerOrchestrator {
|
|
|
155
156
|
// table of the whole film it does not use (r38, torrent-tv/meta#178).
|
|
156
157
|
const aside = this.#answersWithoutReading(key, statement);
|
|
157
158
|
const previous = aside ? Promise.resolve() : this.#reads.get(key) ?? Promise.resolve();
|
|
158
|
-
const askedAt =
|
|
159
|
+
const askedAt = performance.now();
|
|
159
160
|
const reading = previous.catch(() => undefined).then(async () => {
|
|
160
161
|
if (!isCurrent()) return { result: { kind: "terminal", reason: "request-obsolete", requestId: params.requestId } };
|
|
161
|
-
const startedAt =
|
|
162
|
+
const startedAt = performance.now();
|
|
162
163
|
const revision = params.onReadStart?.(statement);
|
|
163
164
|
const result = await this.#inspectRead({ ...params, isCurrent }, statement);
|
|
164
|
-
|
|
165
|
+
const endedAt = performance.now();
|
|
166
|
+
params.onTimed?.(statement, startedAt - askedAt, endedAt - startedAt);
|
|
167
|
+
if (statement !== "source-ranges" || endedAt - askedAt >= 10) params.onObserved?.({ statement, askedAt,
|
|
168
|
+
startedAt, endedAt, queuedMs: startedAt - askedAt, readMs: endedAt - startedAt, cached: aside,
|
|
169
|
+
result: result.kind, reason: result.reason, ranges: result.ranges });
|
|
165
170
|
return { result, revision };
|
|
166
171
|
});
|
|
167
172
|
if (!aside) this.#reads.set(key, reading);
|
|
@@ -232,11 +237,20 @@ export class ContainerOrchestrator {
|
|
|
232
237
|
value = await container.readPacketIndex(params.packetInterval, { isCurrent: params.isCurrent, account: params.walkAccount });
|
|
233
238
|
if (value === null) return { kind: "terminal", reason: "request-obsolete", requestId };
|
|
234
239
|
await value?.prepareAudioDependencies?.(params.packetInterval, container.readRange);
|
|
240
|
+
} else if (statement === "chapters") {
|
|
241
|
+
value = await container.readChapters({ read: params.readHeld ?? params.readRange,
|
|
242
|
+
isCurrent: params.isCurrent,
|
|
243
|
+
ranges: params.heldRanges ? await params.heldRanges() : undefined });
|
|
244
|
+
if (params.isCurrent?.() === false) return { kind: "terminal", reason: "request-obsolete", requestId };
|
|
245
|
+
params.acceptChapterResult?.(value);
|
|
246
|
+
if (!value.complete && value.pending) return { kind: "needs-ranges", ranges: [[value.pending.start, value.pending.end]], value, requestId };
|
|
235
247
|
} else {
|
|
236
248
|
throw new TypeError(`Unknown media statement: ${statement}`);
|
|
237
249
|
}
|
|
238
250
|
return { kind: "result", value, requestId };
|
|
239
251
|
} catch (error) {
|
|
252
|
+
if (statement === "chapters" && params.isCurrent?.() === false) return { kind: "terminal", reason: "request-obsolete", requestId };
|
|
253
|
+
if (error instanceof ChapterUnsupported) return { kind: "terminal", reason: "chapter-not-supported", chapterReason: error.message, requestId };
|
|
240
254
|
if (error instanceof IndexMemoryUnavailable) {
|
|
241
255
|
const result = { kind: "needs-memory", bytes: error.bytes, requestId };
|
|
242
256
|
logger.info(`media request=${requestId} needs memory=${error.bytes}`);
|
|
@@ -248,7 +262,8 @@ export class ContainerOrchestrator {
|
|
|
248
262
|
return { kind: "terminal", reason: "outside-edges", requestId };
|
|
249
263
|
}
|
|
250
264
|
if (isUnavailable(error)) {
|
|
251
|
-
const result = { kind: "needs-ranges", ranges: [[error.start, error.end]], requestId
|
|
265
|
+
const result = { kind: "needs-ranges", ranges: [[error.start, error.end]], requestId,
|
|
266
|
+
...(error.passive ? { passive: true } : {}) };
|
|
252
267
|
logger.info(`media request=${requestId} needs bytes=${error.start}-${error.end}`);
|
|
253
268
|
return result;
|
|
254
269
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { isUnavailable, strictReader } from "./unavailable.js";
|
|
16
16
|
import { textList } from "./work-tags.js";
|
|
17
|
+
import { chapterResult } from "./chapters.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* What one file declares about itself. Every field is either a value the
|
|
@@ -29,6 +30,10 @@ import { textList } from "./work-tags.js";
|
|
|
29
30
|
*/
|
|
30
31
|
|
|
31
32
|
export class Container {
|
|
33
|
+
/** Read chapters only through the caller's non-fetching held-byte reader. */
|
|
34
|
+
async readChapters(_held) {
|
|
35
|
+
return chapterResult("unsupported", { reason: `chapter-container:${this.formatName}` });
|
|
36
|
+
}
|
|
32
37
|
/**
|
|
33
38
|
* One strict available-byte reader and one container per
|
|
34
39
|
* file — the one `ContainerOrchestrator` keeps.
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
import { FFMPEG_STREAM_SEARCH_SECONDS } from "./ffmpeg-stream-search.js";
|
|
36
36
|
import { Container } from "./Container.js";
|
|
37
|
+
import { readMatroskaChapters } from "./matroska-chapters.js";
|
|
37
38
|
import { VideoTrack } from "../tracks/VideoTrack.js";
|
|
38
39
|
import { h264Configuration } from "./h264-configuration.js";
|
|
39
40
|
import { hevcConfiguration } from "./hevc-configuration.js";
|
|
@@ -144,6 +145,9 @@ function readString(buf, el) {
|
|
|
144
145
|
*/
|
|
145
146
|
|
|
146
147
|
export class MatroskaContainer extends Container {
|
|
148
|
+
async readChapters(held) {
|
|
149
|
+
return readMatroskaChapters(this, held);
|
|
150
|
+
}
|
|
147
151
|
/** @type {Buffer | null} */
|
|
148
152
|
#declarations = null;
|
|
149
153
|
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { Container } from "./Container.js";
|
|
20
20
|
import { RetainedBytes } from "./RetainedBytes.js";
|
|
21
|
+
import { readMp4Chapters } from "./mp4-chapters.js";
|
|
21
22
|
import { VideoTrack } from "../tracks/VideoTrack.js";
|
|
22
23
|
import { AudioTrack } from "../tracks/AudioTrack.js";
|
|
23
24
|
import { TextSubtitleTrack, TEXT_FORMATS_MP4 } from "../tracks/TextSubtitleTrack.js";
|
|
@@ -265,6 +266,9 @@ function decoderSpecificInfoOf(buf, start, end) {
|
|
|
265
266
|
|
|
266
267
|
export class Mp4Container extends Container {
|
|
267
268
|
#tracks = null;
|
|
269
|
+
async readChapters(held) {
|
|
270
|
+
return readMp4Chapters(this, held);
|
|
271
|
+
}
|
|
268
272
|
get formatName() {
|
|
269
273
|
return "mp4";
|
|
270
274
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** File chapter declarations and bounded, non-fetching byte access. */
|
|
2
|
+
import { BytesUnavailable, strictReader } from "./unavailable.js";
|
|
3
|
+
|
|
4
|
+
export class ChapterUnsupported extends Error {
|
|
5
|
+
constructor(reason) { super(reason); this.name = "ChapterUnsupported"; }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function chapterResult(state, extra = {}) {
|
|
9
|
+
return { version: 1, state, complete: state !== "pending", selectedEditionId: null,
|
|
10
|
+
editions: [], sections: [], tailContent: "unknown", ...extra };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Limits bound hostile metadata, not the media file's size. */
|
|
14
|
+
export const CHAPTER_LIMITS = { entries: 4096, depth: 64, textBytes: 8192, readBytes: 8 * 1024 * 1024, elements: 100000 };
|
|
15
|
+
|
|
16
|
+
export class ChapterBytes {
|
|
17
|
+
constructor(held, size) {
|
|
18
|
+
this.held = held;
|
|
19
|
+
this.size = size;
|
|
20
|
+
this.read = strictReader(held.read, size);
|
|
21
|
+
this.used = 0;
|
|
22
|
+
this.elements = 0;
|
|
23
|
+
this.window = null;
|
|
24
|
+
this.windowAt = 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async bytes(start, length) {
|
|
28
|
+
if (this.held.isCurrent?.() === false) throw new Error("Chapter request was withdrawn");
|
|
29
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(length) || start < 0 || length < 0 || start + length > this.size) {
|
|
30
|
+
throw new Error("Chapter byte range is outside the file");
|
|
31
|
+
}
|
|
32
|
+
if (length === 0) return Buffer.alloc(0);
|
|
33
|
+
if (this.window && start >= this.windowAt && start + length <= this.windowAt + this.window.length) {
|
|
34
|
+
return this.window.subarray(start - this.windowAt, start - this.windowAt + length);
|
|
35
|
+
}
|
|
36
|
+
const range = this.held.ranges?.find(([first, last]) => first <= start && last >= start + length - 1);
|
|
37
|
+
if (this.held.ranges && !range) {
|
|
38
|
+
const error = new BytesUnavailable(start, start + length - 1, 0);
|
|
39
|
+
error.passive = this.passiveMissing === true;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
// Header traversal must not copy adjacent video payloads into the metadata budget.
|
|
43
|
+
const span = length;
|
|
44
|
+
this.used += span;
|
|
45
|
+
if (this.used > CHAPTER_LIMITS.readBytes) throw new ChapterUnsupported("chapter-read-budget");
|
|
46
|
+
if (++this.elements % 64 === 0) await new Promise((resolve) => setImmediate(resolve));
|
|
47
|
+
try { this.window = await this.read(start, start + span - 1); }
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error?.name === "BytesUnavailable") error.passive = this.passiveMissing === true;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
this.windowAt = start;
|
|
53
|
+
return this.window.subarray(0, length);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
step(depth = 0) {
|
|
57
|
+
if (this.held.isCurrent?.() === false) throw new Error("Chapter request was withdrawn");
|
|
58
|
+
if (++this.elements > CHAPTER_LIMITS.elements || depth > CHAPTER_LIMITS.depth) {
|
|
59
|
+
throw new ChapterUnsupported("chapter-structure-budget");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function chapterSeconds(value, units) {
|
|
65
|
+
const seconds = Number(value / BigInt(units)) + Number(value % BigInt(units)) / units;
|
|
66
|
+
if (!Number.isFinite(seconds) || seconds > Number.MAX_SAFE_INTEGER) throw new Error("Chapter time is outside the supported numeric range");
|
|
67
|
+
return seconds;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function classifyChapter(titles) {
|
|
71
|
+
const types = new Set();
|
|
72
|
+
for (const { text } of titles) {
|
|
73
|
+
const title = text.trim().toLowerCase();
|
|
74
|
+
if (/^(op(?:ening)?|opening credits|opening theme|オープニング)$/.test(title)) types.add("opening");
|
|
75
|
+
if (/^(ed|ending|ending theme|エンディング)$/.test(title)) types.add("ending");
|
|
76
|
+
if (/^(credits|end credits|closing credits|титры)$/.test(title)) types.add("credits");
|
|
77
|
+
if (/^(post[- ]credits?(?: scene)?|after[- ]credits?(?: scene)?)$/.test(title)) types.add("post-credits");
|
|
78
|
+
}
|
|
79
|
+
const inferred = types.size === 1;
|
|
80
|
+
return { type: inferred ? [...types][0] : "chapter",
|
|
81
|
+
classification: inferred ? "title-hint" : "unclassified",
|
|
82
|
+
confidence: inferred ? "unconfirmed" : "declared" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Build navigation intervals without promoting inferred ends to file facts. */
|
|
86
|
+
export function navigationSections(edition, duration = null) {
|
|
87
|
+
const out = [];
|
|
88
|
+
const walk = (chapters) => {
|
|
89
|
+
for (const chapter of chapters) {
|
|
90
|
+
if (chapter.enabled && !chapter.hidden) {
|
|
91
|
+
const next = chapters.filter((other) => other.startSeconds > chapter.startSeconds)
|
|
92
|
+
.reduce((end, other) => Math.min(end, other.startSeconds), Infinity);
|
|
93
|
+
const inferred = Number.isFinite(next) ? next : duration;
|
|
94
|
+
out.push({ ...chapter, children: undefined, ...classifyChapter(chapter.titles),
|
|
95
|
+
displayEndSeconds: chapter.endSeconds ?? inferred,
|
|
96
|
+
endSource: chapter.endSeconds !== null ? "declared" : inferred !== null ? "navigation" : "unknown",
|
|
97
|
+
automaticSkipAllowed: false, evidence: "file-chapter" });
|
|
98
|
+
}
|
|
99
|
+
walk(chapter.children);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
walk(edition.chapters);
|
|
103
|
+
return out.sort((a, b) => a.startSeconds - b.startSeconds || a.order - b.order);
|
|
104
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/** RFC 9559 §§5.1.7, 6.6, 11.1.1 and 20: chapter metadata only. */
|
|
2
|
+
import { readVint } from "./ebml-reader.js";
|
|
3
|
+
import { ChapterBytes, ChapterUnsupported, CHAPTER_LIMITS, chapterResult, chapterSeconds, navigationSections } from "./chapters.js";
|
|
4
|
+
|
|
5
|
+
const SEGMENT = 0x18538067;
|
|
6
|
+
const CHAPTERS = 0x1043a770;
|
|
7
|
+
const SEEK_HEAD = 0x114d9b74;
|
|
8
|
+
const CLUSTER = 0x1f43b675;
|
|
9
|
+
const TOP_LEVEL = new Set([SEEK_HEAD, CHAPTERS, CLUSTER, 0x1549a966, 0x1654ae6b, 0x1c53bb6b, 0x1941a469, 0x1254c367]);
|
|
10
|
+
|
|
11
|
+
async function header(reader, at, limit) {
|
|
12
|
+
reader.step();
|
|
13
|
+
const vint = async (position, marker) => {
|
|
14
|
+
const first = (await reader.bytes(position, 1))[0];
|
|
15
|
+
let n = 1;
|
|
16
|
+
while (n <= 8 && !(first & (0x80 >> (n - 1)))) n++;
|
|
17
|
+
if (n > (marker ? 4 : 8) || position + n > limit) throw new Error("Invalid chapter EBML header");
|
|
18
|
+
const bytes = await reader.bytes(position, n);
|
|
19
|
+
const value = readVint(bytes, 0, marker);
|
|
20
|
+
const mask = 2 ** (8 - n) - 1;
|
|
21
|
+
return { ...value, unknown: !marker && (bytes[0] & mask) === mask && bytes.subarray(1).every((byte) => byte === 255) };
|
|
22
|
+
};
|
|
23
|
+
const id = await vint(at, true);
|
|
24
|
+
const size = await vint(at + id.length, false);
|
|
25
|
+
const dataOffset = at + id.length + size.length;
|
|
26
|
+
const unknown = size.unknown;
|
|
27
|
+
const end = unknown ? null : dataOffset + size.value;
|
|
28
|
+
if (!Number.isSafeInteger(dataOffset) || (!unknown && (!Number.isSafeInteger(end) || end > limit))) throw new Error("Chapter element exceeds its parent");
|
|
29
|
+
return { id: id.value, at, dataOffset, end, size: unknown ? null : size.value };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function* children(reader, master, depth = 0) {
|
|
33
|
+
reader.step(depth);
|
|
34
|
+
if (master.end === null) throw new Error("Unknown size is not valid for this chapter element");
|
|
35
|
+
for (let at = master.dataOffset; at < master.end;) {
|
|
36
|
+
const child = await header(reader, at, master.end);
|
|
37
|
+
if (child.end === null) throw new Error("Unknown-sized chapter child");
|
|
38
|
+
yield child;
|
|
39
|
+
at = child.end;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function integer(reader, field, fallback = null) {
|
|
44
|
+
if (field.size > 8) throw new Error("Chapter integer exceeds eight bytes");
|
|
45
|
+
if (field.size === 0) return fallback;
|
|
46
|
+
const bytes = await reader.bytes(field.dataOffset, field.size);
|
|
47
|
+
let value = 0n;
|
|
48
|
+
for (const byte of bytes) value = value * 256n + BigInt(byte);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function text(reader, field) {
|
|
53
|
+
if (field.size > CHAPTER_LIMITS.textBytes) throw new ChapterUnsupported("chapter-title-budget");
|
|
54
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(await reader.bytes(field.dataOffset, field.size)).replace(/\0+$/, "");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function display(reader, field, depth) {
|
|
58
|
+
let value = null;
|
|
59
|
+
const languages = [], bcp47 = [], countries = [];
|
|
60
|
+
for await (const part of children(reader, field, depth)) {
|
|
61
|
+
if (part.id === 0x85) value = await text(reader, part);
|
|
62
|
+
if (part.id === 0x437c) languages.push(await text(reader, part));
|
|
63
|
+
if (part.id === 0x437d) bcp47.push(await text(reader, part));
|
|
64
|
+
if (part.id === 0x437e) countries.push(await text(reader, part));
|
|
65
|
+
}
|
|
66
|
+
if (value === null) throw new Error("ChapterDisplay has no ChapString");
|
|
67
|
+
return { text: value, languages: bcp47.length ? bcp47 : languages.length ? languages : ["eng"],
|
|
68
|
+
countries: bcp47.length ? [] : countries, languageSource: bcp47.length ? "bcp47" : "matroska" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function atom(reader, field, editionId, parentId, count, depth) {
|
|
72
|
+
if (++count.value > CHAPTER_LIMITS.entries) throw new ChapterUnsupported("chapter-count-budget");
|
|
73
|
+
let uid = null, start = null, end = null, linkedSegment = null, linkedEdition = null;
|
|
74
|
+
let hidden = false, enabled = true;
|
|
75
|
+
const titles = [], childFields = [], trackNumbers = [], processCodecs = [];
|
|
76
|
+
for await (const part of children(reader, field, depth)) {
|
|
77
|
+
if (part.id === 0x73c4) uid = await integer(reader, part);
|
|
78
|
+
if (part.id === 0x91) start = await integer(reader, part, 0n);
|
|
79
|
+
if (part.id === 0x92) end = await integer(reader, part, 0n);
|
|
80
|
+
if (part.id === 0x98) hidden = (await integer(reader, part, 0n)) !== 0n;
|
|
81
|
+
if (part.id === 0x4598) enabled = (await integer(reader, part, 1n)) !== 0n;
|
|
82
|
+
if (part.id === 0x6e67) {
|
|
83
|
+
if (part.size !== 16) throw new Error("ChapterSegmentUUID must contain sixteen bytes");
|
|
84
|
+
linkedSegment = (await reader.bytes(part.dataOffset, part.size)).toString("hex");
|
|
85
|
+
}
|
|
86
|
+
if (part.id === 0x6ebc) linkedEdition = String(await integer(reader, part));
|
|
87
|
+
if (part.id === 0x80) titles.push(await display(reader, part, depth + 1));
|
|
88
|
+
if (part.id === 0xb6) childFields.push(part);
|
|
89
|
+
if (part.id === 0x8f) {
|
|
90
|
+
for await (const track of children(reader, part, depth + 1)) if (track.id === 0x89) trackNumbers.push(String(await integer(reader, track)));
|
|
91
|
+
}
|
|
92
|
+
if (part.id === 0x6944) {
|
|
93
|
+
let codec = 0n;
|
|
94
|
+
for await (const command of children(reader, part, depth + 1)) if (command.id === 0x6955) codec = await integer(reader, command, 0n);
|
|
95
|
+
processCodecs.push(String(codec));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (uid === null || uid === 0n || start === null) throw new Error("ChapterAtom requires a nonzero ChapterUID and ChapterTimeStart");
|
|
99
|
+
if (end !== null && end < start) throw new Error("ChapterTimeEnd precedes ChapterTimeStart");
|
|
100
|
+
const id = `${editionId}:chapter:${uid}`;
|
|
101
|
+
const chapter = { id, uid: String(uid), editionId, parentId, order: count.value,
|
|
102
|
+
startNanoseconds: String(start), endNanoseconds: end === null ? null : String(end),
|
|
103
|
+
startSeconds: chapterSeconds(start, 1e9), endSeconds: end === null ? null : chapterSeconds(end, 1e9),
|
|
104
|
+
hidden, enabled, titles, linkedSegment, linkedEdition, trackNumbers, processCodecs, children: [] };
|
|
105
|
+
for (const child of childFields) {
|
|
106
|
+
const nested = await atom(reader, child, editionId, id, count, depth + 1);
|
|
107
|
+
if (BigInt(nested.startNanoseconds) < start || (end !== null && BigInt(nested.startNanoseconds) > end)) throw new Error("Nested chapter starts outside its parent");
|
|
108
|
+
chapter.children.push(nested);
|
|
109
|
+
}
|
|
110
|
+
return chapter;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function editionsOf(reader, chapters) {
|
|
114
|
+
const editions = [], count = { value: 0 };
|
|
115
|
+
for await (const field of children(reader, chapters)) {
|
|
116
|
+
if (field.id !== 0x45b9) continue;
|
|
117
|
+
let uid = null, hidden = false, isDefault = false, ordered = false;
|
|
118
|
+
const atoms = [];
|
|
119
|
+
for await (const part of children(reader, field, 1)) {
|
|
120
|
+
if (part.id === 0x45bc) uid = await integer(reader, part);
|
|
121
|
+
if (part.id === 0x45bd) hidden = (await integer(reader, part, 0n)) !== 0n;
|
|
122
|
+
if (part.id === 0x45db) isDefault = (await integer(reader, part, 0n)) !== 0n;
|
|
123
|
+
if (part.id === 0x45dd) ordered = (await integer(reader, part, 0n)) !== 0n;
|
|
124
|
+
if (part.id === 0xb6) atoms.push(part);
|
|
125
|
+
}
|
|
126
|
+
const id = uid === null ? `matroska:edition-at:${field.at}` : `matroska:edition:${uid}`;
|
|
127
|
+
const edition = { id, uid: uid === null ? null : String(uid), hidden, isDefault, ordered, chapters: [] };
|
|
128
|
+
for (const part of atoms) edition.chapters.push(await atom(reader, part, id, null, count, 2));
|
|
129
|
+
if (!edition.chapters.length) throw new Error("EditionEntry has no ChapterAtom");
|
|
130
|
+
editions.push(edition);
|
|
131
|
+
}
|
|
132
|
+
if (!editions.length) throw new Error("Chapters has no EditionEntry");
|
|
133
|
+
const ids = new Set();
|
|
134
|
+
const check = (chapters) => {
|
|
135
|
+
for (const chapter of chapters) {
|
|
136
|
+
if (ids.has(chapter.uid)) throw new Error("Duplicate ChapterUID");
|
|
137
|
+
ids.add(chapter.uid); check(chapter.children);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
for (const edition of editions) check(edition.chapters);
|
|
141
|
+
return editions;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function readMatroskaChapters(container, held) {
|
|
145
|
+
const reader = new ChapterBytes(held, container.fileSize);
|
|
146
|
+
let at = 0, segment = null;
|
|
147
|
+
while (at < reader.size) {
|
|
148
|
+
const field = await header(reader, at, reader.size);
|
|
149
|
+
if (field.id === SEGMENT) { segment = field; break; }
|
|
150
|
+
if (field.end === null) throw new Error("Unknown-sized element before Segment");
|
|
151
|
+
at = field.end;
|
|
152
|
+
}
|
|
153
|
+
if (!segment) throw new Error("Matroska Segment is missing");
|
|
154
|
+
const limit = segment.end ?? reader.size;
|
|
155
|
+
const visits = new Set();
|
|
156
|
+
const seekHeads = [];
|
|
157
|
+
const seek = async (field) => {
|
|
158
|
+
if (visits.has(field.at)) return null;
|
|
159
|
+
visits.add(field.at);
|
|
160
|
+
for await (const entry of children(reader, field)) {
|
|
161
|
+
if (entry.id !== 0x4dbb) continue;
|
|
162
|
+
let id = null, position = null;
|
|
163
|
+
for await (const part of children(reader, entry)) {
|
|
164
|
+
if (part.id === 0x53ab) id = await integer(reader, part);
|
|
165
|
+
if (part.id === 0x53ac) position = await integer(reader, part, 0n);
|
|
166
|
+
}
|
|
167
|
+
if (id === null || position === null) throw new Error("Incomplete chapter Seek entry");
|
|
168
|
+
if (id !== BigInt(CHAPTERS) && id !== BigInt(SEEK_HEAD)) continue;
|
|
169
|
+
const offset = segment.dataOffset + Number(position);
|
|
170
|
+
if (!Number.isSafeInteger(offset) || offset < segment.dataOffset || offset >= limit) throw new Error("Chapter SeekPosition is outside Segment");
|
|
171
|
+
reader.passiveMissing = false;
|
|
172
|
+
const target = await header(reader, offset, limit);
|
|
173
|
+
if (BigInt(target.id) !== id) throw new Error("Chapter SeekID does not match its target");
|
|
174
|
+
if (target.id === CHAPTERS) return target;
|
|
175
|
+
seekHeads.push(target);
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
};
|
|
179
|
+
let chapters = null;
|
|
180
|
+
let passedCluster = false;
|
|
181
|
+
at = segment.dataOffset;
|
|
182
|
+
while (at < limit && !chapters) {
|
|
183
|
+
// Without a declared location, traversing unavailable media must not
|
|
184
|
+
// download every Cluster just to discover whether chapters follow it.
|
|
185
|
+
reader.passiveMissing = passedCluster;
|
|
186
|
+
const field = await header(reader, at, limit);
|
|
187
|
+
reader.passiveMissing = false;
|
|
188
|
+
if (field.id === CHAPTERS) { chapters = field; break; }
|
|
189
|
+
if (field.id === CLUSTER) passedCluster = true;
|
|
190
|
+
if (field.id === SEEK_HEAD) {
|
|
191
|
+
chapters = await seek(field);
|
|
192
|
+
while (!chapters && seekHeads.length) chapters = await seek(seekHeads.shift());
|
|
193
|
+
if (chapters) break;
|
|
194
|
+
}
|
|
195
|
+
if (field.end !== null) { at = field.end; continue; }
|
|
196
|
+
if (field.id !== CLUSTER) throw new Error("Unknown-sized non-Cluster element inside Segment");
|
|
197
|
+
// An unknown-sized Cluster ends at the next Segment-level element (RFC 8794 §6.2).
|
|
198
|
+
at = field.dataOffset;
|
|
199
|
+
reader.passiveMissing = true;
|
|
200
|
+
while (at < limit) {
|
|
201
|
+
const child = await header(reader, at, limit);
|
|
202
|
+
if (TOP_LEVEL.has(child.id)) break;
|
|
203
|
+
if (child.end === null) throw new ChapterUnsupported("unknown-sized-cluster-child");
|
|
204
|
+
at = child.end;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (!chapters) return chapterResult("absent");
|
|
208
|
+
const editions = await editionsOf(reader, chapters);
|
|
209
|
+
const selected = editions.find((edition) => edition.isDefault) ?? editions[0];
|
|
210
|
+
if (selected.ordered) return chapterResult("unsupported", { reason: "ordered-edition-playback", editions, selectedEditionId: selected.id });
|
|
211
|
+
return chapterResult("available", { editions, selectedEditionId: selected.id,
|
|
212
|
+
sections: navigationSections(selected, container.mediaInfo?.durationSeconds ?? null) });
|
|
213
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/** QuickTime Chapter Lists and ISO/IEC 14496-12 sample tables/edit lists. */
|
|
2
|
+
import { isUnavailable } from "./unavailable.js";
|
|
3
|
+
import { ChapterBytes, ChapterUnsupported, CHAPTER_LIMITS, chapterResult, chapterSeconds, navigationSections } from "./chapters.js";
|
|
4
|
+
|
|
5
|
+
async function box(reader, at, end) {
|
|
6
|
+
reader.step();
|
|
7
|
+
const bytes = await reader.bytes(at, 8);
|
|
8
|
+
let size = bytes.readUInt32BE(0), header = 8;
|
|
9
|
+
if (size === 1) { size = Number((await reader.bytes(at + 8, 8)).readBigUInt64BE()); header = 16; }
|
|
10
|
+
if (size === 0) size = end - at;
|
|
11
|
+
if (!Number.isSafeInteger(size) || size < header || at + size > end) throw new Error("Chapter box exceeds its parent");
|
|
12
|
+
return { type: bytes.toString("latin1", 4, 8), at, dataOffset: at + header, end: at + size };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function boxes(reader, start, end) {
|
|
16
|
+
const result = [];
|
|
17
|
+
for (let at = start; at < end;) {
|
|
18
|
+
if (end - at < 8) throw new Error("Truncated chapter box header");
|
|
19
|
+
const child = await box(reader, at, end);
|
|
20
|
+
result.push(child);
|
|
21
|
+
at = child.end;
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const one = (list, type) => list.find((field) => field.type === type);
|
|
27
|
+
async function inside(reader, parent) { return parent ? boxes(reader, parent.dataOffset, parent.end) : []; }
|
|
28
|
+
async function payload(reader, field, count) {
|
|
29
|
+
if (!field || field.dataOffset + count > field.end) throw new Error("Truncated chapter field");
|
|
30
|
+
return reader.bytes(field.dataOffset, count);
|
|
31
|
+
}
|
|
32
|
+
async function table(reader, field, width, prefix = 8) {
|
|
33
|
+
const head = await payload(reader, field, prefix);
|
|
34
|
+
const count = head.readUInt32BE(prefix - 4);
|
|
35
|
+
if (count > CHAPTER_LIMITS.entries) throw new ChapterUnsupported("chapter-sample-table-budget");
|
|
36
|
+
if (field.dataOffset + prefix + width * count !== field.end) throw new Error("Chapter sample table size mismatch");
|
|
37
|
+
return { head, count, bytes: await reader.bytes(field.dataOffset + prefix, count * width) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function titleOf(bytes) {
|
|
41
|
+
if (bytes.length > CHAPTER_LIMITS.textBytes) throw new ChapterUnsupported("chapter-title-budget");
|
|
42
|
+
if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder("utf-16be", { fatal: true }).decode(bytes.subarray(2));
|
|
43
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder("utf-16le", { fatal: true }).decode(bytes.subarray(2));
|
|
44
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/\0+$/, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const MAC_LANGUAGES = new Map([[0, "eng"], [1, "fra"], [2, "deu"], [3, "ita"],
|
|
48
|
+
[4, "nld"], [5, "swe"], [6, "spa"], [7, "dan"], [8, "por"], [9, "nor"], [13, "fin"], [32, "rus"]]);
|
|
49
|
+
|
|
50
|
+
function trackTitle(bytes, format, languageCode) {
|
|
51
|
+
if (bytes.length > CHAPTER_LIMITS.textBytes) throw new ChapterUnsupported("chapter-title-budget");
|
|
52
|
+
const bom = bytes.length >= 2 && ((bytes[0] === 0xfe && bytes[1] === 0xff) || (bytes[0] === 0xff && bytes[1] === 0xfe));
|
|
53
|
+
if (format === "tx3g" || bom || (languageCode >= 0x400 && languageCode !== 0x7fff)) return titleOf(bytes);
|
|
54
|
+
if (!MAC_LANGUAGES.has(languageCode)) throw new ChapterUnsupported(`chapter-text-encoding:${languageCode}`);
|
|
55
|
+
return new TextDecoder(languageCode === 32 ? "x-mac-cyrillic" : "macintosh", { fatal: true }).decode(bytes).replace(/\0+$/, "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function chapter(id, start, end, title, language = "und", order = 0) {
|
|
59
|
+
return { id, parentId: null, order, startSeconds: start, endSeconds: end,
|
|
60
|
+
titles: [{ text: title, languages: [language], countries: [], languageSource: "mp4" }], hidden: false, enabled: true, children: [] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function nero(reader, field) {
|
|
64
|
+
const initial = await payload(reader, field, 4);
|
|
65
|
+
const version = initial[0];
|
|
66
|
+
if (version > 1) throw new ChapterUnsupported("chpl-version");
|
|
67
|
+
const prefix = version === 1 ? 9 : 5;
|
|
68
|
+
const count = (await payload(reader, field, prefix))[prefix - 1];
|
|
69
|
+
let at = field.dataOffset + prefix;
|
|
70
|
+
const chapters = [];
|
|
71
|
+
for (let index = 0; index < count; index++) {
|
|
72
|
+
if (at + 9 > field.end) throw new Error("Truncated chpl entry");
|
|
73
|
+
const head = await reader.bytes(at, 9);
|
|
74
|
+
const time = head.readBigUInt64BE(0), length = head[8];
|
|
75
|
+
at += 9;
|
|
76
|
+
if (at + length > field.end) throw new Error("Truncated chpl title");
|
|
77
|
+
chapters.push({ ...chapter(`mp4:chpl:${index}`, chapterSeconds(time, 10000000), null,
|
|
78
|
+
titleOf(await reader.bytes(at, length)), "und", index), timeTicks: String(time), timeScale: 10000000 });
|
|
79
|
+
at += length;
|
|
80
|
+
}
|
|
81
|
+
if (at !== field.end) throw new Error("Unexpected bytes after chpl entries");
|
|
82
|
+
return { id: "mp4:chpl", source: "chpl", ordered: false, chapters };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function timedHeader(reader, field) {
|
|
86
|
+
const first = await payload(reader, field, 4);
|
|
87
|
+
if (first[0] > 1) throw new ChapterUnsupported("mp4-time-header-version");
|
|
88
|
+
const wide = first[0] === 1;
|
|
89
|
+
const data = await payload(reader, field, wide ? 32 : 20);
|
|
90
|
+
const scale = data.readUInt32BE(wide ? 20 : 12);
|
|
91
|
+
const duration = wide ? data.readBigUInt64BE(24) : BigInt(data.readUInt32BE(16));
|
|
92
|
+
if (scale === 0) throw new Error("Zero chapter time scale");
|
|
93
|
+
return { scale, duration, wide };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function trackInfo(reader, field) {
|
|
97
|
+
const parts = await inside(reader, field);
|
|
98
|
+
const tkhd = one(parts, "tkhd");
|
|
99
|
+
const first = await payload(reader, tkhd, 4);
|
|
100
|
+
if (first[0] > 1) throw new ChapterUnsupported("tkhd-version");
|
|
101
|
+
const data = await payload(reader, tkhd, first[0] === 1 ? 24 : 16);
|
|
102
|
+
const id = data.readUInt32BE(first[0] === 1 ? 20 : 12);
|
|
103
|
+
const enabled = (first.readUIntBE(1, 3) & 1) !== 0;
|
|
104
|
+
const references = await inside(reader, one(parts, "tref"));
|
|
105
|
+
const refs = [];
|
|
106
|
+
for (const ref of references.filter((item) => item.type === "chap")) {
|
|
107
|
+
const size = ref.end - ref.dataOffset;
|
|
108
|
+
if (size % 4 || size / 4 > CHAPTER_LIMITS.entries) throw new Error("Invalid chap references");
|
|
109
|
+
const values = await payload(reader, ref, size);
|
|
110
|
+
for (let at = 0; at < size; at += 4) refs.push(values.readUInt32BE(at));
|
|
111
|
+
}
|
|
112
|
+
return { id, enabled, refs, parts };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function sizesOf(reader, fields) {
|
|
116
|
+
const stsz = one(fields, "stsz");
|
|
117
|
+
if (stsz) {
|
|
118
|
+
const head = await payload(reader, stsz, 12);
|
|
119
|
+
const uniform = head.readUInt32BE(4), count = head.readUInt32BE(8);
|
|
120
|
+
if (count > CHAPTER_LIMITS.entries) throw new ChapterUnsupported("chapter-sample-count");
|
|
121
|
+
if (stsz.dataOffset + 12 + (uniform ? 0 : 4 * count) !== stsz.end) throw new Error("Invalid chapter stsz");
|
|
122
|
+
const values = uniform ? null : await reader.bytes(stsz.dataOffset + 12, count * 4);
|
|
123
|
+
return Array.from({ length: count }, (_, index) => uniform || values.readUInt32BE(index * 4));
|
|
124
|
+
}
|
|
125
|
+
const compact = one(fields, "stz2");
|
|
126
|
+
const head = await payload(reader, compact, 12);
|
|
127
|
+
const width = head[7], count = head.readUInt32BE(8);
|
|
128
|
+
if (![4, 8, 16].includes(width)) throw new Error("Invalid stz2 field size");
|
|
129
|
+
if (count > CHAPTER_LIMITS.entries) throw new ChapterUnsupported("chapter-sample-count");
|
|
130
|
+
const length = Math.ceil(width * count / 8);
|
|
131
|
+
if (compact.dataOffset + 12 + length !== compact.end) throw new Error("Invalid chapter stz2");
|
|
132
|
+
const values = await reader.bytes(compact.dataOffset + 12, length);
|
|
133
|
+
return Array.from({ length: count }, (_, index) => width === 16 ? values.readUInt16BE(index * 2)
|
|
134
|
+
: width === 8 ? values[index] : index % 2 ? values[index >> 1] & 15 : values[index >> 1] >> 4);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function editMap(reader, parts, movieScale, trackScale, trackDuration) {
|
|
138
|
+
const edts = await inside(reader, one(parts, "edts"));
|
|
139
|
+
const elst = one(edts, "elst");
|
|
140
|
+
if (!elst) return [{ media: 0, movie: 0, duration: chapterSeconds(trackDuration, trackScale), index: 0 }];
|
|
141
|
+
const version = (await payload(reader, elst, 4))[0];
|
|
142
|
+
if (version > 1) throw new ChapterUnsupported("chapter-edit-version");
|
|
143
|
+
const width = version === 1 ? 20 : 12;
|
|
144
|
+
const entries = await table(reader, elst, width);
|
|
145
|
+
let movie = 0;
|
|
146
|
+
const result = [];
|
|
147
|
+
for (let index = 0; index < entries.count; index++) {
|
|
148
|
+
const at = index * width;
|
|
149
|
+
const duration = chapterSeconds(version === 1 ? entries.bytes.readBigUInt64BE(at) : BigInt(entries.bytes.readUInt32BE(at)), movieScale);
|
|
150
|
+
const media = version === 1 ? entries.bytes.readBigInt64BE(at + 8) : BigInt(entries.bytes.readInt32BE(at + 4));
|
|
151
|
+
const rate = version === 1 ? at + 16 : at + 8;
|
|
152
|
+
if (entries.bytes.readInt16BE(rate) !== 1 || entries.bytes.readInt16BE(rate + 2) !== 0) throw new ChapterUnsupported("chapter-edit-rate");
|
|
153
|
+
if (media < -1n) throw new Error("Invalid chapter edit media time");
|
|
154
|
+
if (media !== -1n) result.push({ media: chapterSeconds(media, trackScale), movie, duration, index });
|
|
155
|
+
movie += duration;
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function quicktime(reader, track, movieScale) {
|
|
161
|
+
const mdia = await inside(reader, one(track.parts, "mdia"));
|
|
162
|
+
const timing = await timedHeader(reader, one(mdia, "mdhd"));
|
|
163
|
+
const mdhd = await payload(reader, one(mdia, "mdhd"), timing.wide ? 34 : 22);
|
|
164
|
+
const lang = mdhd.readUInt16BE(timing.wide ? 32 : 20);
|
|
165
|
+
let language = lang ? String.fromCharCode(((lang >> 10) & 31) + 96, ((lang >> 5) & 31) + 96, (lang & 31) + 96) : "und";
|
|
166
|
+
if (lang < 0x400) language = MAC_LANGUAGES.get(lang) ?? "und";
|
|
167
|
+
if (!/^[a-z]{3}$/.test(language)) language = "und";
|
|
168
|
+
const elng = one(mdia, "elng");
|
|
169
|
+
if (elng) {
|
|
170
|
+
await payload(reader, elng, 4);
|
|
171
|
+
language = titleOf(await reader.bytes(elng.dataOffset + 4, elng.end - elng.dataOffset - 4)) || language;
|
|
172
|
+
}
|
|
173
|
+
const minf = await inside(reader, one(mdia, "minf"));
|
|
174
|
+
const dinf = await inside(reader, one(minf, "dinf"));
|
|
175
|
+
const dref = one(dinf, "dref");
|
|
176
|
+
const dataRefs = dref ? await boxes(reader, dref.dataOffset + 8, dref.end) : [];
|
|
177
|
+
if (dref && (await payload(reader, dref, 8)).readUInt32BE(4) !== dataRefs.length) throw new Error("Chapter data reference count mismatch");
|
|
178
|
+
const fields = await inside(reader, one(minf, "stbl"));
|
|
179
|
+
const stsd = one(fields, "stsd");
|
|
180
|
+
if (!stsd) throw new Error("Chapter track has no sample descriptions");
|
|
181
|
+
const descriptions = await boxes(reader, stsd.dataOffset + 8, stsd.end);
|
|
182
|
+
if ((await payload(reader, stsd, 8)).readUInt32BE(4) !== descriptions.length) throw new Error("Chapter sample description count mismatch");
|
|
183
|
+
if (!descriptions.length) throw new Error("Chapter track has no sample description");
|
|
184
|
+
for (const description of descriptions) {
|
|
185
|
+
if (!["text", "tx3g"].includes(description.type)) throw new ChapterUnsupported(`chapter-sample-format:${description.type}`);
|
|
186
|
+
const reference = (await payload(reader, description, 8)).readUInt16BE(6);
|
|
187
|
+
const source = dataRefs[reference - 1];
|
|
188
|
+
if (!source || !["url ", "alis"].includes(source.type) || ((await payload(reader, source, 4)).readUIntBE(1, 3) & 1) === 0) {
|
|
189
|
+
throw new ChapterUnsupported("external-chapter-data-reference");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const sizes = await sizesOf(reader, fields);
|
|
193
|
+
const durations = await table(reader, one(fields, "stts"), 8);
|
|
194
|
+
const times = [];
|
|
195
|
+
let ticks = 0n;
|
|
196
|
+
for (let index = 0; index < durations.count; index++) {
|
|
197
|
+
const count = durations.bytes.readUInt32BE(index * 8), delta = BigInt(durations.bytes.readUInt32BE(index * 8 + 4));
|
|
198
|
+
if (times.length + count > sizes.length) throw new Error("stts exceeds chapter sample count");
|
|
199
|
+
for (let sample = 0; sample < count; sample++) { times.push({ start: ticks, end: ticks + delta }); ticks += delta; }
|
|
200
|
+
}
|
|
201
|
+
if (times.length !== sizes.length) throw new Error("stts does not cover chapter samples");
|
|
202
|
+
const ctts = one(fields, "ctts");
|
|
203
|
+
if (ctts) {
|
|
204
|
+
const offsets = await table(reader, ctts, 8);
|
|
205
|
+
if (offsets.head[0] > 1) throw new ChapterUnsupported("chapter-ctts-version");
|
|
206
|
+
let sample = 0;
|
|
207
|
+
for (let index = 0; index < offsets.count; index++) {
|
|
208
|
+
const count = offsets.bytes.readUInt32BE(index * 8);
|
|
209
|
+
const offset = BigInt(offsets.head[0] === 1 ? offsets.bytes.readInt32BE(index * 8 + 4) : offsets.bytes.readUInt32BE(index * 8 + 4));
|
|
210
|
+
if (sample + count > times.length) throw new Error("ctts exceeds chapter samples");
|
|
211
|
+
for (let n = 0; n < count; n++, sample++) { times[sample].start += offset; times[sample].end += offset; }
|
|
212
|
+
}
|
|
213
|
+
if (sample !== times.length) throw new Error("ctts does not cover chapter samples");
|
|
214
|
+
}
|
|
215
|
+
const chunksField = one(fields, "stco") ?? one(fields, "co64");
|
|
216
|
+
const wide = chunksField?.type === "co64";
|
|
217
|
+
const chunks = await table(reader, chunksField, wide ? 8 : 4);
|
|
218
|
+
const runs = await table(reader, one(fields, "stsc"), 12);
|
|
219
|
+
const locations = [];
|
|
220
|
+
const formats = [];
|
|
221
|
+
let run = 0, sample = 0;
|
|
222
|
+
const runAt = (index) => ({ first: runs.bytes.readUInt32BE(index * 12), count: runs.bytes.readUInt32BE(index * 12 + 4), description: runs.bytes.readUInt32BE(index * 12 + 8) });
|
|
223
|
+
if (chunks.count && (!runs.count || runAt(0).first !== 1)) throw new Error("Invalid first chapter chunk mapping");
|
|
224
|
+
for (let index = 0; index < runs.count; index++) {
|
|
225
|
+
const current = runAt(index);
|
|
226
|
+
if (!current.count || current.description < 1 || current.description > descriptions.length || current.first > chunks.count || (index && current.first <= runAt(index - 1).first)) throw new Error("Invalid chapter chunk mapping");
|
|
227
|
+
}
|
|
228
|
+
for (let index = 0; index < chunks.count; index++) {
|
|
229
|
+
if (run + 1 < runs.count && runAt(run + 1).first === index + 1) run++;
|
|
230
|
+
let at = wide ? Number(chunks.bytes.readBigUInt64BE(index * 8)) : chunks.bytes.readUInt32BE(index * 4);
|
|
231
|
+
for (let n = 0; n < runAt(run).count; n++, sample++) {
|
|
232
|
+
if (sample >= sizes.length || !Number.isSafeInteger(at) || at < 0 || at + sizes[sample] > reader.size) throw new Error("Chapter sample outside file or count");
|
|
233
|
+
locations.push(at); formats.push(descriptions[runAt(run).description - 1].type); at += sizes[sample];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (locations.length !== sizes.length) throw new Error("Chunk table does not cover chapter samples");
|
|
237
|
+
const edits = await editMap(reader, track.parts, movieScale, timing.scale, timing.duration);
|
|
238
|
+
const chapters = [];
|
|
239
|
+
for (let index = 0; index < sizes.length; index++) {
|
|
240
|
+
if (sizes[index] < 2 || sizes[index] > CHAPTER_LIMITS.textBytes + 65536) throw new ChapterUnsupported("chapter-text-sample-size");
|
|
241
|
+
const head = await reader.bytes(locations[index], 2);
|
|
242
|
+
const length = head.readUInt16BE();
|
|
243
|
+
if (length + 2 > sizes[index]) throw new Error("Chapter text exceeds sample");
|
|
244
|
+
const title = trackTitle(await reader.bytes(locations[index] + 2, length), formats[index], lang);
|
|
245
|
+
const start = chapterSeconds(times[index].start, timing.scale), end = chapterSeconds(times[index].end, timing.scale);
|
|
246
|
+
for (const edit of edits) {
|
|
247
|
+
if (end <= edit.media || start >= edit.media + edit.duration) continue;
|
|
248
|
+
chapters.push(chapter(`mp4:track:${track.id}:sample:${index}:edit:${edit.index}`,
|
|
249
|
+
edit.movie + Math.max(start - edit.media, 0), edit.movie + Math.min(end - edit.media, edit.duration), title, language, chapters.length));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { id: `mp4:track:${track.id}`, source: "quicktime", ordered: false, chapters };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function readMp4Chapters(container, held) {
|
|
256
|
+
const cached = container.moovHeld;
|
|
257
|
+
const originalRead = held.read;
|
|
258
|
+
const ranges = held.ranges ? [...held.ranges] : undefined;
|
|
259
|
+
if (cached?.offset !== undefined) ranges?.push([cached.offset, cached.offset + cached.moov.length - 1]);
|
|
260
|
+
const reader = new ChapterBytes({ ...held, ranges, read: (start, end) => {
|
|
261
|
+
if (cached?.offset !== undefined && start >= cached.offset && end < cached.offset + cached.moov.length) return Promise.resolve(cached.moov.subarray(start - cached.offset, end - cached.offset + 1));
|
|
262
|
+
return originalRead(start, end);
|
|
263
|
+
} }, container.fileSize);
|
|
264
|
+
let moov = null;
|
|
265
|
+
for (let at = 0; at < reader.size;) {
|
|
266
|
+
const field = await box(reader, at, reader.size);
|
|
267
|
+
if (field.type === "moov") { moov = field; break; }
|
|
268
|
+
at = field.end;
|
|
269
|
+
}
|
|
270
|
+
if (!moov) return chapterResult("absent");
|
|
271
|
+
const parts = await inside(reader, moov);
|
|
272
|
+
const udta = await inside(reader, one(parts, "udta"));
|
|
273
|
+
const chpl = one(udta, "chpl");
|
|
274
|
+
const editions = [];
|
|
275
|
+
if (chpl) editions.push(await nero(reader, chpl));
|
|
276
|
+
let pending = null;
|
|
277
|
+
try {
|
|
278
|
+
const tracks = [];
|
|
279
|
+
for (const field of parts.filter((part) => part.type === "trak")) tracks.push(await trackInfo(reader, field));
|
|
280
|
+
const referrer = tracks.find((track) => track.enabled && track.refs.length);
|
|
281
|
+
if (referrer) {
|
|
282
|
+
const track = tracks.find((item) => item.id === referrer.refs[0]);
|
|
283
|
+
if (!track) throw new Error("Referenced chapter track is missing");
|
|
284
|
+
const extensions = await inside(reader, one(parts, "mvex"));
|
|
285
|
+
for (const defaults of extensions.filter((item) => item.type === "trex")) {
|
|
286
|
+
if ((await payload(reader, defaults, 8)).readUInt32BE(4) === track.id) {
|
|
287
|
+
// Fragment samples are not described by the initial sample tables.
|
|
288
|
+
throw new ChapterUnsupported("chapter-fragmented-track");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const timing = await timedHeader(reader, one(parts, "mvhd"));
|
|
292
|
+
editions.unshift(await quicktime(reader, track, timing.scale));
|
|
293
|
+
}
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (!editions.length) throw error;
|
|
296
|
+
if (isUnavailable(error)) pending = { state: "pending", start: error.start, end: error.end };
|
|
297
|
+
else throw error;
|
|
298
|
+
}
|
|
299
|
+
const selected = editions[0];
|
|
300
|
+
if (!selected || !selected.chapters.length) return chapterResult(pending ? "pending" : "absent", { editions, pending,
|
|
301
|
+
selectedEditionId: selected?.id ?? null });
|
|
302
|
+
let duration = container.mediaInfo?.durationSeconds ?? null;
|
|
303
|
+
if (duration === null && one(parts, "mvhd")) {
|
|
304
|
+
const timing = await timedHeader(reader, one(parts, "mvhd"));
|
|
305
|
+
if (timing.duration !== (timing.wide ? 0xffffffffffffffffn : 0xffffffffn)) duration = chapterSeconds(timing.duration, timing.scale);
|
|
306
|
+
}
|
|
307
|
+
return chapterResult("available", { editions, selectedEditionId: selected.id, complete: pending === null,
|
|
308
|
+
pending, representationsDisagree: editions.length > 1 && JSON.stringify(editions[0].chapters.map((item) => [item.startSeconds, item.titles[0].text])) !== JSON.stringify(editions[1].chapters.map((item) => [item.startSeconds, item.titles[0].text])),
|
|
309
|
+
sections: navigationSections(selected, duration) });
|
|
310
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** HTTP validation without torrent startup or media parsing. */
|
|
2
|
+
export class ChapterController {
|
|
3
|
+
constructor({ sourceRegistry, chapters }) { this.sourceRegistry = sourceRegistry; this.chapters = chapters; }
|
|
4
|
+
get({ sourceKey, fileIndex }) {
|
|
5
|
+
if (!this.sourceRegistry.get(sourceKey)) return { status: 404, body: { code: "SOURCE_NOT_FOUND", error: "Source key was not found." } };
|
|
6
|
+
if (!Number.isSafeInteger(fileIndex) || fileIndex < 0) return { status: 400, body: { error: "fileIndex must be a non-negative integer." } };
|
|
7
|
+
return { status: 200, body: { ...this.chapters.get(sourceKey, fileIndex), capability: "supported" } };
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -177,6 +177,7 @@ function buildHttpBaseUrl(host, port) {
|
|
|
177
177
|
* @returns {object} The components, and the settings they read.
|
|
178
178
|
*/
|
|
179
179
|
export function wireOutputs({
|
|
180
|
+
observeWait = null,
|
|
180
181
|
enabled,
|
|
181
182
|
ffmpegBin,
|
|
182
183
|
localBindHost,
|
|
@@ -363,6 +364,7 @@ export function wireOutputs({
|
|
|
363
364
|
get segmentDurationSec() { return parts.segmentDurationSec; },
|
|
364
365
|
readsThroughCache: () => parts.inputCacheAvailable === true,
|
|
365
366
|
segmentFiles,
|
|
367
|
+
observeWait,
|
|
366
368
|
get videoEncoder() { return parts.encoders.current; },
|
|
367
369
|
get encoders() { return parts.encoders; },
|
|
368
370
|
invalidateWaits: (output) => parts.serving.invalidateWaits(output),
|
|
@@ -31,12 +31,14 @@ export class WorkerTorrentPool {
|
|
|
31
31
|
/** Stand-ins by source key, so repeat calls return the same object. */
|
|
32
32
|
#torrents = new Map();
|
|
33
33
|
#pending = new Map();
|
|
34
|
+
#observed;
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* @param {{ memoryBytes?: number, stateDir?: string }} [options]
|
|
37
38
|
* @param {object | null} [client] - The worker interface; tests supply a fake.
|
|
38
39
|
*/
|
|
39
40
|
constructor(options = {}, client = null) {
|
|
41
|
+
this.#observed = options.onObserved;
|
|
40
42
|
this.#client = client ?? new TorrentWorkerClient({ ...options, onSourceForgotten: event => {
|
|
41
43
|
this.forget(event.sourceKey);
|
|
42
44
|
options.onSourceForgotten?.(event);
|
|
@@ -51,13 +53,16 @@ export class WorkerTorrentPool {
|
|
|
51
53
|
* @returns {Promise<object>}
|
|
52
54
|
*/
|
|
53
55
|
async getTorrent(sourceType, source) {
|
|
56
|
+
const startedAt = performance.now();
|
|
54
57
|
const sourceKey = await deriveSourceKey(sourceType, source);
|
|
58
|
+
const observed = outcome => this.#observed?.({ sourceKey, startedAt, endedAt: performance.now(), outcome });
|
|
55
59
|
const existing = this.#torrents.get(sourceKey);
|
|
56
60
|
if (existing) {
|
|
61
|
+
observed("cached");
|
|
57
62
|
return existing;
|
|
58
63
|
}
|
|
59
64
|
const joined = this.#pending.get(sourceKey);
|
|
60
|
-
if (joined) return joined;
|
|
65
|
+
if (joined) return joined.finally(() => observed("joined"));
|
|
61
66
|
const pending = this.#client.getTorrent({ sourceKey, sourceType, source }).then(torrent => {
|
|
62
67
|
if (this.#pending.get(sourceKey) !== pending) {
|
|
63
68
|
const error = new Error("Source was forgotten while its metadata was being read.");
|
|
@@ -66,7 +71,10 @@ export class WorkerTorrentPool {
|
|
|
66
71
|
}
|
|
67
72
|
this.#torrents.set(sourceKey, torrent);
|
|
68
73
|
return torrent;
|
|
69
|
-
}).finally(() => {
|
|
74
|
+
}).finally(() => {
|
|
75
|
+
observed(this.#torrents.has(sourceKey) ? "ready" : "error");
|
|
76
|
+
if (this.#pending.get(sourceKey) === pending) this.#pending.delete(sourceKey);
|
|
77
|
+
});
|
|
70
78
|
this.#pending.set(sourceKey, pending);
|
|
71
79
|
return pending;
|
|
72
80
|
}
|
|
@@ -713,6 +713,17 @@ export function createDataChannelHandler({
|
|
|
713
713
|
* @type {Map<string, Map<string, DataChannel | null>>}
|
|
714
714
|
*/
|
|
715
715
|
const viewersOnConnection = new Map();
|
|
716
|
+
// One selected chapter file per channel; removed with the channel.
|
|
717
|
+
const chapterChannels = new Map();
|
|
718
|
+
|
|
719
|
+
function publishChaptersChanged(event) {
|
|
720
|
+
for (const [channel, selected] of chapterChannels) {
|
|
721
|
+
if (selected.sourceKey === event.sourceKey && selected.fileIndex === event.fileIndex) {
|
|
722
|
+
try { send(channel, { type: "chapters-changed", ...event }); }
|
|
723
|
+
catch (error) { log(`[dc] chapter notification failed: ${error.message}`); }
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
716
727
|
|
|
717
728
|
/**
|
|
718
729
|
* The connections a viewer is still named on, oldest first.
|
|
@@ -799,6 +810,7 @@ export function createDataChannelHandler({
|
|
|
799
810
|
* @returns {void}
|
|
800
811
|
*/
|
|
801
812
|
function forgetChannel(channel) {
|
|
813
|
+
chapterChannels.delete(channel);
|
|
802
814
|
for (const viewers of viewersOnConnection.values()) {
|
|
803
815
|
for (const [consumerId, on] of viewers) {
|
|
804
816
|
if (on === channel) {
|
|
@@ -1162,6 +1174,16 @@ export function createDataChannelHandler({
|
|
|
1162
1174
|
return;
|
|
1163
1175
|
}
|
|
1164
1176
|
|
|
1177
|
+
const chapterPath = /^\/api\/sources\/([^/]+)\/chapters$/.exec(path);
|
|
1178
|
+
if (method === "GET" && chapterPath) {
|
|
1179
|
+
const rawIndex = new URLSearchParams(query ?? "").get("fileIndex");
|
|
1180
|
+
const fileIndex = rawIndex !== null && /^\d+$/.test(rawIndex) ? Number(rawIndex) : NaN;
|
|
1181
|
+
if (Number.isSafeInteger(fileIndex) && fileIndex >= 0) {
|
|
1182
|
+
try { chapterChannels.set(channel, { sourceKey: decodeURIComponent(chapterPath[1]), fileIndex }); }
|
|
1183
|
+
catch (error) { log(`[dc] chapter source key cannot be decoded: ${error.message}`); }
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1165
1187
|
|
|
1166
1188
|
const queryInfo = query ? `?${query}` : "";
|
|
1167
1189
|
const bodyInfo =
|
|
@@ -1464,7 +1486,8 @@ export function createDataChannelHandler({
|
|
|
1464
1486
|
handleChannel,
|
|
1465
1487
|
publishSubtitleCues,
|
|
1466
1488
|
/** What the film costs viewers' links beyond itself, per byte of film, or null. */
|
|
1467
|
-
serviceShare: () => deliveryShares.serviceShare()
|
|
1489
|
+
serviceShare: () => deliveryShares.serviceShare(),
|
|
1490
|
+
publishChaptersChanged
|
|
1468
1491
|
};
|
|
1469
1492
|
}
|
|
1470
1493
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Bounded, opt-in startup observations. No torrent or application imports. */
|
|
2
|
+
export class SourceWaitTrace {
|
|
3
|
+
constructor({ emit, now = () => performance.now() }) {
|
|
4
|
+
Object.assign(this, { emit, now, sources: new Map(), requests: new WeakMap() });
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
write(context, event, fields = {}) {
|
|
8
|
+
this.emit({ version: 1, clock: "proxy-main", wait: context.wait, attempt: context.attempt,
|
|
9
|
+
operation: context.operation, event, at: this.now(), ...fields });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
remember(sourceKey, context) {
|
|
13
|
+
if (typeof sourceKey !== "string" || !sourceKey) return;
|
|
14
|
+
for (const [key, value] of this.sources) if (this.now() - value.seen > 60_000) this.sources.delete(key);
|
|
15
|
+
if (this.sources.size >= 64 && !this.sources.has(sourceKey)) this.sources.delete(this.sources.keys().next().value);
|
|
16
|
+
this.sources.set(sourceKey, { ...context, seen: this.now() });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
source(sourceKey, event, fields = {}) {
|
|
20
|
+
const context = this.sources.get(sourceKey);
|
|
21
|
+
if (!context || this.now() - context.seen > 60_000) return;
|
|
22
|
+
this.write({ ...context, operation: null }, event, { sourceKey, ...fields });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
install(app) {
|
|
26
|
+
app.addHook("onRequest", async (req, reply) => {
|
|
27
|
+
const wait = req.headers["x-ttv-wait-trace"];
|
|
28
|
+
const operation = req.headers["x-ttv-wait-operation"];
|
|
29
|
+
const attempt = Number(req.headers["x-ttv-wait-attempt"]);
|
|
30
|
+
if (typeof wait !== "string" || !/^[a-zA-Z0-9.-]{1,80}$/.test(wait) ||
|
|
31
|
+
typeof operation !== "string" || !/^b[0-9]{1,12}$/.test(operation) ||
|
|
32
|
+
!Number.isSafeInteger(attempt) || attempt < 0) return;
|
|
33
|
+
const context = { wait, attempt, operation, startedAt: this.now() };
|
|
34
|
+
this.requests.set(req, context);
|
|
35
|
+
this.write(context, "http-start", { method: req.method, path: req.url,
|
|
36
|
+
requestId: req.headers["x-ttv-forwarded-request-id"] ?? req.id });
|
|
37
|
+
let finished = false;
|
|
38
|
+
const end = outcome => {
|
|
39
|
+
if (finished) return;
|
|
40
|
+
finished = true;
|
|
41
|
+
this.write(context, "http-end", { outcome, status: reply.statusCode, duration: this.now() - context.startedAt });
|
|
42
|
+
};
|
|
43
|
+
reply.raw.once("finish", () => end("finished"));
|
|
44
|
+
reply.raw.once("close", () => end(reply.raw.writableFinished ? "finished" : "closed"));
|
|
45
|
+
});
|
|
46
|
+
app.addHook("preHandler", async req => {
|
|
47
|
+
const context = this.requests.get(req);
|
|
48
|
+
if (!context) return;
|
|
49
|
+
const match = /^\/api\/sources\/([^/?]+)/.exec(req.url);
|
|
50
|
+
const sourceKey = req.body?.sourceKey ?? req.query?.sourceKey ?? (match ? decodeURIComponent(match[1]) : null);
|
|
51
|
+
this.remember(sourceKey, context);
|
|
52
|
+
this.write(context, "http-handler", { sourceKey });
|
|
53
|
+
});
|
|
54
|
+
app.addHook("preSerialization", async (req, _reply, payload) => {
|
|
55
|
+
const context = this.requests.get(req);
|
|
56
|
+
if (context) {
|
|
57
|
+
this.remember(payload?.sourceKey, context);
|
|
58
|
+
this.write(context, "http-serialize");
|
|
59
|
+
}
|
|
60
|
+
return payload;
|
|
61
|
+
});
|
|
62
|
+
app.addHook("onSend", async (req, _reply, payload) => {
|
|
63
|
+
const context = this.requests.get(req);
|
|
64
|
+
if (context) this.write(context, "http-send");
|
|
65
|
+
return payload;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|