@torrent-tv/proxy 2.95.3 → 2.95.4
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/package.json +1 -1
- package/routes/encode-input/get.js +29 -0
- package/server.js +55 -10
- package/services/encode/EncodeInputs.js +8 -2
- package/services/encode/EncodeRuns.js +21 -3
- package/services/encode/OriginalInput.js +59 -0
- package/services/encode/admitted-command.js +8 -2
- package/services/encode/output/Timeline.js +4 -0
- package/services/encode/piece-completeness.js +6 -2
- package/services/encode/run-command.js +5 -5
- package/services/encode/segment-formats/fmp4.js +1 -0
- package/services/encode/segment-formats/mp4-boxes.js +26 -2
- package/services/encode/source-command.js +239 -0
- package/services/media/ContainerOrchestrator.js +7 -1
- package/services/media/container/MatroskaContainer.js +79 -0
- package/services/media/container/ffprobe-record.js +2 -0
- package/services/server/SegmentServing.js +3 -1
- package/services/server/wire-outputs.js +2 -1
- package/services/storage/piece-store/shared-piece-store.js +8 -1
- package/services/torrent/download/FutureDownload.js +98 -150
- package/services/torrent/download/SwarmSelection.js +45 -33
- package/services/torrent/download/bands.js +5 -0
- package/services/torrent/download/registry.js +5 -11
- package/services/viewer/DownloadMaps.js +1 -1
- package/services/viewer/NativeSourceMap.js +25 -0
- package/services/viewer/PriorityMap.js +1 -0
- package/services/viewer/playback-readiness.js +61 -19
- package/services/viewer/segment-demands.js +6 -2
- package/services/torrent/download/DeadlineScheduler.js +0 -195
package/package.json
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { parseRange } from "../../utils/parse-range.js";
|
|
2
|
+
|
|
3
|
+
/** FFmpeg reads only original byte ranges already owned by its live run. */
|
|
4
|
+
export function handleEncodeInputGet(req, reply, { inputOf }) {
|
|
5
|
+
const address = req.raw?.socket?.remoteAddress;
|
|
6
|
+
if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(address)) return reply.code(403).send();
|
|
7
|
+
const token = Number(req.params.token), fileIndex = Number(req.params.fileIndex);
|
|
8
|
+
if (!Number.isSafeInteger(token) || !Number.isSafeInteger(fileIndex) || fileIndex < 0) return reply.code(400).send();
|
|
9
|
+
const input = inputOf(token);
|
|
10
|
+
const length = input?.lengthOf(fileIndex);
|
|
11
|
+
if (!Number.isSafeInteger(length) || length <= 0) return reply.code(404).send();
|
|
12
|
+
reply.header("Accept-Ranges", "bytes");
|
|
13
|
+
reply.header("Content-Type", "application/octet-stream");
|
|
14
|
+
if (req.method === "HEAD") {
|
|
15
|
+
reply.hijack();
|
|
16
|
+
reply.raw.writeHead(200, { "Accept-Ranges": "bytes", "Content-Type": "application/octet-stream", "Content-Length": String(length) });
|
|
17
|
+
reply.raw.end();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const range = parseRange(req.headers.range, length);
|
|
21
|
+
if (!range || range.start >= length) return reply.code(416).header("Content-Range", `bytes */${length}`).send();
|
|
22
|
+
// Older FFmpeg versions request through EOF. A finite partial response keeps
|
|
23
|
+
// each read within the admitted lookahead, without version-specific options.
|
|
24
|
+
range.end = Math.min(range.end, range.start + 1048575);
|
|
25
|
+
const held = input.read(fileIndex, range.start, range.end, true);
|
|
26
|
+
if (!held) return reply.code(503).send({ error: "Original input bytes are outside the admitted ranges." });
|
|
27
|
+
return reply.code(206).header("Content-Length", String(held.bytes.length))
|
|
28
|
+
.header("Content-Range", `bytes ${range.start}-${held.end}/${length}`).send(held.bytes);
|
|
29
|
+
}
|
package/server.js
CHANGED
|
@@ -42,6 +42,7 @@ import { handleApiTranscodeSessionNetReportPost } from "./routes/api/transcode-s
|
|
|
42
42
|
import { handleApiTranscodeSessionFragmentFarPost } from "./routes/api/transcode-sessions/fragment-far/post.js";
|
|
43
43
|
import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessions/seek/post.js";
|
|
44
44
|
import { handleStreamGet } from "./routes/stream/get.js";
|
|
45
|
+
import { handleEncodeInputGet } from "./routes/encode-input/get.js";
|
|
45
46
|
import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
|
|
46
47
|
import { handleTranscodeVariantFileGet } from "./routes/transcode/variant-file/get.js";
|
|
47
48
|
import { handleTranscodeAudioFileGet } from "./routes/transcode/audio-file/get.js";
|
|
@@ -57,7 +58,7 @@ import { SubtitleOrchestrator } from "./services/media/SubtitleOrchestrator.js";
|
|
|
57
58
|
import { containerOrchestrator, CONTAINER_HEAD_BYTES, describeWorkTags } from "./services/media/ContainerOrchestrator.js";
|
|
58
59
|
import { readPlaybackDeclarations } from "./services/media/read-playback-declarations.js";
|
|
59
60
|
import { DownloadMaps } from "./services/viewer/DownloadMaps.js";
|
|
60
|
-
import { nativeSourceMap } from "./services/viewer/NativeSourceMap.js";
|
|
61
|
+
import { nativeSourceMap, nativeOriginalSourceMap } from "./services/viewer/NativeSourceMap.js";
|
|
61
62
|
import { MediaReadRequests } from "./services/media/MediaReadRequests.js";
|
|
62
63
|
import { SegmentInputs } from "./services/media/SegmentInputs.js";
|
|
63
64
|
import { ContainerTrack } from "./services/media/tracks/ContainerTrack.js";
|
|
@@ -417,7 +418,7 @@ export async function startProxyServer({
|
|
|
417
418
|
const tracks = await containerOrchestrator.inspect(params, "tracks");
|
|
418
419
|
if (tracks.kind !== "result" || !map.isCurrent()) return [];
|
|
419
420
|
const container = containerOrchestrator.known(map.sourceKey, map.fileIndex);
|
|
420
|
-
if (typeof container?.readPacketIndex !== "function") return [];
|
|
421
|
+
if (typeof container?.readSourceRanges !== "function" && typeof container?.readPacketIndex !== "function") return [];
|
|
421
422
|
const media = await containerOrchestrator.inspect(params, "media-info");
|
|
422
423
|
if (media.kind !== "result" || !map.isCurrent()) return [];
|
|
423
424
|
const shift = Number(media.value?.startTimeSeconds) || 0;
|
|
@@ -450,7 +451,9 @@ export async function startProxyServer({
|
|
|
450
451
|
demands.sort((left, right) => left.deadlineAt - right.deadlineAt || right.priority - left.priority || left.index - right.index);
|
|
451
452
|
for (const zone of demands) {
|
|
452
453
|
if (!map.isCurrent()) return [];
|
|
453
|
-
const selected = zone.tracks.map(choice => ({ choice, track:
|
|
454
|
+
const selected = zone.tracks.map(choice => ({ choice, track: choice.type === "video"
|
|
455
|
+
? ContainerTrack.firstUsable(tracks.value, "video")
|
|
456
|
+
: tracks.value.filter(track => track.type === choice.type)[choice.index] }));
|
|
454
457
|
if (selected.some(({ track }) => !track)) continue;
|
|
455
458
|
const wanted = selected.map(({ track }) => track);
|
|
456
459
|
const modes = new Map(selected.map(({ choice, track }) => [track, choice.mode]));
|
|
@@ -461,10 +464,15 @@ export async function startProxyServer({
|
|
|
461
464
|
demand: { ...zone.owner, leadSeconds: zone.leadSeconds } });
|
|
462
465
|
if (!map.isCurrent()) return [];
|
|
463
466
|
if (!intervalParams) continue;
|
|
464
|
-
const
|
|
467
|
+
const navigation = await containerOrchestrator.inspect(intervalParams, "source-navigation");
|
|
468
|
+
if (navigation.kind !== "result") continue;
|
|
469
|
+
const originalRanges = navigation.value;
|
|
470
|
+
const packets = originalRanges && !zone.urgent
|
|
471
|
+
? { kind: "result", value: await container.readMappedSourceRanges(interval) }
|
|
472
|
+
: await containerOrchestrator.inspect(intervalParams, originalRanges ? "source-ranges" : "packets");
|
|
465
473
|
if (!map.isCurrent()) return [];
|
|
466
474
|
if (packets.kind !== "result") continue;
|
|
467
|
-
const input = new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
|
|
475
|
+
const input = originalRanges ? packets.value : new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
|
|
468
476
|
if (input.kind !== "result") continue;
|
|
469
477
|
const { tracks: _choices, owner: _owner, sourceInterval: _sourceInterval, ...demand } = zone;
|
|
470
478
|
for (const [byteStart, byteEnd] of input.ranges) converted.push({ ...demand,
|
|
@@ -610,6 +618,16 @@ export async function startProxyServer({
|
|
|
610
618
|
params.packetInterval = { ...sourceInterval,
|
|
611
619
|
trackIds: wanted.map(track => track.trackNumber),
|
|
612
620
|
modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
|
|
621
|
+
const navigation = await containerOrchestrator.inspect(params, "source-navigation");
|
|
622
|
+
if (navigation.kind !== "result") return navigation;
|
|
623
|
+
if (navigation.value && selected.size === 1 && output.segmentFormat.supportsOriginalInput === true) {
|
|
624
|
+
const ranges = await containerOrchestrator.inspect(params, "source-ranges");
|
|
625
|
+
if (ranges.kind !== "result") return ranges;
|
|
626
|
+
sources.push({ sourceKey: params.sourceKey, fileIndex, timeShiftSeconds,
|
|
627
|
+
input: { ...ranges.value, original: true, selections: wanted.map(track => ({ track,
|
|
628
|
+
index: track.declaredIndex ?? choices.find(choice => choice.type === track.type)?.index ?? 0 })) } });
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
613
631
|
const packets = await containerOrchestrator.inspect(params, "packets");
|
|
614
632
|
if (packets.kind !== "result") return packets;
|
|
615
633
|
const input = new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({
|
|
@@ -769,13 +787,15 @@ export async function startProxyServer({
|
|
|
769
787
|
// decides anything, keeps the segments whose closure is proven, and removes
|
|
770
788
|
// the one piece per output that was being written when the process died.
|
|
771
789
|
outputParts.lifecycle.adoptSegmentsLeftBehind();
|
|
772
|
-
const publishNative = work => {
|
|
790
|
+
const publishNative = async work => {
|
|
773
791
|
if (!work.nativeInput || !sourcePreparation.accepts(work)) return Promise.resolve();
|
|
774
792
|
const viewers = outputParts.viewers.forSource(work.sourceKey)
|
|
775
793
|
.filter(viewer => viewer.source.selectedFileIndex === work.fileIndex && viewer.outputs.size === 0);
|
|
776
|
-
const
|
|
794
|
+
const convert = work.nativeInput.container ? nativeOriginalSourceMap : nativeSourceMap;
|
|
795
|
+
const zones = await convert({ ...work.nativeInput, viewers,
|
|
777
796
|
allowanceSeconds: outputParts.segmentDurationSec,
|
|
778
797
|
urgentReadyFor: viewer => sourcePreparation.urgentReadyFor(viewer) });
|
|
798
|
+
if (!sourcePreparation.accepts(work)) return;
|
|
779
799
|
return downloadMaps.native({ sourceKey: work.sourceKey, fileIndex: work.fileIndex, zones });
|
|
780
800
|
};
|
|
781
801
|
sourcePreparation = new SourcePreparation({
|
|
@@ -828,7 +848,12 @@ export async function startProxyServer({
|
|
|
828
848
|
const params = await containerOver(work);
|
|
829
849
|
if (!params) return { kind: "needs-source" };
|
|
830
850
|
params.isCurrent = () => sourcePreparation.accepts(work);
|
|
831
|
-
|
|
851
|
+
const originalContainer = work.statement === "packets" ? await containerOrchestrator.containerFor(params) : null;
|
|
852
|
+
const navigation = work.statement === "packets" && work.role !== "subtitle-embedded"
|
|
853
|
+
? await containerOrchestrator.inspect(params, "source-navigation") : { kind: "result", value: false };
|
|
854
|
+
if (navigation.kind !== "result") return navigation;
|
|
855
|
+
const originalRanges = navigation.value;
|
|
856
|
+
if (!originalRanges && work.statement === "packets" && work.role === "source-rest" && sourcePreparation.accepts(work)) {
|
|
832
857
|
const file = torrentPool.knownTorrent(work.sourceKey)?.files?.[work.fileIndex];
|
|
833
858
|
if (file?.length > 0) {
|
|
834
859
|
const statement = `${work.statement}:${work.requestId}:input`;
|
|
@@ -837,7 +862,7 @@ export async function startProxyServer({
|
|
|
837
862
|
scope: "preparation", ...sourcePreparation.demandFor(work) });
|
|
838
863
|
}
|
|
839
864
|
}
|
|
840
|
-
if ((work.statement === "packets" && work.role !== "source-rest") || work.role === "subtitle-embedded" && work.statement === "subtitle-cues") {
|
|
865
|
+
if ((work.statement === "packets" && (work.role !== "source-rest" || originalRanges)) || work.role === "subtitle-embedded" && work.statement === "subtitle-cues") {
|
|
841
866
|
const info = await containerOrchestrator.inspect({ ...params, onReadResult: undefined }, "media-info");
|
|
842
867
|
if (info.kind !== "result") return info;
|
|
843
868
|
const positions = outputParts.viewers.forSource(work.sourceKey)
|
|
@@ -858,6 +883,23 @@ export async function startProxyServer({
|
|
|
858
883
|
if (!sourcePreparation.accepts(work)) return;
|
|
859
884
|
mediaReads.record(params, statement, result, revision.storage, revision.memory);
|
|
860
885
|
const inputStatement = `${work.statement}:${work.requestId}:input`;
|
|
886
|
+
if (statement === "source-ranges" && result.kind === "result") {
|
|
887
|
+
if (work.selected) work.inputRanges = result.value.ranges;
|
|
888
|
+
if (work.role === "source-rest") {
|
|
889
|
+
const info = await containerOrchestrator.inspect({ ...params, onReadResult: undefined }, "media-info");
|
|
890
|
+
if (!sourcePreparation.accepts(work)) return;
|
|
891
|
+
if (info.kind === "result") {
|
|
892
|
+
work.nativeInput = { container: originalContainer, durationSeconds: info.value.durationSeconds,
|
|
893
|
+
startTimeSeconds: info.value.startTimeSeconds ?? 0 };
|
|
894
|
+
await publishNative(work);
|
|
895
|
+
}
|
|
896
|
+
} else {
|
|
897
|
+
await downloadMaps.metadata({ sourceKey: work.sourceKey, fileIndex: work.fileIndex,
|
|
898
|
+
statement: inputStatement, result: { kind: "needs-ranges", ranges: result.value.ranges, requestId: inputStatement },
|
|
899
|
+
scope: "preparation", ...sourcePreparation.demandFor(work) });
|
|
900
|
+
if (work.selected) await sourcePreparation.bytesChanged(work.sourceKey, work.fileIndex);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
861
903
|
if (statement === "packets" && work.role === "source-rest" && result.kind === "result") {
|
|
862
904
|
const tracks = await containerOrchestrator.containerFor(params).then(container => container.readTracks());
|
|
863
905
|
const info = await containerOrchestrator.inspect({ ...params, onReadResult: undefined }, "media-info");
|
|
@@ -895,7 +937,7 @@ export async function startProxyServer({
|
|
|
895
937
|
};
|
|
896
938
|
return work.statement === "subtitle-file"
|
|
897
939
|
? subtitles.inspectFile(params) : work.statement === "subtitle-cues"
|
|
898
|
-
? subtitles.inspectPackets(params) : containerOrchestrator.inspect(params, work.statement);
|
|
940
|
+
? subtitles.inspectPackets(params) : containerOrchestrator.inspect(params, originalRanges ? "source-ranges" : work.statement);
|
|
899
941
|
},
|
|
900
942
|
withdraw: work => {
|
|
901
943
|
if (work.role === "source-rest" && work.statement === "packets") {
|
|
@@ -1055,6 +1097,9 @@ export async function startProxyServer({
|
|
|
1055
1097
|
subtitles
|
|
1056
1098
|
})
|
|
1057
1099
|
);
|
|
1100
|
+
app.get("/encode-input/:token/:fileIndex", (req, reply) =>
|
|
1101
|
+
handleEncodeInputGet(req, reply, { inputOf: token => outputParts.encodeRuns.originalInputOf(token) })
|
|
1102
|
+
);
|
|
1058
1103
|
app.get("/stream", async (req, reply) =>
|
|
1059
1104
|
handleStreamGet(req, reply, {
|
|
1060
1105
|
sourceRegistry,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { admitInput } from "./AdmittedInput.js";
|
|
2
|
+
import { admitOriginalInput } from "./OriginalInput.js";
|
|
2
3
|
|
|
3
4
|
/** Event-driven preparation; a synchronous run request can take only ready input. */
|
|
4
5
|
export class EncodeInputs {
|
|
@@ -31,7 +32,11 @@ export class EncodeInputs {
|
|
|
31
32
|
let bytes = this.#held;
|
|
32
33
|
for (const [key, wanted] of this.#wanted) {
|
|
33
34
|
const request = this.#requests.get(key);
|
|
34
|
-
|
|
35
|
+
// The encode plan already chose this finite input. It must fit before
|
|
36
|
+
// production can advance, even when playback has not reached it yet.
|
|
37
|
+
// Sharing against speculative whole-file demand can otherwise deny it
|
|
38
|
+
// forever, so urgency must not decide its minimum allocation again.
|
|
39
|
+
if (request) bytes += wanted;
|
|
35
40
|
}
|
|
36
41
|
return bytes;
|
|
37
42
|
}
|
|
@@ -110,7 +115,8 @@ export class EncodeInputs {
|
|
|
110
115
|
request.promise = (async () => {
|
|
111
116
|
const resolved = await this.#resolve(request.output, request.from, request.to);
|
|
112
117
|
if (this.#requests.get(request.key) !== request) return;
|
|
113
|
-
const
|
|
118
|
+
const admit = resolved.sources?.every(source => source.input.original === true) ? admitOriginalInput : admitInput;
|
|
119
|
+
const result = resolved.kind === "result" ? await admit({
|
|
114
120
|
sources: resolved.sources,
|
|
115
121
|
readRanges: this.#read,
|
|
116
122
|
reserve: async bytes => {
|
|
@@ -15,6 +15,7 @@ import { ENCODE_EXIT } from "./encode-exit.js";
|
|
|
15
15
|
import { EncodeRun } from "./EncodeRun.js";
|
|
16
16
|
import { computeOutputDimensions } from "./args.js";
|
|
17
17
|
import { buildAdmittedCommand } from "./admitted-command.js";
|
|
18
|
+
import { buildOriginalCommand } from "./source-command.js";
|
|
18
19
|
import { writeAdmittedInput } from "./AdmittedInput.js";
|
|
19
20
|
import { cutOf, judgeNeighbors, judgePiece } from "./piece-completeness.js";
|
|
20
21
|
import { presentationSegment } from "./segment-formats/presentation-segment.js";
|
|
@@ -254,6 +255,10 @@ export class EncodeRuns {
|
|
|
254
255
|
}
|
|
255
256
|
}
|
|
256
257
|
|
|
258
|
+
originalInputOf(runToken) {
|
|
259
|
+
return this.#runsByInputToken.get(runToken)?.originalInput ?? null;
|
|
260
|
+
}
|
|
261
|
+
|
|
257
262
|
/**
|
|
258
263
|
* @param {object} output
|
|
259
264
|
* @returns {number}
|
|
@@ -892,8 +897,12 @@ export class EncodeRuns {
|
|
|
892
897
|
this.#lastInputToken += 1;
|
|
893
898
|
const inputToken = this.#lastInputToken;
|
|
894
899
|
if (admittedInput) admittedInput.runTag = `${startIndex}r${inputToken}`;
|
|
895
|
-
const
|
|
900
|
+
const buildCommand = admittedInput.original ? buildOriginalCommand : buildAdmittedCommand;
|
|
901
|
+
const { args, safeIndex, startSeconds, cutTimes } = buildCommand({
|
|
896
902
|
admittedInput,
|
|
903
|
+
inputToken,
|
|
904
|
+
baseUrl: this.#host.localBaseUrl,
|
|
905
|
+
keyframes: session.file.keyframes,
|
|
897
906
|
|
|
898
907
|
timeline: session.timeline,
|
|
899
908
|
output: session.output,
|
|
@@ -971,7 +980,10 @@ export class EncodeRuns {
|
|
|
971
980
|
|
|
972
981
|
const index = session.segmentFormat.segmentIndexFromName(
|
|
973
982
|
session.segmentFormat.servedNameOf?.(name) ?? name);
|
|
974
|
-
|
|
983
|
+
// Encoder flush can create a partial file beyond its assigned interval.
|
|
984
|
+
// It belongs to no requested output segment and must not condemn the
|
|
985
|
+
// complete input or overwrite a neighboring run's segment.
|
|
986
|
+
if (Number.isInteger(index) && (index < safeIndex || index > run.to)) return null;
|
|
975
987
|
let bytes = null;
|
|
976
988
|
if (admittedInput && session.segmentFormat.id === "fmp4" && session.spec.carries !== "audio-only" &&
|
|
977
989
|
session.spec.audio && !this.#host.servesAudioSeparately(session)) {
|
|
@@ -997,10 +1009,16 @@ export class EncodeRuns {
|
|
|
997
1009
|
onSpeedMeasured: () => this.#host.noteRunSpeedMeasured?.(session, run),
|
|
998
1010
|
onEnded: (ended) => {
|
|
999
1011
|
this.#runsByInputToken.delete(inputToken);
|
|
1012
|
+
if (admittedInput.original) admittedInput.release();
|
|
1000
1013
|
this.noteRunEnded(session, run, ended);
|
|
1001
1014
|
}
|
|
1002
1015
|
});
|
|
1003
|
-
if (admittedInput) {
|
|
1016
|
+
if (admittedInput.original) {
|
|
1017
|
+
run.originalInput = admittedInput;
|
|
1018
|
+
run.inputFingerprint = admittedInput.fingerprint;
|
|
1019
|
+
run.admittedInputKeys = new Map([[safeIndex, inputKey]]);
|
|
1020
|
+
run.process.stdin.end();
|
|
1021
|
+
} else if (admittedInput) {
|
|
1004
1022
|
run.inputFingerprint = admittedInput.fingerprint;
|
|
1005
1023
|
run.admittedInputKeys = new Map([[safeIndex, inputKey]]);
|
|
1006
1024
|
let nextIndex = startIndex + 1;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** Retain complete original byte ranges under the encoder's input allowance.
|
|
4
|
+
* The available-only reader acquires complete pieces atomically before copying;
|
|
5
|
+
* owned buffers then outlive torrent eviction without keeping duplicate pins.
|
|
6
|
+
*/
|
|
7
|
+
export async function admitOriginalInput({ sources, reserve, readRanges }) {
|
|
8
|
+
const bytes = sources.reduce((total, source) => total + source.input.ranges
|
|
9
|
+
.reduce((sum, [start, end]) => sum + end - start + 1, 0), 0);
|
|
10
|
+
if (!Number.isSafeInteger(bytes) || bytes <= 0) throw new TypeError("Original input requires complete finite byte ranges.");
|
|
11
|
+
const releaseBudget = await reserve(bytes);
|
|
12
|
+
if (typeof releaseBudget !== "function") return releaseBudget?.kind === "terminal" ? releaseBudget : { kind: "needs-memory", bytes };
|
|
13
|
+
const held = new Map();
|
|
14
|
+
const hash = createHash("sha256");
|
|
15
|
+
let retained = false;
|
|
16
|
+
try {
|
|
17
|
+
for (const source of sources) {
|
|
18
|
+
const buffers = await readRanges(source, source.input.ranges, bytes);
|
|
19
|
+
if (buffers === null) return { kind: "needs-bytes", sourceKey: source.sourceKey, fileIndex: source.fileIndex, ranges: source.input.ranges };
|
|
20
|
+
if (!Array.isArray(buffers) || buffers.length !== source.input.ranges.length || buffers.some((buffer, index) =>
|
|
21
|
+
!Buffer.isBuffer(buffer) || buffer.length !== source.input.ranges[index][1] - source.input.ranges[index][0] + 1)) {
|
|
22
|
+
throw new Error("Original input reader returned incomplete ranges.");
|
|
23
|
+
}
|
|
24
|
+
hash.update(JSON.stringify([source.sourceKey, source.fileIndex, source.input.ranges, source.input.selections]));
|
|
25
|
+
for (const buffer of buffers) hash.update(buffer);
|
|
26
|
+
held.set(source.fileIndex, { source, buffers });
|
|
27
|
+
}
|
|
28
|
+
let released = false;
|
|
29
|
+
retained = true;
|
|
30
|
+
return {
|
|
31
|
+
kind: "result", original: true, sources, bytes, fingerprint: hash.digest("hex"),
|
|
32
|
+
tracks: sources.flatMap(source => (source.input.selections ?? []).map(selection => ({ track: selection.track,
|
|
33
|
+
...(Number.isFinite(source.input.sourceEnds?.[selection.track.trackNumber]) ? {
|
|
34
|
+
sourceEndSeconds: source.input.sourceEnds[selection.track.trackNumber] - source.timeShiftSeconds
|
|
35
|
+
} : {}) }))),
|
|
36
|
+
read(fileIndex, start, end, partial = false) {
|
|
37
|
+
if (released) return null;
|
|
38
|
+
const entry = held.get(fileIndex);
|
|
39
|
+
if (!entry) return null;
|
|
40
|
+
const { ranges, fileLength } = entry.source.input;
|
|
41
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || start >= fileLength) return null;
|
|
42
|
+
let stop = Math.min(end, fileLength - 1);
|
|
43
|
+
const position = ranges.findIndex(([from, to]) => from <= start && (partial ? start : stop) <= to);
|
|
44
|
+
if (position < 0) return null;
|
|
45
|
+
stop = Math.min(stop, ranges[position][1]);
|
|
46
|
+
return { length: fileLength, end: stop, bytes: entry.buffers[position].subarray(start - ranges[position][0], stop - ranges[position][0] + 1) };
|
|
47
|
+
},
|
|
48
|
+
lengthOf: fileIndex => held.get(fileIndex)?.source.input.fileLength ?? null,
|
|
49
|
+
release() {
|
|
50
|
+
if (released) return;
|
|
51
|
+
released = true;
|
|
52
|
+
held.clear();
|
|
53
|
+
releaseBudget();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
} finally {
|
|
57
|
+
if (!retained) releaseBudget();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -13,7 +13,13 @@ export function buildAdmittedCommand({ admittedInput, timeline, output, segmentF
|
|
|
13
13
|
const origin = admittedInput.originSeconds;
|
|
14
14
|
const relativeStart = Math.max(0, startSeconds - origin);
|
|
15
15
|
const relativeEnd = endSeconds - origin;
|
|
16
|
-
|
|
16
|
+
// segment.c adds the first reference packet's PTS to every relative cut.
|
|
17
|
+
// Copied audio can start after the published boundary; price cuts from its
|
|
18
|
+
// actual first packet rather than adding that delay to every segment end.
|
|
19
|
+
const firstAudio = audioOnly && !transcodeAudio
|
|
20
|
+
? admittedInput.tracks.find(input => input.track.type === "audio")?.packets[0]?.pts : null;
|
|
21
|
+
const cutOrigin = Number.isFinite(firstAudio) ? firstAudio : startSeconds;
|
|
22
|
+
const cutTimes = grid.slice(safeIndex + 1, endIndex + 1).map(time => time - cutOrigin);
|
|
17
23
|
const args = ["-hide_banner", "-nostats", "-loglevel", "error", "-progress", "pipe:1"];
|
|
18
24
|
if (transcodeVideo && Array.isArray(videoEncoder.inputArgs)) args.push(...videoEncoder.inputArgs);
|
|
19
25
|
args.push("-f", "matroska", "-i", "pipe:0", "-copyts", "-avoid_negative_ts", "disabled", "-output_ts_offset", ffmpegSeconds(origin));
|
|
@@ -42,7 +48,7 @@ export function buildAdmittedCommand({ admittedInput, timeline, output, segmentF
|
|
|
42
48
|
else args.push("-map", "0:v:0", "-map", "0:a:0?", ...video, ...audio);
|
|
43
49
|
args.push("-to", ffmpegSeconds(relativeEnd), "-f", "segment");
|
|
44
50
|
if (cutTimes.length) args.push("-segment_times", cutTimes.join(","));
|
|
45
|
-
else args.push("-segment_time", ffmpegSeconds(endSeconds -
|
|
51
|
+
else args.push("-segment_time", ffmpegSeconds(endSeconds - cutOrigin));
|
|
46
52
|
const formatArgs = segmentFormat.explicitTimesMuxerArgs?.();
|
|
47
53
|
if (!Array.isArray(formatArgs)) throw new Error("The segment format cannot publish admitted packet input.");
|
|
48
54
|
const muxOptions = formatArgs.indexOf("-segment_format_options");
|
|
@@ -180,6 +180,7 @@ export class Timeline {
|
|
|
180
180
|
let priority = 0;
|
|
181
181
|
let withinSeconds = Number.POSITIVE_INFINITY;
|
|
182
182
|
let behind = true;
|
|
183
|
+
let urgent = false;
|
|
183
184
|
for (let second = from; second < until && second < map.durationSeconds; second += 1) {
|
|
184
185
|
if (map.priority[second] > priority) {
|
|
185
186
|
priority = map.priority[second];
|
|
@@ -190,6 +191,7 @@ export class Timeline {
|
|
|
190
191
|
if (map.behind[second] === 0) {
|
|
191
192
|
behind = false;
|
|
192
193
|
}
|
|
194
|
+
if (map.urgent?.[second]) urgent = true;
|
|
193
195
|
}
|
|
194
196
|
if (priority === 0) {
|
|
195
197
|
continue;
|
|
@@ -200,6 +202,7 @@ export class Timeline {
|
|
|
200
202
|
&& previous.to === index - 1
|
|
201
203
|
&& previous.priority === priority
|
|
202
204
|
&& previous.behind === behind
|
|
205
|
+
&& previous.urgent === urgent
|
|
203
206
|
) {
|
|
204
207
|
// The time is not compared, only the rank. A run's time is that of its
|
|
205
208
|
// near edge — a stretch is met at its beginning — and whoever needs the
|
|
@@ -212,6 +215,7 @@ export class Timeline {
|
|
|
212
215
|
to: index,
|
|
213
216
|
priority,
|
|
214
217
|
withinSeconds,
|
|
218
|
+
urgent,
|
|
215
219
|
// Which side of the viewers this is. Stated by the map and carried
|
|
216
220
|
// through: converting seconds into piece numbers cannot move a stretch
|
|
217
221
|
// from in front of somebody to behind them.
|
|
@@ -141,7 +141,11 @@ function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {} })
|
|
|
141
141
|
const end = BigInt(Math.round(through * Number(track.timescale)));
|
|
142
142
|
const first = track.ranges[0];
|
|
143
143
|
const frame = track.productionFrame ?? first.frame;
|
|
144
|
-
|
|
144
|
+
// The muxer rounds an empty edit to movie ticks independently of sample
|
|
145
|
+
// cadence. Account for that recorded position uncertainty at the boundary,
|
|
146
|
+
// without admitting another missing frame or widening internal gaps.
|
|
147
|
+
const positionError = track.positionErrorTicks ?? 0n;
|
|
148
|
+
if (first.start > start + frame + positionError || first.start < start - frame - positionError) return `segment-start-outside-interval-${kind}`;
|
|
145
149
|
// The first sample's cadence already bounds the interval start. A shorter
|
|
146
150
|
// final sample must not replace that bound when this range is traversed.
|
|
147
151
|
let reached = first.start;
|
|
@@ -150,7 +154,7 @@ function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {} })
|
|
|
150
154
|
if (range.end > reached) reached = range.end;
|
|
151
155
|
}
|
|
152
156
|
const finalFrame = track.productionFrame ?? track.ranges.at(-1).frame;
|
|
153
|
-
if (reached < end - finalFrame || reached > end + finalFrame) return `segment-end-outside-interval-${kind}`;
|
|
157
|
+
if (reached < end - finalFrame - positionError || reached > end + finalFrame + positionError) return `segment-end-outside-interval-${kind}`;
|
|
154
158
|
}
|
|
155
159
|
return null;
|
|
156
160
|
|
|
@@ -23,19 +23,19 @@
|
|
|
23
23
|
* duration parser rejects that outright: a field session died on
|
|
24
24
|
* `Invalid duration for option ss: 3.3333333249174757e-7`, after which the
|
|
25
25
|
* transcode was in state `failed` and every segment request answered 500 for
|
|
26
|
-
* as long as the viewer kept trying.
|
|
27
|
-
*
|
|
28
|
-
* it is dropped rather than passed on.
|
|
26
|
+
* as long as the viewer kept trying. Round to the duration parser's microsecond
|
|
27
|
+
* precision; a sub-millisecond value can be a real trim, not subtraction noise.
|
|
29
28
|
*
|
|
30
29
|
* @param {number} value
|
|
31
30
|
* @returns {string}
|
|
32
31
|
*/
|
|
33
32
|
export function ffmpegSeconds(value) {
|
|
34
|
-
if (!Number.isFinite(value)
|
|
33
|
+
if (!Number.isFinite(value)) {
|
|
35
34
|
return "0";
|
|
36
35
|
}
|
|
37
36
|
// Microsecond resolution, fixed notation, no trailing zero noise.
|
|
38
|
-
|
|
37
|
+
const rounded = value.toFixed(6).replace(/\.?0+$/, "");
|
|
38
|
+
return rounded === "-0" ? "0" : rounded;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
@@ -165,16 +165,40 @@ export function readTrackEdits(initSegment) {
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
/** An init shared across seeks has no segment-specific empty edit. */
|
|
168
|
-
export function neutralizeEmptyEdits(initSegment) {
|
|
168
|
+
export function neutralizeEmptyEdits(initSegment, { audioEncoderDelay = 0 } = {}) {
|
|
169
169
|
const neutral = Buffer.from(initSegment);
|
|
170
|
+
const audioTracks = new Set();
|
|
171
|
+
let trackId = null;
|
|
172
|
+
if (audioEncoderDelay > 0) walkBoxes(neutral, (type, start, end) => {
|
|
173
|
+
if (type === "tkhd") {
|
|
174
|
+
const at = start + (neutral[start] === 1 ? 20 : 12);
|
|
175
|
+
if (at + 4 <= end) trackId = neutral.readUInt32BE(at);
|
|
176
|
+
} else if (type === "hdlr" && start + 12 <= end && neutral.toString("latin1", start + 8, start + 12) === "soun") {
|
|
177
|
+
audioTracks.add(trackId);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
trackId = null;
|
|
170
181
|
walkBoxes(neutral, (type, start, end) => {
|
|
182
|
+
if (type === "tkhd") {
|
|
183
|
+
const at = start + (neutral[start] === 1 ? 20 : 12);
|
|
184
|
+
if (at + 4 <= end) trackId = neutral.readUInt32BE(at);
|
|
185
|
+
}
|
|
171
186
|
if (type !== "elst" || start + 8 > end) return;
|
|
172
187
|
const version = neutral[start];
|
|
173
188
|
const width = version === 1 ? 20 : 12;
|
|
174
189
|
const count = neutral.readUInt32BE(start + 4);
|
|
175
190
|
for (let entry = 0, at = start + 8; entry < count && at + width <= end; entry++, at += width) {
|
|
176
191
|
const mediaTime = version === 1 ? neutral.readBigInt64BE(at + 8) : BigInt(neutral.readInt32BE(at + 4));
|
|
177
|
-
if (mediaTime !== -1n)
|
|
192
|
+
if (mediaTime !== -1n) {
|
|
193
|
+
// Native AAC emits 1024 samples of encoder delay (aacenc.c).
|
|
194
|
+
// A later run's positive start can hide that delay in an empty edit.
|
|
195
|
+
// The shared origin must also represent the first run's negative DTS.
|
|
196
|
+
if (audioTracks.has(trackId) && mediaTime < BigInt(audioEncoderDelay)) {
|
|
197
|
+
if (version === 1) neutral.writeBigInt64BE(BigInt(audioEncoderDelay), at + 8);
|
|
198
|
+
else neutral.writeInt32BE(audioEncoderDelay, at + 4);
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
178
202
|
if (version === 1) neutral.writeBigUInt64BE(0n, at);
|
|
179
203
|
else neutral.writeUInt32BE(0, at);
|
|
180
204
|
}
|