@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 +1 -1
- package/services/encode/EncodeRuns.js +3 -1
- package/services/encode/OutputOpening.js +8 -2
- package/services/encode/Renditions.js +12 -5
- package/services/encode/args.js +27 -112
- package/services/encode/calibration.js +12 -4
- package/services/encode/hwaccel.js +0 -1
- package/services/encode/output/Timeline.js +13 -2
- package/services/encode/quality/QualityOffer.js +4 -2
- package/services/encode/quality/link-budget.js +9 -5
- package/services/encode/quality/output-format.js +17 -12
- package/services/media/SourceFile.js +31 -0
- package/services/media/playback-planner.js +2 -0
- package/services/server/wire-outputs.js +6 -9
package/package.json
CHANGED
|
@@ -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: {
|
|
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(
|
|
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
|
-
*
|
|
484
|
-
*
|
|
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`);
|
package/services/encode/args.js
CHANGED
|
@@ -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
|
-
*
|
|
102
|
-
*
|
|
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
|
-
* @
|
|
83
|
+
* @param {{ width: number, height: number, pictureKbps: number | null } | null} source
|
|
84
|
+
* @returns {number | null}
|
|
180
85
|
*/
|
|
181
|
-
export function nominalKbpsFor(frame) {
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
188
|
-
|
|
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
|
-
* `
|
|
287
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
break;
|
|
286
|
+
activeRungs.delete(rung);
|
|
279
287
|
}
|
|
280
288
|
}
|
|
281
289
|
} finally {
|
|
@@ -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.
|
|
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.
|
|
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
|
|
99
|
-
*
|
|
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 {{
|
|
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
|
-
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
60
|
-
* The nominal
|
|
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(
|
|
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
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
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; },
|