@torrent-tv/proxy 2.95.35 → 2.95.37

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.35",
3
+ "version": "2.95.37",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -494,11 +494,13 @@ export class EncodeRuns {
494
494
  // each: 454 pieces against 401 on the field file.
495
495
  for (const [address, sessions] of byOutput) {
496
496
  const timeline = sessions[0].timeline;
497
+ const spec = sessions[0].spec;
497
498
  this.#host.encodeOrchestrator.notePriorityMap(
498
499
  address,
499
500
  timeline?.inSegments?.(
500
501
  this.#host.priority.mapForOutput(address),
501
- Number(timeline?.segmentCount) || 0
502
+ Number(timeline?.segmentCount) || 0,
503
+ { runsPastEnd: Boolean(spec?.audio) && spec?.transcodesAudio !== true }
502
504
  ) ?? []
503
505
  );
504
506
  }
@@ -546,7 +546,13 @@ export class OutputOpening {
546
546
  encodesPicture: transcodeVideo && carriesVideo,
547
547
  exact: forceExactSize,
548
548
  target: this.#targetFor({ normalizedTargetWidth, normalizedTargetHeight, visiblePicture, sourceWidth, sourceHeight }),
549
- source: { width: sourceWidth, height: sourceHeight, megabitsPerSecond: file.megabitsPerSecond, decode: file.decode },
549
+ source: {
550
+ width: sourceWidth,
551
+ height: sourceHeight,
552
+ megabitsPerSecond: file.megabitsPerSecond,
553
+ pictureKbps: file.pictureKbps,
554
+ decode: file.decode
555
+ },
550
556
  fps: outputFps,
551
557
  encoder: this.#host.videoEncoder,
552
558
  benchmark: this.#host.softwarePresetBenchmark,
@@ -561,7 +567,7 @@ export class OutputOpening {
561
567
  : null,
562
568
  tonemap: applyTonemap,
563
569
  capKbps,
564
- limitsFor: (frame) => this.#host.limitsFor(frame),
570
+ limitsFor: (frame) => this.#host.limitsFor(frame, file),
565
571
  audioLoad: viewerAudio !== undefined
566
572
  ? soundtrackLoadOf(viewerAudio?.entry ?? null, viewerAudio ? viewerAudio.transcode : null)
567
573
  : soundtrackLoadOf(audioSource.entry, audioTranscoded),
@@ -131,7 +131,14 @@ export class Renditions {
131
131
  wanted,
132
132
  judge: (spec) => linkCouldCarry(
133
133
  linkMbps,
134
- loadOf(videoLoadOfSpec(spec, base.file.megabitsPerSecond, this.#host.observedPeakMbps?.(spec) ?? null), audioLoad)
134
+ loadOf(
135
+ videoLoadOfSpec(
136
+ spec,
137
+ Number.isFinite(base.file.pictureKbps) ? base.file.pictureKbps / 1000 : null,
138
+ this.#host.observedPeakMbps?.(spec) ?? null
139
+ ),
140
+ audioLoad
141
+ )
135
142
  )
136
143
  });
137
144
  }
@@ -479,9 +486,9 @@ export class Renditions {
479
486
  * given, so a move that never completes costs them nothing.
480
487
  *
481
488
  * WHAT IS MOVED BETWEEN: outputs of the same material and the same produced
482
- * size whose bounds are two neighbouring limits of `limitsFor(frame)` — the
483
- * row of the frame on screen, chosen by its area, the same row the output was
484
- * opened in (`output-format.js`). Down,
489
+ * size whose bounds are two neighbouring limits of `limitsFor(frame, file)` — the
490
+ * source-scaled nominal for the frame on screen, the same nominal used
491
+ * to open the output (`output-format.js`). Down,
485
492
  * the highest lower limit their link admits; up, the next limit only, and
486
493
  * only if their link admits it. A copy has no limit and a hardware encode is
487
494
  * given none, so neither has anywhere to move to here, and the caller's other
@@ -521,7 +528,7 @@ export class Renditions {
521
528
  if (askedHeights.length === 0) {
522
529
  return refuse("no height is chosen as the output on screen");
523
530
  }
524
- const limits = this.#host.limitsFor({ width: encode.width, height: encode.height });
531
+ const limits = this.#host.limitsFor({ width: encode.width, height: encode.height }, base.file).filter(Number.isFinite);
525
532
  const at = limits.findIndex((limit) => maxrateKbpsFor(limit) === encode.rateControl.maxrateKbps);
526
533
  if (at < 0) {
527
534
  return refuse(`the limit on screen (maxrate ${encode.rateControl.maxrateKbps}k) is not one of ${encode.height}p's`);
@@ -13,7 +13,6 @@
13
13
  */
14
14
 
15
15
  import os from "node:os";
16
- import { LADDER_HEIGHTS } from "./output/ladder.js";
17
16
 
18
17
  export const SOFTWARE_PRESET = "ultrafast";
19
18
  export const SOFTWARE_CRF = "24";
@@ -65,27 +64,6 @@ export function chooseOutputFps(sourceFps, cap = MAX_OUTPUT_FPS) {
65
64
  // Software x264 on weak ARM hosts is the transcode bottleneck — use all cores.
66
65
  export const CPU_THREADS = Math.max(1, os.cpus().length);
67
66
 
68
- // Bitrate caps (constrained CRF). CRF stays the quality driver; -maxrate/
69
- // -bufsize only bound the peaks. Field evidence (iPhone on cellular,
70
- // 2026-07-10): uncapped complex scenes produced 4 s segments of ~18 Mbit/s
71
- // against a 1-6 Mbit/s viewer link — 45 s prebuffer, draining buffer.
72
- // Nominal H.264 rates per row; multipliers from webtor's production ladder
73
- // (content-transcoder): maxrate = 1.3x nominal, bufsize = 1.5x. The nominal
74
- // figures themselves came with no source when they were written (2026-07-10,
75
- // openspec `adaptive-bitrate`) and stand as an assumption until roadmap item
76
- // 97, step 14 measures them.
77
- const NOMINAL_KBPS_BY_ROW = new Map([
78
- [1080, 5000],
79
- [720, 2800],
80
- [480, 1400],
81
- [360, 800],
82
- [240, 400]
83
- ]);
84
- // The picture every row is named after: a 16:9 frame of the row's height, as
85
- // the encode itself would size one (`computeOutputDimensions` from a 16:9
86
- // source), so the reference is the frame the product makes and not a width
87
- // written down beside it.
88
- const ROW_REFERENCE_SOURCE = Object.freeze({ width: 3840, height: 2160 });
89
67
  /**
90
68
  * The rate a re-encoded soundtrack is asked for, in kbit/s: stereo AAC. A
91
69
  * TARGET, not a bound — ffmpeg's AAC encoder lets single frames run above it
@@ -98,95 +76,25 @@ const CAP_MAXRATE_FACTOR = 1.3;
98
76
  const CAP_BUFSIZE_FACTOR = 1.5;
99
77
 
100
78
  /**
101
- * The rows of limits, one per height of the ladder, each with the area of the
102
- * frame it is named after.
103
- *
104
- * @type {{ height: number, area: number }[] | null}
105
- */
106
- let limitRows = null;
107
-
108
- /**
109
- * @returns {{ height: number, area: number }[]}
110
- */
111
- function rows() {
112
- if (limitRows === null) {
113
- limitRows = LADDER_HEIGHTS.map((height) => {
114
- const frame = computeOutputDimensions(
115
- ROW_REFERENCE_SOURCE.width,
116
- height,
117
- ROW_REFERENCE_SOURCE.width,
118
- ROW_REFERENCE_SOURCE.height
119
- );
120
- return { height, area: frame.w * frame.h };
121
- });
122
- }
123
- return limitRows;
124
- }
125
-
126
- /**
127
- * The row whose frame area is nearest to this one, measured as a ratio rather
128
- * than a difference (decided with the user 2026-09-24): a rate grows with the
129
- * number of points multiplicatively, and the rows stand at uneven distances, so
130
- * the border between two rows is the geometric mean of their areas. On the
131
- * border itself the row with the larger area wins, so the choice has one answer.
132
- *
133
- * Keyed by area and not by height because a height alone names the wrong
134
- * frame for any picture that is not 16:9: a 2.4:1 film in a 1080 box is
135
- * encoded at about 1920x800, 1.54 million points, which a height puts in the
136
- * 720 row (0.92 million) although it is nearer the 1080 row (2.07 million).
137
- *
138
- * @param {{ width: number, height: number }} frame
139
- * @returns {number} The height the row is named after.
140
- */
141
- export function limitRowFor({ width, height }) {
142
- const area = Number(width) * Number(height);
143
- if (!(Number.isFinite(area) && area > 0)) {
144
- throw new RangeError(`a limit row is chosen for a frame, not for ${width}x${height}`);
145
- }
146
- return nearestByArea(rows(), area).height;
147
- }
148
-
149
- /**
150
- * @param {{ height: number, area: number }[]} candidates
151
- * @param {number} area
152
- * @returns {{ height: number, area: number }}
153
- */
154
- function nearestByArea(candidates, area) {
155
- // The larger area over the smaller: the same order as the distance of the
156
- // logarithms, without a logarithm. Taken as log(a / b) the two sides of a
157
- // border come out one last digit apart — log(2/3) is not exactly -log(3/2) —
158
- // and the tie rule below would never be reached.
159
- const ratioTo = (rowArea) => Math.max(area, rowArea) / Math.min(area, rowArea);
160
- let best = candidates[0];
161
- let bestDistance = ratioTo(best.area);
162
- for (const row of candidates.slice(1)) {
163
- const distance = ratioTo(row.area);
164
- if (distance < bestDistance || (distance === bestDistance && row.area > best.area)) {
165
- best = row;
166
- bestDistance = distance;
167
- }
168
- }
169
- return best;
170
- }
171
-
172
- /**
173
- * Nominal kbps for a frame: the nominal of the row its area falls in. A row
174
- * that has no nominal of its own yet (540, 1440 and 2160 today) takes the one
175
- * of the nearest row by area that has one, by the same rule, until step 14
176
- * measures it.
79
+ * Nominal video rate for an output frame, scaled from the measured source
80
+ * picture rate by pixel area. Missing source facts mean no safe ceiling.
177
81
  *
178
82
  * @param {{ width: number, height: number }} frame
179
- * @returns {number}
83
+ * @param {{ width: number, height: number, pictureKbps: number | null } | null} source
84
+ * @returns {number | null}
180
85
  */
181
- export function nominalKbpsFor(frame) {
182
- const row = limitRowFor(frame);
183
- const own = NOMINAL_KBPS_BY_ROW.get(row);
184
- if (own !== undefined) {
185
- return own;
86
+ export function nominalKbpsFor(frame, source) {
87
+ const width = Number(frame?.width);
88
+ const height = Number(frame?.height);
89
+ const sourceWidth = Number(source?.width);
90
+ const sourceHeight = Number(source?.height);
91
+ const pictureKbps = Number(source?.pictureKbps);
92
+ if (![width, height, sourceWidth, sourceHeight, pictureKbps].every(Number.isFinite) ||
93
+ width <= 0 || height <= 0 || sourceWidth <= 0 || sourceHeight <= 0 || pictureKbps <= 0) {
94
+ return null;
186
95
  }
187
- const named = rows().filter((candidate) => NOMINAL_KBPS_BY_ROW.has(candidate.height));
188
- const rowArea = rows().find((candidate) => candidate.height === row).area;
189
- return NOMINAL_KBPS_BY_ROW.get(nearestByArea(named, rowArea).height);
96
+ const nominal = pictureKbps * (width * height) / (sourceWidth * sourceHeight);
97
+ return Number.isFinite(nominal) && nominal > 0 ? Math.round(nominal) : null;
190
98
  }
191
99
 
192
100
  /**
@@ -282,12 +190,19 @@ export function h264LevelFor({ width, height, fps, maxrateKbps, bufsizeKbps }) {
282
190
  * higher level than the one every other output of this size declares, and the
283
191
  * request would then be answered with something other than what it asked for.
284
192
  *
285
- * @param {{ width: number, height: number, fps: number, capKbps?: number | null }} params
286
- * `capKbps` is the nominal rate asked for; absent, the size's own.
287
- * @returns {{ maxrateKbps: number, bufsizeKbps: number, level: string | null }}
193
+ * @param {{ width: number, height: number, fps: number, source: object | null, capKbps?: number | null }} params
194
+ * `source` supplies source dimensions and measured picture rate. `capKbps`
195
+ * is an explicit nominal limit; absent, the source-scaled rate is used.
196
+ * @returns {{ maxrateKbps: number, bufsizeKbps: number, level: string | null } | null}
288
197
  */
289
- export function softwareRateControlFor({ width, height, fps, capKbps = null }) {
290
- const nominal = nominalKbpsFor({ width, height });
198
+ export function softwareRateControlFor({ width, height, fps, source = null, capKbps = null }) {
199
+ const nominal = nominalKbpsFor({ width, height }, source);
200
+ if (nominal === null && (capKbps === null || capKbps === undefined)) {
201
+ return null;
202
+ }
203
+ if (nominal === null) {
204
+ throw new RangeError(`a bitrate limit for ${width}x${height} needs a measured source picture rate`);
205
+ }
291
206
  const asked = capKbps === null || capKbps === undefined ? nominal : Number(capKbps);
292
207
  if (!(Number.isFinite(asked) && asked > 0)) {
293
208
  throw new RangeError(`a bitrate limit must be a positive number of kbit/s, not ${capKbps}`);
@@ -74,6 +74,9 @@ export const CALIBRATION_FRAMES = Object.freeze([
74
74
 
75
75
  /** The size the correctness check encodes at, and every earlier reading was taken at. */
76
76
  const CHECK_FRAME = Object.freeze({ width: 640, height: 360 });
77
+ // Measured from the bundled 1920x1080 calibration clip with ffprobe. It has no
78
+ // audio streams, so its whole-file rate is its picture rate.
79
+ const CALIBRATION_SOURCE = Object.freeze({ width: 1920, height: 1080, pictureKbps: 19653 });
77
80
 
78
81
  /**
79
82
  * @typedef {object} SizeReading
@@ -121,7 +124,7 @@ export async function checkModeProducesCorrectSegments({ ffmpegBin, encoder, run
121
124
  // Three segments' worth, so at least two are cut and each is checked alone.
122
125
  const seconds = segmentDurationSec * 3;
123
126
  const rateControl = encoder.kind === "software"
124
- ? softwareRateControlFor({ ...CHECK_FRAME, fps: TRANSCODE_FPS })
127
+ ? softwareRateControlFor({ ...CHECK_FRAME, fps: TRANSCODE_FPS, source: CALIBRATION_SOURCE })
125
128
  : null;
126
129
  const args = [
127
130
  "-hide_banner", "-loglevel", "error",
@@ -237,6 +240,10 @@ export async function calibrateEncoder({
237
240
 
238
241
  /** @type {Map<string | null, SizeReading[]>} */
239
242
  const readings = new Map(qualified.map((rung) => [rung, []]));
243
+ // Each preset remains in the size walk until its own reading falls below
244
+ // realtime. A faster preset's result says nothing reliable about a slower
245
+ // preset when readings vary with load.
246
+ const activeRungs = new Set(qualified);
240
247
  /** The share of the machine other work left each reading. @type {number[]} */
241
248
  const freeShares = [];
242
249
  const sortedFrames = [...frames].sort((left, right) => left.width * left.height - right.width * right.height);
@@ -254,6 +261,7 @@ export async function calibrateEncoder({
254
261
  // Fastest first: the ladder is slowest first, so walk it backwards.
255
262
  for (let index = qualified.length - 1; index >= 0; index -= 1) {
256
263
  const rung = qualified[index];
264
+ if (!activeRungs.has(rung)) continue;
257
265
  const reading = await speed(rung, rawPath, frame);
258
266
  // A number from a check that replaces the reading; the reading itself
259
267
  // carries the share of the machine other work left it.
@@ -263,7 +271,8 @@ export async function calibrateEncoder({
263
271
  }
264
272
  if (realtimes === null) {
265
273
  log.warn(`calibration: ${encoder.name} "${rung ?? "as it comes"}" gave no reading at ${frame.width}x${frame.height}`);
266
- break;
274
+ activeRungs.delete(rung);
275
+ continue;
267
276
  }
268
277
  readings.get(rung).push({
269
278
  width: frame.width,
@@ -274,8 +283,7 @@ export async function calibrateEncoder({
274
283
  fastestKeptUp = realtimes >= 1;
275
284
  }
276
285
  if (realtimes < 1) {
277
- // Every slower mode is slower still at this size.
278
- break;
286
+ activeRungs.delete(rung);
279
287
  }
280
288
  }
281
289
  } finally {
@@ -53,7 +53,6 @@ import {
53
53
  export {
54
54
  chooseOutputFps,
55
55
  maxrateKbpsFor,
56
- limitRowFor,
57
56
  nominalKbpsFor,
58
57
  TRANSCODE_FPS
59
58
  } from "./args.js";
@@ -159,10 +159,13 @@ export class Timeline {
159
159
  * @param {import("../../viewer/PriorityMap.js").PriorityMap} map - One number
160
160
  * per second of film.
161
161
  * @param {number} segmentCount - How many pieces this output has.
162
+ * @param {{ runsPastEnd?: boolean }} [shape] - `runsPastEnd`: the output's
163
+ * pieces copy a stream, which is cut where its packets end, so a piece
164
+ * holds sound past its published end by up to one packet.
162
165
  * @returns {import("../../viewer/PriorityMap.js").DemandZone[]} Runs of pieces
163
166
  * that agree. Empty where nothing is stated, which says nobody is coming.
164
167
  */
165
- inSegments(map, segmentCount) {
168
+ inSegments(map, segmentCount, { runsPastEnd = false } = {}) {
166
169
  if (!(segmentCount > 0) || !map || !(map.durationSeconds > 0)) {
167
170
  return [];
168
171
  }
@@ -173,9 +176,17 @@ export class Timeline {
173
176
  // whole, so it is wanted as soon as the soonest second inside it is
174
177
  // wanted — and the piece a viewer is standing in the middle of is wanted
175
178
  // exactly as much as the second under their feet.
179
+ // A COPIED PIECE ALSO HOLDS THE SECOND ITS END FALLS IN. A copy ends at
180
+ // the first packet boundary at or after its published end, and the
181
+ // player places the next piece where this one truly ends (hls.js
182
+ // `updateFragPTSDTS` carries a parsed piece's end to every later one).
183
+ // Field 2026-10-09, MP3 copied out of an AVI on a 4 s grid: pieces ended
184
+ // up to 24 ms late, and a seek to 4000 s asked for the piece ending at
185
+ // 4000 s, which nothing made because second 4000 belonged to the next.
176
186
  const from = Math.max(0, Math.floor(this.publishedStartOf(index)));
187
+ const end = this.publishedStartOf(index + 1);
177
188
  const until = index + 1 < segmentCount
178
- ? Math.max(from + 1, Math.ceil(this.publishedStartOf(index + 1)))
189
+ ? Math.max(from + 1, runsPastEnd ? Math.floor(end) + 1 : Math.ceil(end))
179
190
  : map.durationSeconds;
180
191
  let priority = 0;
181
192
  let withinSeconds = Number.POSITIVE_INFINITY;
@@ -404,12 +404,14 @@ export class QualityOffer {
404
404
  if (!(frame.width > 0)) {
405
405
  // No source size read yet: the frame, and so its row, cannot be named,
406
406
  // and a load that cannot be named is unknown rather than a guess.
407
- return videoLoadForFrame({ sourceHeight, copiesAtSource: false, sourceMbps: null, encoderKind: "" }, frame);
407
+ return videoLoadForFrame({ sourceWidth: 0, sourceHeight, sourcePictureKbps: null, copiesAtSource: false, sourceMbps: null, encoderKind: "" }, frame);
408
408
  }
409
409
  return videoLoadForFrame({
410
+ sourceWidth,
410
411
  sourceHeight,
411
412
  copiesAtSource: !base.spec.transcodesVideo,
412
- sourceMbps: base.file.megabitsPerSecond,
413
+ sourceMbps: Number.isFinite(base.file.pictureKbps) ? base.file.pictureKbps / 1000 : null,
414
+ sourcePictureKbps: base.file.pictureKbps,
413
415
  encoderKind
414
416
  }, frame);
415
417
  }
@@ -95,11 +95,10 @@ export function videoLoadOfSpec(spec, sourceMbps, observedPeakMbps = null) {
95
95
  * with no limit on a hardware one, and as the source's own stream when the
96
96
  * picture is copied at the source's height.
97
97
  *
98
- * The nominal limit is the one of the FRAME the height would be encoded at,
99
- * chosen by its area (`nominalKbpsFor`) — the same row the output is then
100
- * opened in, so the offer and the output cannot price one step differently.
98
+ * The nominal limit scales this file's measured picture rate by the output
99
+ * frame's area, matching the rate control used when the output is opened.
101
100
  *
102
- * @param {{ sourceHeight: number, copiesAtSource: boolean, sourceMbps: number | null, encoderKind: string }} picture
101
+ * @param {{ sourceWidth: number, sourceHeight: number, sourceMbps: number | null, sourcePictureKbps: number | null, copiesAtSource: boolean, encoderKind: string }} picture
103
102
  * @param {{ width: number, height: number }} frame - The frame this height is
104
103
  * encoded at for this source.
105
104
  * @returns {LoadPart}
@@ -112,7 +111,12 @@ export function videoLoadForFrame(picture, frame) {
112
111
  if (picture?.encoderKind !== "software") {
113
112
  return part(null, PEAK_CLASS.UNKNOWN);
114
113
  }
115
- return part(maxrateKbpsFor(nominalKbpsFor(frame)) / 1000, PEAK_CLASS.KNOWN);
114
+ const nominal = nominalKbpsFor(frame, {
115
+ width: picture.sourceWidth,
116
+ height: picture.sourceHeight,
117
+ pictureKbps: picture.sourcePictureKbps
118
+ });
119
+ return part(nominal === null ? null : maxrateKbpsFor(nominal) / 1000, PEAK_CLASS.KNOWN);
116
120
  }
117
121
 
118
122
  /**
@@ -13,10 +13,10 @@
13
13
  * 3. on a hardware encoder, or with no startup measurement, there is no ladder
14
14
  * to choose from and the box asked for is produced.
15
15
  *
16
- * The rate control is part of the format: a software encode is bounded at a
17
- * limit from `limitsFor(frame)` — the nominal one until a set of lower limits
18
- * is decided — or at the limit asked for (`capKbps`), and declared at the
19
- * level of that size's nominal output. A hardware encoder is given no bound.
16
+ * The rate control is part of the format: a software encode is bounded at the
17
+ * source-scaled nominal from `limitsFor(frame, source)` or at the limit asked
18
+ * for (`capKbps`), and declared at that source and size's nominal level. A
19
+ * hardware encoder is given no bound. Unknown source rates mean no bound.
20
20
  *
21
21
  * AND THE VIEWER'S LINK DECIDES WHETHER IT MAY BE GIVEN AT ALL (roadmap item
22
22
  * 97, step 11). Every format considered is judged by what this viewer's link
@@ -45,7 +45,7 @@ import { linkAnswerFigures, linkCouldCarry, linkRefusalReason, loadOf, videoLoad
45
45
  * @param {boolean} params.encodesPicture - The output carries a picture and re-encodes it.
46
46
  * @param {boolean} params.exact - The size is produced exactly as asked.
47
47
  * @param {{ width: number, height: number }} params.target - The box asked for; zeroes mean the source's.
48
- * @param {{ width: number, height: number, megabitsPerSecond: number | null, decode: object | null }} params.source
48
+ * @param {{ width: number, height: number, megabitsPerSecond: number | null, pictureKbps?: number | null, decode: object | null }} params.source
49
49
  * @param {number} params.fps
50
50
  * @param {{ kind: string, name: string }} params.encoder
51
51
  * @param {unknown} params.benchmark - The startup measurement of the software encoder, or null.
@@ -56,9 +56,8 @@ import { linkAnswerFigures, linkCouldCarry, linkRefusalReason, loadOf, videoLoad
56
56
  * @param {boolean} params.tonemap
57
57
  * @param {number | null} [params.capKbps] - A nominal limit asked for outright;
58
58
  * above the size's own it is refused (`softwareRateControlFor`).
59
- * @param {(frame: { width: number, height: number }) => number[]} [params.limitsFor] -
60
- * The nominal limits a frame may be produced at, highest first; the row is
61
- * chosen by the frame's area (`limitRowFor`).
59
+ * @param {(frame: { width: number, height: number }, source: object) => Array<number | null>} [params.limitsFor] -
60
+ * The nominal limit derived from this source's picture rate and frame area.
62
61
  * @param {import("./link-budget.js").LoadPart | null} [params.audioLoad] - The
63
62
  * soundtrack this viewer receives with the picture, from it or beside it.
64
63
  * @param {(encode: object | null) => OutputSpec} params.specWith - The output
@@ -85,7 +84,9 @@ export function decideOutputFormat({
85
84
  chooseBudget,
86
85
  tonemap,
87
86
  capKbps = null,
88
- limitsFor = (frame) => [nominalKbpsFor(frame)],
87
+ limitsFor = (frame, source) => [nominalKbpsFor(frame, {
88
+ width: source.width, height: source.height, pictureKbps: source.pictureKbps
89
+ })],
89
90
  audioLoad = null,
90
91
  specWith,
91
92
  serving,
@@ -93,7 +94,11 @@ export function decideOutputFormat({
93
94
  }) {
94
95
  const judge = (spec) => linkCouldCarry(
95
96
  serving.linkMbps,
96
- loadOf(videoLoadOfSpec(spec, source.megabitsPerSecond, serving.observedPeakMbps?.(spec) ?? null), audioLoad)
97
+ loadOf(
98
+ videoLoadOfSpec(spec, Number.isFinite(source.pictureKbps) ? source.pictureKbps / 1000 : null,
99
+ serving.observedPeakMbps?.(spec) ?? null),
100
+ audioLoad
101
+ )
97
102
  );
98
103
  const given = (spec, answer, extra = {}) => ({
99
104
  spec,
@@ -196,7 +201,7 @@ export function decideOutputFormat({
196
201
  ? [null]
197
202
  : capKbps !== null && capKbps !== undefined && w === width && h === height
198
203
  ? [capKbps]
199
- : limitsFor({ width: w, height: h });
204
+ : limitsFor({ width: w, height: h }, source);
200
205
  return limits.map((limit) => specWith({
201
206
  encoder: encoder.name,
202
207
  width: w,
@@ -204,7 +209,7 @@ export function decideOutputFormat({
204
209
  fps,
205
210
  preset: presetFor(w, h),
206
211
  tonemap,
207
- rateControl: limit === null ? null : softwareRateControlFor({ width: w, height: h, fps, capKbps: limit })
212
+ rateControl: limit === null ? null : softwareRateControlFor({ width: w, height: h, fps, source, capKbps: limit })
208
213
  }));
209
214
  };
210
215
  const wantedFormats = formatsAt(width, height);
@@ -60,6 +60,32 @@ export function sourceDecodeCharacteristics(mediaInfo) {
60
60
  };
61
61
  }
62
62
 
63
+ /**
64
+ * The source picture's average rate, derived from the file rate after removing
65
+ * the rates stated for every embedded audio track. A missing track rate makes
66
+ * the picture rate unknown rather than treating the whole file rate as video.
67
+ *
68
+ * @param {{ bitrateKbps?: number | null, audioTracks?: Array<{ bitrateKbps?: number | null }> } | null} mediaInfo
69
+ * @returns {number | null}
70
+ */
71
+ export function sourcePictureBitrateKbps(mediaInfo) {
72
+ const totalKbps = Number(mediaInfo?.bitrateKbps);
73
+ const audioTracks = mediaInfo?.audioTracks;
74
+ if (!(Number.isFinite(totalKbps) && totalKbps > 0) || !Array.isArray(audioTracks)) {
75
+ return null;
76
+ }
77
+ let audioKbps = 0;
78
+ for (const track of audioTracks) {
79
+ const rate = Number(track?.bitrateKbps);
80
+ if (!(Number.isFinite(rate) && rate > 0)) {
81
+ return null;
82
+ }
83
+ audioKbps += rate;
84
+ }
85
+ const pictureKbps = totalKbps - audioKbps;
86
+ return Number.isFinite(pictureKbps) && pictureKbps > 0 ? Math.round(pictureKbps) : null;
87
+ }
88
+
63
89
  export class SourceFile {
64
90
  /**
65
91
  * @param {object} params
@@ -195,6 +221,11 @@ export class SourceFile {
195
221
  return Number.isFinite(kbps) && kbps > 0 ? kbps / 1000 : null;
196
222
  }
197
223
 
224
+ /** The source picture's average rate, or null until every required fact is known. */
225
+ get pictureKbps() {
226
+ return sourcePictureBitrateKbps(this.media);
227
+ }
228
+
198
229
  /**
199
230
  * The address this proxy's own HTTP serves this file at.
200
231
  *
@@ -716,6 +716,7 @@ export function createPlaybackPlanner({
716
716
  height: videoFacts.height,
717
717
  fps: videoFacts.fps,
718
718
  bitrateKbps: probe.bitrateKbps,
719
+ audioTracks: probe.audioTracks,
719
720
  // Which family of the decode measurement prices this source. A video
720
721
  // that has to be re-encoded is one the browser could not play, so it
721
722
  // is usually NOT H.264, and H.264 constants are wrong for it.
@@ -754,6 +755,7 @@ export function createPlaybackPlanner({
754
755
  width: videoFacts.width,
755
756
  height: videoFacts.height,
756
757
  bitrateKbps: probe.bitrateKbps,
758
+ audioTracks: probe.audioTracks,
757
759
  fps: videoFacts.fps,
758
760
  startTime: probe.startTimeSeconds,
759
761
  isHdr: videoFacts.isHdr,
@@ -268,13 +268,10 @@ export function wireOutputs({
268
268
  const count = Number(session?.timeline?.segmentCount) || 0;
269
269
  return count > 0 && parts.segmentStore.provenNumbers(session.outputKey).length >= count;
270
270
  };
271
- // The nominal limits a size may be produced at, highest first. ONE list, read
272
- // by the opening of an output and by the move between limits of a height, so
273
- // the two cannot disagree about which limits exist. Only the size's own until
274
- // a set of lower limits is decided (roadmap item 97, step 14): a lower limit
275
- // handed to a viewer must come with the page saying so. Asked with the whole
276
- // frame, because the row is chosen by area (`limitRowFor`).
277
- parts.limitsFor = (frame) => [nominalKbpsFor(frame)];
271
+ // The limit follows this file's measured picture rate and the output frame;
272
+ // no rate means no ceiling. The same answer is used when opening and moving
273
+ // between limits, so one size of one file keeps one declared H.264 level.
274
+ parts.limitsFor = (frame, file) => [nominalKbpsFor(frame, file)];
278
275
  // What the torrent and the proxy itself spend on this host, and how fast each watched torrent moves.
279
276
  parts.hostLoad = new HostLoad({
280
277
  readMetadataActivity,
@@ -418,7 +415,7 @@ export function wireOutputs({
418
415
  // item 97, step 12): which heights their choice is the output on screen
419
416
  // under, the segment they were last given, the move being prepared, and
420
417
  // whether a segment is closed on the output being prepared.
421
- limitsFor: (frame) => parts.limitsFor(frame),
418
+ limitsFor: (frame, file) => parts.limitsFor(frame, file),
422
419
  heightsChosenAs: (consumerId, outputKey) => heightsChosenAs(parts.viewers, consumerId, outputKey),
423
420
  highestGivenSegmentOf: (consumerId, askedHeight) => highestGivenSegmentOf(parts.viewers, consumerId, askedHeight),
424
421
  sameHeightSwitchOf: (consumerId) => sameHeightSwitchOf(parts.viewers, consumerId),
@@ -522,7 +519,7 @@ export function wireOutputs({
522
519
  // The largest peak an output of this mode and rate control has been seen
523
520
  // carrying here: the only figure an encoder with no bound of its own has.
524
521
  observedPeakMbps: (spec) => parts.localObservations?.peakMbps(spec) ?? null,
525
- limitsFor: (frame) => parts.limitsFor(frame),
522
+ limitsFor: (frame, file) => parts.limitsFor(frame, file),
526
523
  readSourceMedia,
527
524
  get encodeInputs() { return parts.encodeInputs; },
528
525
  get decodeCostModel() { return parts.decodeCostModel; },