@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.95.3",
3
+ "version": "2.95.4",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -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: tracks.value.filter(track => track.type === choice.type)[choice.index] }));
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 packets = await containerOrchestrator.inspect(intervalParams, "packets");
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 zones = nativeSourceMap({ ...work.nativeInput, viewers,
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
- if (work.statement === "packets" && work.role === "source-rest" && sourcePreparation.accepts(work)) {
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
- if (request && this.#urgent(request.output, request.from)) bytes += wanted;
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 result = resolved.kind === "result" ? await admitInput({
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 { args, safeIndex, startSeconds, cutTimes } = buildAdmittedCommand({
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
- if (Number.isInteger(index) && index < safeIndex) return null;
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
- const cutTimes = grid.slice(safeIndex + 1, endIndex + 1).map(time => time - startSeconds);
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 - startSeconds));
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
- if (first.start > start + frame || first.start < start - frame) return `segment-start-outside-interval-${kind}`;
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. Anything under a millisecond is also not a
27
- * real offset — it is the residue of subtracting two nearly equal floats — so
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) || Math.abs(value) < 0.001) {
33
+ if (!Number.isFinite(value)) {
35
34
  return "0";
36
35
  }
37
36
  // Microsecond resolution, fixed notation, no trailing zero noise.
38
- return value.toFixed(6).replace(/\.?0+$/, "");
37
+ const rounded = value.toFixed(6).replace(/\.?0+$/, "");
38
+ return rounded === "-0" ? "0" : rounded;
39
39
  }
40
40
 
41
41
  /**
@@ -98,6 +98,7 @@ const SEGMENT_PATTERN = /^segment-(\d{5})\.mp4$/;
98
98
  */
99
99
  export const fmp4Format = {
100
100
  id: "fmp4",
101
+ supportsOriginalInput: true,
101
102
  initFileName: INIT_FILE_NAME,
102
103
  initContentType: "video/mp4",
103
104
  segmentContentType: "video/mp4",
@@ -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) continue;
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
  }