@torrent-tv/proxy 2.96.1 → 2.96.2

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.96.1",
3
+ "version": "2.96.2",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -285,6 +285,15 @@ export class EncodeRun {
285
285
  this.indexOfName = typeof indexOfName === "function" ? indexOfName : () => null;
286
286
  /** The last thing ffmpeg said on stderr, which is what a failure is explained by. */
287
287
  this.lastError = "";
288
+ /**
289
+ * How far after the start it was asked for this run's segment muxer counts
290
+ * its cuts from, in seconds. The muxer measures every cut from its first
291
+ * reference packet, and a copied stream's first packet lies up to one frame
292
+ * after the requested start, so every piece of the run ends that much later
293
+ * than its cut. `null` until the run's first piece has closed
294
+ * (`piece-completeness.js`, `cutShiftOf`).
295
+ */
296
+ this.cutShiftSeconds = null;
288
297
  // EXISTING IS RUNNING. There is no moment at which a built run is not yet a
289
298
  // process, so there is no second act for two owners to perform.
290
299
  this.#begin(because);
@@ -17,7 +17,7 @@ import { computeOutputDimensions } from "./args.js";
17
17
  import { buildAdmittedCommand } from "./admitted-command.js";
18
18
  import { buildOriginalCommand } from "./source-command.js";
19
19
  import { writeAdmittedInput } from "./AdmittedInput.js";
20
- import { cutOf, judgeNeighbors, judgePiece } from "./piece-completeness.js";
20
+ import { cutOf, cutShiftOf, judgeNeighbors, judgePiece } from "./piece-completeness.js";
21
21
  import { presentationSegment } from "./segment-formats/presentation-segment.js";
22
22
  import { InputFailures, failedAdmittedInput } from "./InputFailures.js";
23
23
 
@@ -351,9 +351,13 @@ export class EncodeRuns {
351
351
  * @param {HlsSession} session
352
352
  * @param {string} name - The working name the encoder closed it under.
353
353
  * @param {number} index
354
+ * @param {object | false} [admitted] - The original input the run reads, when it reads one.
355
+ * @param {Buffer | null} [bytes]
356
+ * @param {EncodeRun | null} [run] - The run that closed it, which keeps where its muxer counts cuts from.
357
+ * @param {{ endMicros: bigint } | null} [timing] - The piece's end in the muxer's clock, from its segment list.
354
358
  * @returns {object | null | false}
355
359
  */
356
- #wholeClosedPiece(session, name, index, admitted = false, bytes = null) {
360
+ #wholeClosedPiece(session, name, index, admitted = false, bytes = null, run = null, timing = null) {
357
361
 
358
362
  const format = session.segmentFormat;
359
363
  if (!format?.readMediaRanges || !Number.isInteger(index) || index < 0) {
@@ -371,6 +375,16 @@ export class EncodeRuns {
371
375
  .map(input => [input.track.type === "video" ? "vide" : "soun", input.sourceEndSeconds])) } : {}),
372
376
  requiredKinds: [session.spec.carries !== "audio-only" ? "vide" : null,
373
377
  session.spec.audio && !this.#host.servesAudioSeparately(session) ? "soun" : null].filter(Boolean) } : undefined;
378
+ if (run) {
379
+ // Where the muxer counts this run's cuts from is known once its first
380
+ // piece has closed, and holds for every piece after it. It is a fact
381
+ // of each piece the run makes, so it travels with the piece.
382
+ if (run.cutShiftSeconds === null && index === run.from) {
383
+ run.cutShiftSeconds = cutShiftOf(mediaRanges, grid?.[run.from], session.spec.carries !== "audio-only" ? "vide" : "soun",
384
+ timing?.endMicros ?? null);
385
+ }
386
+ if (mediaRanges && run.cutShiftSeconds !== null) mediaRanges.cutShiftSeconds = run.cutShiftSeconds;
387
+ }
374
388
  const { whole, throughSeconds, reason } = judgePiece(format, mediaRanges, cut, interval);
375
389
  if (!whole) {
376
390
  this.#host.logger.warn(
@@ -990,7 +1004,7 @@ export class EncodeRuns {
990
1004
  // Why this encoder exists, recorded with its argument list. It used to be
991
1005
  // handed to a separate `start` call; there is no separate call now.
992
1006
  because,
993
- onClosed: (name, following) => {
1007
+ onClosed: (name, following, timing) => {
994
1008
 
995
1009
  const index = session.segmentFormat.segmentIndexFromName(
996
1010
  session.segmentFormat.servedNameOf?.(name) ?? name);
@@ -1012,7 +1026,7 @@ export class EncodeRuns {
1012
1026
  throw error;
1013
1027
  }
1014
1028
  }
1015
- const mediaRanges = this.#wholeClosedPiece(session, name, index, admittedInput, bytes);
1029
+ const mediaRanges = this.#wholeClosedPiece(session, name, index, admittedInput, bytes, run, timing);
1016
1030
  if (mediaRanges === false) throw Object.assign(new Error(`Closed piece #${index} has incomplete media.`),
1017
1031
  { code: "ERR_INCOMPLETE_MEDIA" });
1018
1032
  const published = this.#host.segmentFiles.publish(session.outputKey ?? "", name, session.segmentFormat, { mediaRanges, bytes });
@@ -30,82 +30,81 @@ export function cutOf(timeline, index) {
30
30
  }
31
31
 
32
32
  /**
33
- * Track positions read from empty edits retain their movie-tick uncertainty.
34
- * Fresh closures also carry the muxer's packet end and its actual cut, so a
35
- * restarted run is judged on its own reference clock. Neither allowance is a
36
- * browser's frame-join rule.
33
+ * Whether a closed piece holds its stretch of film. With `interval` (a run
34
+ * reading the original file, which states the stretch each piece covers) the
35
+ * piece's tracks are compared with that stretch; otherwise with the cut.
36
+ *
37
+ * Both are compared on the clock the producing run's muxer cut by: moved by
38
+ * `ranges.cutShiftSeconds`, which the run states for every piece it closes
39
+ * ({@link cutShiftOf}) and which travels with the piece. A piece whose run is
40
+ * not known — one adopted from an earlier life of the process — has none, and
41
+ * is compared with the cut as published.
37
42
  *
38
43
  * @param {{ producedThroughSeconds?: (ranges: object) => number | null }} format
39
44
  * @param {object} ranges - The piece's media intervals, as the format read them.
40
45
  * @param {number | undefined} cutSeconds - From {@link cutOf}.
41
- * @returns {{ whole: boolean, throughSeconds: number | null }}
46
+ * @param {{ from: number, to: number, requiredKinds: string[], sourceEnds?: object }} [interval]
47
+ * @returns {{ whole: boolean, throughSeconds: number | null, reason?: string }}
42
48
  */
43
49
  export function judgePiece(format, ranges, cutSeconds, interval) {
44
50
  const throughSeconds = format.producedThroughSeconds?.(ranges) ?? null;
45
51
  if (throughSeconds === null) {
46
52
  return { whole: false, throughSeconds };
47
53
  }
54
+ const shift = Number.isFinite(ranges?.cutShiftSeconds) ? ranges.cutShiftSeconds : 0;
48
55
  if (interval) {
49
- const reason = intervalFailure(ranges, interval);
56
+ const reason = intervalFailure(ranges, { ...interval, shift });
50
57
  return reason ? { whole: false, throughSeconds, reason } : { whole: true, throughSeconds };
51
58
  }
52
- const cutMicros = ranges?.production?.cutMicros ??
53
- (Number.isFinite(cutSeconds) ? BigInt(Math.round(cutSeconds * 1_000_000)) : null);
59
+ const cutMicros = Number.isFinite(cutSeconds) ? BigInt(Math.round((cutSeconds + shift) * 1_000_000)) : null;
54
60
  if (cutMicros !== null && ranges?.tracks?.length) {
55
- const tracks = ranges.tracks;
56
- const production = ranges.production;
57
- const reference = production ? tracks.find(({ kind }) => kind === production.kind) : null;
58
- const referenceEnd = reference?.ranges.at(-1)?.end;
59
- const whole = tracks.every((track) => {
60
- const { timescale, ranges: held, positionErrorTicks = 0n } = track;
61
+ const whole = ranges.tracks.every(({ timescale, ranges: held, positionErrorTicks = 0n }) => {
61
62
  const end = held.at(-1)?.end;
62
- if (end === undefined) return false;
63
- if (referenceEnd !== undefined) {
64
- if (track === reference) return 2n * production.endMicros + 1n >= 2n * cutMicros;
65
- // The segment muxer's CSV reports packet time before movenc truncates
66
- // the empty edit. Compare on that clock, including another track's
67
- // relative end and the precision of both written track positions.
68
- const difference = (end + positionErrorTicks) * reference.timescale -
69
- (referenceEnd - (reference.positionErrorTicks ?? 0n)) * timescale;
70
- const denominator = timescale * reference.timescale;
71
- return 2n * difference * 1_000_000n +
72
- (2n * production.endMicros + 1n) * denominator >= 2n * cutMicros * denominator;
73
- }
74
- return (end + positionErrorTicks) * 1_000_000n >= cutMicros * timescale;
63
+ return end !== undefined && (end + positionErrorTicks) * 1_000_000n >= cutMicros * timescale;
75
64
  });
76
65
  return { whole, throughSeconds };
77
66
  }
78
67
  if (cutMicros !== null && throughSeconds < Number(cutMicros) / 1_000_000) {
79
-
80
68
  return { whole: false, throughSeconds };
81
69
  }
82
70
  return { whole: true, throughSeconds };
83
71
  }
84
72
 
85
- /** The muxer cuts relative to its first reference packet, not the requested seek. */
86
- export function productionOf(timing, cutTimes, relativeIndex, kind) {
87
- if (!timing || timing.originMicros === null || !Number.isFinite(cutTimes?.[relativeIndex])) return null;
88
- return {
89
- kind,
90
- endMicros: timing.endMicros,
91
- cutMicros: timing.originMicros + BigInt(Math.round(cutTimes[relativeIndex] * 1_000_000)) -
92
- BigInt(Math.round(SEGMENT_CUT_TIME_DELTA_SECONDS * 1_000_000))
93
- };
94
- }
95
-
96
73
  /**
97
- * The first CSV entry has start_time = 0 even after a seek (segment.c).
98
- * Recover its reference packet's PTS from the reported end minus the exact
99
- * sample span. The edit-list offset cancels, including its lost precision.
74
+ * How far after its requested start a run's segment muxer counts its cuts
75
+ * from, read from the run's FIRST piece, or `null` when that piece does not
76
+ * start within one frame of the start.
77
+ *
78
+ * With `endMicros`, the end of the piece in the muxer's own clock as its
79
+ * segment list states it, the first packet is that end less the span of the
80
+ * piece's samples: exact, because the span is a sum of sample durations and
81
+ * the muxer's clock needs no track position. Without it, the first sample's
82
+ * position is read, which the piece states only to within its position error.
83
+ *
84
+ * The muxer measures every cut time from its first packet of the reference
85
+ * stream, not from the start it was asked for (measured on ffmpeg 8.1.2,
86
+ * 2026-10-09: AAC copied from Matroska, asked to start at 10.385 s with its
87
+ * first packet at 10.403 s and a cut at 10.386 s from the start, closed the
88
+ * piece at 20.805 s — the first packet at or after 10.403 + 10.386). A copied
89
+ * stream cannot start between its packets, so its first packet lies up to one
90
+ * frame after the requested start, and every cut of the run moves by as much.
91
+ *
92
+ * @param {object} ranges - The first piece's media intervals.
93
+ * @param {number} startSeconds - Where the run was asked to start.
94
+ * @param {string} kind - The reference track: `vide` when the output carries a picture, else `soun`.
95
+ * @returns {number | null}
100
96
  */
101
- export function originOf(ranges, endMicros, kind) {
102
- const track = ranges?.tracks?.find(track => track.kind === kind);
103
- if (track?.firstSampleStart === undefined || !track.ranges.length) return null;
104
- const ticks = endMicros * track.timescale -
105
- (track.ranges.at(-1).end - track.firstSampleStart) * 1_000_000n;
106
- // CSV rounds to microseconds; rescale with the muxer's nearest rounding.
107
- return ticks >= 0n ? (2n * ticks + track.timescale) / (2n * track.timescale) :
108
- -((-2n * ticks + track.timescale) / (2n * track.timescale));
97
+ export function cutShiftOf(ranges, startSeconds, kind, endMicros = null) {
98
+ const track = ranges?.tracks?.find((candidate) => candidate.kind === kind);
99
+ const first = track?.ranges?.[0];
100
+ if (!first || !(track.timescale > 0n) || !Number.isFinite(startSeconds)) return null;
101
+ const timescale = Number(track.timescale);
102
+ const firstSeconds = typeof endMicros === "bigint" && track.firstSampleStart !== undefined
103
+ ? Number(endMicros) / 1_000_000 - Number(track.ranges.at(-1).end - track.firstSampleStart) / timescale
104
+ : Number(first.start) / timescale;
105
+ const shift = firstSeconds - startSeconds;
106
+ const allowance = Number((track.productionFrame ?? first.frame) + (track.positionErrorTicks ?? 0n)) / timescale;
107
+ return Math.abs(shift) <= allowance ? shift : null;
109
108
  }
110
109
 
111
110
  /** Compare adjacent production ranges in their declared track clocks. */
@@ -129,15 +128,19 @@ export function judgeNeighbors(left, right) {
129
128
  return { whole: true };
130
129
  }
131
130
 
132
- /** Every required track must cover the interval with at most one-frame error. */
133
- function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {} }) {
131
+ /**
132
+ * Every required track must cover the interval with at most one-frame error,
133
+ * on the clock the run's muxer cut by: the interval moved by `shift`
134
+ * ({@link cutShiftOf}). A track's own end in the file is not moved.
135
+ */
136
+ function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {}, shift = 0 }) {
134
137
  if (!Number.isFinite(from) || !Number.isFinite(to) || !(to > from) || !requiredKinds?.length) return "segment-interval-is-not-declared";
135
138
  for (const kind of requiredKinds) {
136
139
  const track = coverage?.tracks?.find(track => track.kind === kind);
137
140
  if (!track?.ranges?.length || !(track.timescale > 0n)) return `segment-missing-${kind}`;
138
- const start = BigInt(Math.round(from * Number(track.timescale)));
139
- const through = Number.isFinite(sourceEnds[kind]) ? Math.min(to, sourceEnds[kind]) : to;
140
- if (!(through > from)) return `segment-interval-is-empty-${kind}`;
141
+ const start = BigInt(Math.round((from + shift) * Number(track.timescale)));
142
+ const through = Number.isFinite(sourceEnds[kind]) ? Math.min(to + shift, sourceEnds[kind]) : to + shift;
143
+ if (!(through > from + shift)) return `segment-interval-is-empty-${kind}`;
141
144
  const end = BigInt(Math.round(through * Number(track.timescale)));
142
145
  const first = track.ranges[0];
143
146
  const frame = track.productionFrame ?? first.frame;