@torrent-tv/proxy 2.95.3 → 2.95.5

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.5",
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,13 @@ 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 = await containerOrchestrator.inspect(intervalParams, originalRanges ? "source-ranges" : "packets");
465
471
  if (!map.isCurrent()) return [];
466
472
  if (packets.kind !== "result") continue;
467
- const input = new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
473
+ const input = originalRanges ? packets.value : new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
468
474
  if (input.kind !== "result") continue;
469
475
  const { tracks: _choices, owner: _owner, sourceInterval: _sourceInterval, ...demand } = zone;
470
476
  for (const [byteStart, byteEnd] of input.ranges) converted.push({ ...demand,
@@ -610,6 +616,16 @@ export async function startProxyServer({
610
616
  params.packetInterval = { ...sourceInterval,
611
617
  trackIds: wanted.map(track => track.trackNumber),
612
618
  modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
619
+ const navigation = await containerOrchestrator.inspect(params, "source-navigation");
620
+ if (navigation.kind !== "result") return navigation;
621
+ if (navigation.value && selected.size === 1 && output.segmentFormat.supportsOriginalInput === true) {
622
+ const ranges = await containerOrchestrator.inspect(params, "source-ranges");
623
+ if (ranges.kind !== "result") return ranges;
624
+ sources.push({ sourceKey: params.sourceKey, fileIndex, timeShiftSeconds,
625
+ input: { ...ranges.value, original: true, selections: wanted.map(track => ({ track,
626
+ index: track.declaredIndex ?? choices.find(choice => choice.type === track.type)?.index ?? 0 })) } });
627
+ continue;
628
+ }
613
629
  const packets = await containerOrchestrator.inspect(params, "packets");
614
630
  if (packets.kind !== "result") return packets;
615
631
  const input = new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({
@@ -769,13 +785,15 @@ export async function startProxyServer({
769
785
  // decides anything, keeps the segments whose closure is proven, and removes
770
786
  // the one piece per output that was being written when the process died.
771
787
  outputParts.lifecycle.adoptSegmentsLeftBehind();
772
- const publishNative = work => {
788
+ const publishNative = async work => {
773
789
  if (!work.nativeInput || !sourcePreparation.accepts(work)) return Promise.resolve();
774
790
  const viewers = outputParts.viewers.forSource(work.sourceKey)
775
791
  .filter(viewer => viewer.source.selectedFileIndex === work.fileIndex && viewer.outputs.size === 0);
776
- const zones = nativeSourceMap({ ...work.nativeInput, viewers,
792
+ const convert = work.nativeInput.container ? nativeOriginalSourceMap : nativeSourceMap;
793
+ const zones = await convert({ ...work.nativeInput, viewers,
777
794
  allowanceSeconds: outputParts.segmentDurationSec,
778
795
  urgentReadyFor: viewer => sourcePreparation.urgentReadyFor(viewer) });
796
+ if (!sourcePreparation.accepts(work)) return;
779
797
  return downloadMaps.native({ sourceKey: work.sourceKey, fileIndex: work.fileIndex, zones });
780
798
  };
781
799
  sourcePreparation = new SourcePreparation({
@@ -828,7 +846,12 @@ export async function startProxyServer({
828
846
  const params = await containerOver(work);
829
847
  if (!params) return { kind: "needs-source" };
830
848
  params.isCurrent = () => sourcePreparation.accepts(work);
831
- if (work.statement === "packets" && work.role === "source-rest" && sourcePreparation.accepts(work)) {
849
+ const originalContainer = work.statement === "packets" ? await containerOrchestrator.containerFor(params) : null;
850
+ const navigation = work.statement === "packets" && work.role !== "subtitle-embedded"
851
+ ? await containerOrchestrator.inspect(params, "source-navigation") : { kind: "result", value: false };
852
+ if (navigation.kind !== "result") return navigation;
853
+ const originalRanges = navigation.value;
854
+ if (!originalRanges && work.statement === "packets" && work.role === "source-rest" && sourcePreparation.accepts(work)) {
832
855
  const file = torrentPool.knownTorrent(work.sourceKey)?.files?.[work.fileIndex];
833
856
  if (file?.length > 0) {
834
857
  const statement = `${work.statement}:${work.requestId}:input`;
@@ -837,7 +860,7 @@ export async function startProxyServer({
837
860
  scope: "preparation", ...sourcePreparation.demandFor(work) });
838
861
  }
839
862
  }
840
- if ((work.statement === "packets" && work.role !== "source-rest") || work.role === "subtitle-embedded" && work.statement === "subtitle-cues") {
863
+ if ((work.statement === "packets" && (work.role !== "source-rest" || originalRanges)) || work.role === "subtitle-embedded" && work.statement === "subtitle-cues") {
841
864
  const info = await containerOrchestrator.inspect({ ...params, onReadResult: undefined }, "media-info");
842
865
  if (info.kind !== "result") return info;
843
866
  const positions = outputParts.viewers.forSource(work.sourceKey)
@@ -858,6 +881,23 @@ export async function startProxyServer({
858
881
  if (!sourcePreparation.accepts(work)) return;
859
882
  mediaReads.record(params, statement, result, revision.storage, revision.memory);
860
883
  const inputStatement = `${work.statement}:${work.requestId}:input`;
884
+ if (statement === "source-ranges" && result.kind === "result") {
885
+ if (work.selected) work.inputRanges = result.value.ranges;
886
+ if (work.role === "source-rest") {
887
+ const info = await containerOrchestrator.inspect({ ...params, onReadResult: undefined }, "media-info");
888
+ if (!sourcePreparation.accepts(work)) return;
889
+ if (info.kind === "result") {
890
+ work.nativeInput = { container: originalContainer, durationSeconds: info.value.durationSeconds,
891
+ startTimeSeconds: info.value.startTimeSeconds ?? 0 };
892
+ await publishNative(work);
893
+ }
894
+ } else {
895
+ await downloadMaps.metadata({ sourceKey: work.sourceKey, fileIndex: work.fileIndex,
896
+ statement: inputStatement, result: { kind: "needs-ranges", ranges: result.value.ranges, requestId: inputStatement },
897
+ scope: "preparation", ...sourcePreparation.demandFor(work) });
898
+ if (work.selected) await sourcePreparation.bytesChanged(work.sourceKey, work.fileIndex);
899
+ }
900
+ }
861
901
  if (statement === "packets" && work.role === "source-rest" && result.kind === "result") {
862
902
  const tracks = await containerOrchestrator.containerFor(params).then(container => container.readTracks());
863
903
  const info = await containerOrchestrator.inspect({ ...params, onReadResult: undefined }, "media-info");
@@ -895,7 +935,7 @@ export async function startProxyServer({
895
935
  };
896
936
  return work.statement === "subtitle-file"
897
937
  ? subtitles.inspectFile(params) : work.statement === "subtitle-cues"
898
- ? subtitles.inspectPackets(params) : containerOrchestrator.inspect(params, work.statement);
938
+ ? subtitles.inspectPackets(params) : containerOrchestrator.inspect(params, originalRanges ? "source-ranges" : work.statement);
899
939
  },
900
940
  withdraw: work => {
901
941
  if (work.role === "source-rest" && work.statement === "packets") {
@@ -1055,6 +1095,9 @@ export async function startProxyServer({
1055
1095
  subtitles
1056
1096
  })
1057
1097
  );
1098
+ app.get("/encode-input/:token/:fileIndex", (req, reply) =>
1099
+ handleEncodeInputGet(req, reply, { inputOf: token => outputParts.encodeRuns.originalInputOf(token) })
1100
+ );
1058
1101
  app.get("/stream", async (req, reply) =>
1059
1102
  handleStreamGet(req, reply, {
1060
1103
  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
  }