@torrent-tv/proxy 2.53.0 → 2.55.0
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/CHANGELOG.md +27 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +697 -169
- package/services/hwaccel.js +396 -49
- package/services/segment-formats/fmp4.js +320 -300
- package/services/segment-formats/mp4-boxes.js +59 -0
- package/test/auto-quality-step.test.js +442 -0
- package/test/decode-measurement.test.js +73 -0
- package/test/quality-variants.test.js +6 -5
package/services/hwaccel.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { spawn } from "node:child_process";
|
|
24
24
|
import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
25
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
25
26
|
import os from "node:os";
|
|
26
27
|
import path from "node:path";
|
|
27
28
|
import { fitDecodeCost } from "./decode-cost-fit.js";
|
|
@@ -118,16 +119,59 @@ export function nominalKbpsForHeight(height) {
|
|
|
118
119
|
return best[1];
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The peak this encode may reach, in kbit/s, for a nominal rate.
|
|
124
|
+
*
|
|
125
|
+
* Exported because the same figure answers a second question: whether a rung
|
|
126
|
+
* fits the viewer's measured link. The budget compares the link against what
|
|
127
|
+
* the encode is ALLOWED to peak at rather than against what it happened to
|
|
128
|
+
* produce in the last few segments, so a rung is judged by the bound we impose
|
|
129
|
+
* on it and not by a quiet stretch of the film.
|
|
130
|
+
*
|
|
131
|
+
* @param {number} nominalKbps
|
|
132
|
+
* @returns {number}
|
|
133
|
+
*/
|
|
134
|
+
export function maxrateKbpsFor(nominalKbps) {
|
|
135
|
+
return Math.round(nominalKbps * CAP_MAXRATE_FACTOR);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The nominal rate whose cap is a given peak — the inverse of
|
|
140
|
+
* {@link maxrateKbpsFor}.
|
|
141
|
+
*
|
|
142
|
+
* Used to turn a MEASURED limit into the figure the cap arithmetic takes. The
|
|
143
|
+
* viewer's usable link is a peak the stream must not exceed, and the encoder is
|
|
144
|
+
* configured from a nominal rate, so the two are converted through the one
|
|
145
|
+
* factor rather than through a second constant invented for the purpose.
|
|
146
|
+
*
|
|
147
|
+
* @param {number} maxrateKbps
|
|
148
|
+
* @returns {number}
|
|
149
|
+
*/
|
|
150
|
+
export function nominalKbpsForMaxrate(maxrateKbps) {
|
|
151
|
+
return Math.round(maxrateKbps / CAP_MAXRATE_FACTOR);
|
|
152
|
+
}
|
|
153
|
+
|
|
121
154
|
/**
|
|
122
155
|
* `-maxrate`/`-bufsize` args for an encode height (constrained CRF).
|
|
123
156
|
*
|
|
157
|
+
* `nominalKbps` overrides the height's own nominal rate. It is how a measured
|
|
158
|
+
* limit — the viewer's link, the only figure that bounds an encode from
|
|
159
|
+
* outside this host — reaches the encoder without touching the picture's SIZE.
|
|
160
|
+
* That distinction is the whole point: `-maxrate`, `-bufsize` and CRF do not
|
|
161
|
+
* appear in the SPS (x264 writes no HRD parameters by default), so they can be
|
|
162
|
+
* moved in the middle of a session while one init segment goes on describing
|
|
163
|
+
* every fragment. The size cannot.
|
|
164
|
+
*
|
|
124
165
|
* @param {number} height
|
|
166
|
+
* @param {number | null} [nominalKbps=null]
|
|
125
167
|
* @returns {string[]}
|
|
126
168
|
*/
|
|
127
|
-
function bitrateCapArgs(height) {
|
|
128
|
-
const nominal =
|
|
169
|
+
function bitrateCapArgs(height, nominalKbps = null) {
|
|
170
|
+
const nominal = Number.isFinite(nominalKbps) && nominalKbps > 0
|
|
171
|
+
? nominalKbps
|
|
172
|
+
: nominalKbpsForHeight(height);
|
|
129
173
|
return [
|
|
130
|
-
"-maxrate", `${
|
|
174
|
+
"-maxrate", `${maxrateKbpsFor(nominal)}k`,
|
|
131
175
|
"-bufsize", `${Math.round(nominal * CAP_BUFSIZE_FACTOR)}k`
|
|
132
176
|
];
|
|
133
177
|
}
|
|
@@ -221,7 +265,7 @@ export function softwareDescriptor() {
|
|
|
221
265
|
kind: "software",
|
|
222
266
|
device: null,
|
|
223
267
|
inputArgs: [],
|
|
224
|
-
buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset, fps, tonemap, forcedKeyframeTimes }) {
|
|
268
|
+
buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset, fps, tonemap, forcedKeyframeTimes, nominalKbps = null }) {
|
|
225
269
|
const { w, h } = safeDimensions(targetWidth, targetHeight);
|
|
226
270
|
const chosenPreset = typeof preset === "string" && preset.length > 0 ? preset : SOFTWARE_PRESET;
|
|
227
271
|
// Output frame rate: inherited from the source (rounded/capped) by the
|
|
@@ -249,7 +293,7 @@ export function softwareDescriptor() {
|
|
|
249
293
|
// cannot produce segments a thin viewer link (cellular) can't
|
|
250
294
|
// download in time. Sized by the TARGET box height (the rung the
|
|
251
295
|
// budget/manual selection chose).
|
|
252
|
-
...bitrateCapArgs(h),
|
|
296
|
+
...bitrateCapArgs(h, nominalKbps),
|
|
253
297
|
"-threads", String(CPU_THREADS),
|
|
254
298
|
"-pix_fmt", "yuv420p",
|
|
255
299
|
// Fixed GOP: a keyframe exactly every (segmentDurationSec × fps) frames,
|
|
@@ -396,7 +440,7 @@ function v4l2m2mDescriptor() {
|
|
|
396
440
|
* @property {"software"|"vaapi"|"qsv"|"nvenc"|"v4l2m2m"} kind
|
|
397
441
|
* @property {string|null} device
|
|
398
442
|
* @property {string[]} inputArgs
|
|
399
|
-
* @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number, preset?: string, fps?: number, tonemap?: boolean, forcedKeyframeTimes?: number[] | null }) => string[]} buildVideoArgs
|
|
443
|
+
* @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number, preset?: string, fps?: number, tonemap?: boolean, forcedKeyframeTimes?: number[] | null, nominalKbps?: number | null }) => string[]} buildVideoArgs
|
|
400
444
|
*/
|
|
401
445
|
|
|
402
446
|
/**
|
|
@@ -720,10 +764,20 @@ const CALIBRATION_SETS = {
|
|
|
720
764
|
const CALIBRATION_CLIPS = CALIBRATION_SETS.h264;
|
|
721
765
|
const CALIBRATION_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "assets", "calibration");
|
|
722
766
|
// How wide the measured window must be before the slope is trusted, and how
|
|
723
|
-
// long to wait for it at most.
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
|
|
767
|
+
// long to wait for it at most.
|
|
768
|
+
//
|
|
769
|
+
// Half a second, and it is the TIMING noise that sets it rather than the amount
|
|
770
|
+
// of video: the slope is output time against wall time, both read from the same
|
|
771
|
+
// two progress lines, and the jitter in stamping one is milliseconds — so half
|
|
772
|
+
// a second of window is a fraction of a percent of error on any host. What used
|
|
773
|
+
// to make a longer window necessary was the clip restarting inside it, and that
|
|
774
|
+
// is gone: the stream is continuous now. Measured 2026-08-22 against the
|
|
775
|
+
// continuous-pass truth on a desktop: -3.0 % and +0.6 % at half a second, with
|
|
776
|
+
// the readings spread 2-6 %, against -25 % and -33 % for the loop it replaces.
|
|
777
|
+
// Half a second also costs the startup about 0.6 s per clip less, which matters
|
|
778
|
+
// because every clip of every codec family is paid for before any viewer
|
|
779
|
+
// exists.
|
|
780
|
+
const DECODE_WINDOW_MIN_SEC = 0.5;
|
|
727
781
|
const DECODE_WINDOW_MAX_MS = 8000;
|
|
728
782
|
|
|
729
783
|
/**
|
|
@@ -801,8 +855,18 @@ export async function benchmarkContention({ ffmpegBin, logger, clipsDir = CALIBR
|
|
|
801
855
|
// company, not the clip's own cost, so the smallest one says it soonest.
|
|
802
856
|
const clip = path.join(clipsDir, "cal-h264-480-lo.mp4");
|
|
803
857
|
const startedAt = Date.now();
|
|
804
|
-
|
|
805
|
-
|
|
858
|
+
// Lifted once and decoded three times from the same bytes. Going through
|
|
859
|
+
// `measureDecodeSlope` lifted it again for every reading — three process
|
|
860
|
+
// starts on a path that is awaited before the proxy's tunnel opens, for a
|
|
861
|
+
// remux whose result had not changed.
|
|
862
|
+
const streams = await extractFamilyStreams(ffmpegBin, [clip], "h264");
|
|
863
|
+
const stream = streams?.[0];
|
|
864
|
+
if (!stream) {
|
|
865
|
+
log.warn("hwaccel: contention could not be measured; costs will be added as though jobs were independent");
|
|
866
|
+
return null;
|
|
867
|
+
}
|
|
868
|
+
const alone = await decodePipedStream(ffmpegBin, stream);
|
|
869
|
+
if (!alone?.speed) {
|
|
806
870
|
log.warn("hwaccel: contention could not be measured; costs will be added as though jobs were independent");
|
|
807
871
|
return null;
|
|
808
872
|
}
|
|
@@ -828,8 +892,8 @@ export async function benchmarkContention({ ffmpegBin, logger, clipsDir = CALIBR
|
|
|
828
892
|
await new Promise((resolve) => {
|
|
829
893
|
setTimeout(resolve, 2_000);
|
|
830
894
|
});
|
|
831
|
-
const withCompany = await
|
|
832
|
-
if (withCompany) {
|
|
895
|
+
const withCompany = await decodePipedStream(ffmpegBin, stream);
|
|
896
|
+
if (withCompany?.speed) {
|
|
833
897
|
beside.push({ others, speed: withCompany.speed });
|
|
834
898
|
}
|
|
835
899
|
}
|
|
@@ -904,10 +968,29 @@ async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips }) {
|
|
|
904
968
|
const startedAllAt = Date.now();
|
|
905
969
|
/** @type {Array<{ megapixelsPerSecond: number, megabitsPerSecond: number, costSecondsPerSecond: number }>} */
|
|
906
970
|
const samples = [];
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
971
|
+
// Every clip of the family is lifted out of its container FIRST, in one
|
|
972
|
+
// ffmpeg run. See `extractFamilyStreams` for why one run rather than one per
|
|
973
|
+
// clip, and why before the measurements rather than beside them.
|
|
974
|
+
const streams = await extractFamilyStreams(
|
|
975
|
+
ffmpegBin,
|
|
976
|
+
clips.map((clip) => path.join(clipsDir, clip)),
|
|
977
|
+
family
|
|
978
|
+
);
|
|
979
|
+
if (!streams) {
|
|
980
|
+
log.warn(
|
|
981
|
+
`hwaccel: ${family} cannot be lifted out of its container — no Annex-B filter is mapped for it, ` +
|
|
982
|
+
`so its clips were never measured`
|
|
983
|
+
);
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
for (const [index, clip] of clips.entries()) {
|
|
987
|
+
const stream = streams[index];
|
|
988
|
+
const measured = stream ? await decodePipedStream(ffmpegBin, stream) : null;
|
|
989
|
+
if (!measured?.speed) {
|
|
990
|
+
log.warn(
|
|
991
|
+
`hwaccel: decode benchmark "${clip}" said nothing; ${family} not measured` +
|
|
992
|
+
(measured?.error ? ` — ${measured.error}` : " — the clip could not be lifted out of its container")
|
|
993
|
+
);
|
|
911
994
|
return null;
|
|
912
995
|
}
|
|
913
996
|
const cost = 1 / measured.speed;
|
|
@@ -941,37 +1024,277 @@ async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips }) {
|
|
|
941
1024
|
}
|
|
942
1025
|
|
|
943
1026
|
/**
|
|
944
|
-
*
|
|
1027
|
+
* The bitstream filter and demuxer that turn a clip's video track into a
|
|
1028
|
+
* continuous elementary stream, by codec family.
|
|
1029
|
+
*
|
|
1030
|
+
* H.264 and HEVC in MP4 keep their parameter sets in the container's `avcC` /
|
|
1031
|
+
* `hvcC` and their access units length-prefixed; Annex-B carries them inline,
|
|
1032
|
+
* with start codes, which is what makes plain byte concatenation a valid
|
|
1033
|
+
* stream. That is the property this whole measurement rests on.
|
|
1034
|
+
*/
|
|
1035
|
+
const ANNEX_B_BY_FAMILY = {
|
|
1036
|
+
h264: { filter: "h264_mp4toannexb", demuxer: "h264" },
|
|
1037
|
+
hevc: { filter: "hevc_mp4toannexb", demuxer: "hevc" },
|
|
1038
|
+
hevc10: { filter: "hevc_mp4toannexb", demuxer: "hevc" }
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* The last complaint in an ffmpeg stderr, for a line that has to say why.
|
|
1043
|
+
*
|
|
1044
|
+
* @param {string} stderr
|
|
1045
|
+
* @returns {string}
|
|
1046
|
+
*/
|
|
1047
|
+
function lastErrorLine(stderr) {
|
|
1048
|
+
const lines = String(stderr ?? "")
|
|
1049
|
+
.split(/\r?\n/)
|
|
1050
|
+
.map((line) => line.trim())
|
|
1051
|
+
.filter((line) => line.length > 0);
|
|
1052
|
+
return lines[lines.length - 1] ?? "";
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* How long the lift may take before it is abandoned. It is a remux of a few
|
|
1057
|
+
* megabytes, so this is not a budget — it is the difference between a startup
|
|
1058
|
+
* that reports a failure and one that never finishes. Every other ffmpeg run in
|
|
1059
|
+
* this file has such a bound; this one did not, and it is awaited before the
|
|
1060
|
+
* proxy's tunnel opens.
|
|
1061
|
+
*/
|
|
1062
|
+
const EXTRACT_TIMEOUT_MS = 20_000;
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Lift a whole family's clips out of their containers, as Annex-B elementary
|
|
1066
|
+
* streams, in ONE ffmpeg run.
|
|
1067
|
+
*
|
|
1068
|
+
* No re-encoding — the frames are copied — so the work itself is trivial and
|
|
1069
|
+
* the cost is almost entirely the process. Doing one process per clip added
|
|
1070
|
+
* 11 s to the startup here (fourteen clips at about 0.83 s each), and running
|
|
1071
|
+
* them concurrently did not help: six at once took 4.75 s against 0.89 s for
|
|
1072
|
+
* one, so the machine serialises them. One run with many inputs and many
|
|
1073
|
+
* outputs costs one process.
|
|
1074
|
+
*
|
|
1075
|
+
* The outputs go to temporary files because several outputs cannot share one
|
|
1076
|
+
* pipe; they are read into memory and deleted immediately, and nothing about
|
|
1077
|
+
* this measurement is kept between runs.
|
|
1078
|
+
*
|
|
1079
|
+
* Before the measurements, never beside them: a remux running next to a decode
|
|
1080
|
+
* is a second job on the machine, and this benchmark exists to find out what
|
|
1081
|
+
* ONE job costs here.
|
|
1082
|
+
*
|
|
1083
|
+
* @param {string} ffmpegBin
|
|
1084
|
+
* @param {string[]} clipPaths
|
|
1085
|
+
* @param {string} family
|
|
1086
|
+
* @returns {Promise<Array<{ bytes: Buffer, demuxer: string, megapixelsPerSecond: number, megabitsPerSecond: number, fps: number } | null> | null>}
|
|
1087
|
+
* One entry per clip, in order; null when the family cannot be lifted at all.
|
|
1088
|
+
*/
|
|
1089
|
+
async function extractFamilyStreams(ffmpegBin, clipPaths, family) {
|
|
1090
|
+
const shape = ANNEX_B_BY_FAMILY[family];
|
|
1091
|
+
// A family with no mapping is a hard failure, not a silent fallback to
|
|
1092
|
+
// H.264's filter. AV1 has no Annex-B form at all (its packaging is OBU), and
|
|
1093
|
+
// MPEG-2 and VC-1 have no `*_mp4toannexb` filter — so the three families the
|
|
1094
|
+
// roadmap plans next cannot come through here, and finding that out as
|
|
1095
|
+
// "the clip failed" would send the reader after the clip.
|
|
1096
|
+
if (!shape) {
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
const workDir = await mkdtemp(path.join(os.tmpdir(), "ttv-calibration-"));
|
|
1100
|
+
const outputs = clipPaths.map((_, index) => path.join(workDir, `stream-${index}.${shape.demuxer}`));
|
|
1101
|
+
/** @type {string[]} */
|
|
1102
|
+
const args = ["-hide_banner", "-loglevel", "info", "-nostats", "-y"];
|
|
1103
|
+
for (const clipPath of clipPaths) {
|
|
1104
|
+
args.push("-i", clipPath);
|
|
1105
|
+
}
|
|
1106
|
+
for (const [index, output] of outputs.entries()) {
|
|
1107
|
+
args.push("-map", `${index}:v:0`, "-c:v", "copy", "-bsf:v", shape.filter, "-f", shape.demuxer, output);
|
|
1108
|
+
}
|
|
1109
|
+
const stderr = await runCapturingStderr(ffmpegBin, args, EXTRACT_TIMEOUT_MS);
|
|
1110
|
+
try {
|
|
1111
|
+
if (stderr === null) {
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1114
|
+
// One banner block per input, in the order they were given. Read rather
|
|
1115
|
+
// than declared, so replacing a clip cannot silently invalidate the fit
|
|
1116
|
+
// that rests on it.
|
|
1117
|
+
const blocks = splitInputBlocks(stderr, clipPaths.length);
|
|
1118
|
+
return await Promise.all(clipPaths.map(async (_, index) => {
|
|
1119
|
+
const block = blocks[index];
|
|
1120
|
+
if (!block) {
|
|
1121
|
+
return null;
|
|
1122
|
+
}
|
|
1123
|
+
const clipInfo = parseClipCharacteristics(block);
|
|
1124
|
+
const fps = parseFfmpegVideoFps(block);
|
|
1125
|
+
if (!clipInfo || !(fps > 0)) {
|
|
1126
|
+
return null;
|
|
1127
|
+
}
|
|
1128
|
+
let bytes;
|
|
1129
|
+
try {
|
|
1130
|
+
bytes = await readFile(outputs[index]);
|
|
1131
|
+
} catch {
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
if (bytes.length === 0) {
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
return {
|
|
1138
|
+
bytes,
|
|
1139
|
+
demuxer: shape.demuxer,
|
|
1140
|
+
megapixelsPerSecond: clipInfo.megapixelsPerSecond,
|
|
1141
|
+
megabitsPerSecond: clipInfo.megabitsPerSecond,
|
|
1142
|
+
fps
|
|
1143
|
+
};
|
|
1144
|
+
}));
|
|
1145
|
+
} finally {
|
|
1146
|
+
await rm(workDir, { recursive: true, force: true }).catch(() => {});
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* The part of an ffmpeg banner describing each input, in order.
|
|
1152
|
+
*
|
|
1153
|
+
* ffmpeg prints one `Input #N, …` block per input and then the stream mapping;
|
|
1154
|
+
* the parsers here read a single input's facts, so they are given a single
|
|
1155
|
+
* input's text rather than the whole banner.
|
|
1156
|
+
*
|
|
1157
|
+
* @param {string} stderr
|
|
1158
|
+
* @param {number} count
|
|
1159
|
+
* @returns {string[]}
|
|
1160
|
+
*/
|
|
1161
|
+
function splitInputBlocks(stderr, count) {
|
|
1162
|
+
/** @type {string[]} */
|
|
1163
|
+
const blocks = [];
|
|
1164
|
+
for (let index = 0; index < count; index += 1) {
|
|
1165
|
+
const from = stderr.indexOf(`Input #${index},`);
|
|
1166
|
+
if (from < 0) {
|
|
1167
|
+
blocks.push("");
|
|
1168
|
+
continue;
|
|
1169
|
+
}
|
|
1170
|
+
const nextInput = stderr.indexOf(`Input #${index + 1},`, from);
|
|
1171
|
+
const mapping = stderr.indexOf("Stream mapping:", from);
|
|
1172
|
+
const ends = [nextInput, mapping].filter((at) => at > from);
|
|
1173
|
+
blocks.push(stderr.slice(from, ends.length > 0 ? Math.min(...ends) : stderr.length));
|
|
1174
|
+
}
|
|
1175
|
+
return blocks;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Run ffmpeg to completion and return its stderr, or null when it failed or
|
|
1180
|
+
* outlasted its bound.
|
|
1181
|
+
*
|
|
1182
|
+
* @param {string} ffmpegBin
|
|
1183
|
+
* @param {string[]} args
|
|
1184
|
+
* @param {number} timeoutMs
|
|
1185
|
+
* @returns {Promise<string | null>}
|
|
1186
|
+
*/
|
|
1187
|
+
function runCapturingStderr(ffmpegBin, args, timeoutMs) {
|
|
1188
|
+
return new Promise((resolve) => {
|
|
1189
|
+
let stderr = "";
|
|
1190
|
+
let settled = false;
|
|
1191
|
+
let child;
|
|
1192
|
+
const settle = (value) => {
|
|
1193
|
+
if (settled) {
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
settled = true;
|
|
1197
|
+
clearTimeout(timer);
|
|
1198
|
+
try {
|
|
1199
|
+
child?.kill("SIGKILL");
|
|
1200
|
+
} catch {
|
|
1201
|
+
// already gone
|
|
1202
|
+
}
|
|
1203
|
+
resolve(value);
|
|
1204
|
+
};
|
|
1205
|
+
const timer = setTimeout(() => settle(null), timeoutMs);
|
|
1206
|
+
try {
|
|
1207
|
+
child = spawn(ffmpegBin, args, { stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
|
|
1208
|
+
} catch {
|
|
1209
|
+
settle(null);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
child.stderr.on("data", (chunk) => {
|
|
1213
|
+
stderr += String(chunk);
|
|
1214
|
+
});
|
|
1215
|
+
child.on("error", () => settle(null));
|
|
1216
|
+
child.on("close", (code) => settle(code === 0 ? stderr : null));
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Measure how fast this host DECODES a clip, from ffmpeg's own report of how
|
|
945
1222
|
* much video it has processed.
|
|
946
1223
|
*
|
|
947
|
-
*
|
|
948
|
-
* a second, and on a quick machine a five-second clip decodes in a tenth of
|
|
949
|
-
* that, so the measurement would be of the program starting. Progress lines
|
|
950
|
-
* arrive twice a second AFTER it has started, and the slope between two of them
|
|
951
|
-
* — video processed against time taken — contains no part of the startup by
|
|
952
|
-
* construction.
|
|
1224
|
+
* Two things are deliberately outside the measurement.
|
|
953
1225
|
*
|
|
954
|
-
* The
|
|
955
|
-
*
|
|
956
|
-
*
|
|
1226
|
+
* **The process starting.** Wall-clock around the process cannot answer this:
|
|
1227
|
+
* starting ffmpeg costs about a second, and on a quick machine a five-second
|
|
1228
|
+
* clip decodes in a tenth of that, so the measurement would be of the program
|
|
1229
|
+
* starting. Progress lines arrive AFTER it has started, and the slope between
|
|
1230
|
+
* two of them — video processed against time taken — contains no part of the
|
|
1231
|
+
* startup by construction.
|
|
1232
|
+
*
|
|
1233
|
+
* **The clip restarting.** This used to loop the clip with `-stream_loop -1`,
|
|
1234
|
+
* and a loop is not free: measured 2026-08-22 on a desktop, a restart costs
|
|
1235
|
+
* 0.03 s on the 480p clip and 0.12 s on the 1080p one — the decoder tearing
|
|
1236
|
+
* down and re-allocating its frame buffers, which is why the price rises with
|
|
1237
|
+
* the picture. A five-second clip decoded at 55x restarts eleven times a
|
|
1238
|
+
* second, so that cost DOMINATED the reading: the same clips measured 53.7x
|
|
1239
|
+
* looped against 80.3x in one continuous pass, and 11.8x against 15.8x. Worse,
|
|
1240
|
+
* the bias is not shared — it depends on the clip's own resolution and on how
|
|
1241
|
+
* fast the host is — so it does not cancel out of the fit, it tilts it. That is
|
|
1242
|
+
* the fast-host failure recorded on 2026-08-20, where 1080p read cheaper than
|
|
1243
|
+
* 720p, which is not a thing a decoder does.
|
|
1244
|
+
*
|
|
1245
|
+
* So the clip is fed to the decoder as ONE stream instead. An Annex-B
|
|
1246
|
+
* elementary stream carries its parameter sets inline, so writing the same
|
|
1247
|
+
* bytes again is simply more stream — the decoder never re-initialises, and
|
|
1248
|
+
* there is no restart inside the window to measure. Verified against the
|
|
1249
|
+
* continuous-pass truth on the same host: -0.2 % and -5.5 %, against -25 % and
|
|
1250
|
+
* -33 % for the loop. Nothing is written to disk and the process is killed as
|
|
1251
|
+
* soon as the window is wide enough.
|
|
1252
|
+
*
|
|
1253
|
+
* Exported because the property that broke here is checkable and was not being
|
|
1254
|
+
* checked: a bigger picture must cost more than a smaller one of the same
|
|
1255
|
+
* bitrate, and under the loop it did not.
|
|
957
1256
|
*
|
|
958
1257
|
* @param {string} ffmpegBin
|
|
959
1258
|
* @param {string} clipPath
|
|
1259
|
+
* @param {string} [family="h264"]
|
|
1260
|
+
* @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
|
|
1261
|
+
*/
|
|
1262
|
+
export async function measureDecodeSlope(ffmpegBin, clipPath, family = "h264") {
|
|
1263
|
+
const streams = await extractFamilyStreams(ffmpegBin, [clipPath], family);
|
|
1264
|
+
const stream = streams?.[0];
|
|
1265
|
+
if (!stream) {
|
|
1266
|
+
return null;
|
|
1267
|
+
}
|
|
1268
|
+
const measured = await decodePipedStream(ffmpegBin, stream);
|
|
1269
|
+
return measured?.speed ? measured : null;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Decode an elementary stream fed from memory, and report the slope.
|
|
1274
|
+
*
|
|
1275
|
+
* @param {string} ffmpegBin
|
|
1276
|
+
* @param {{ bytes: Buffer, demuxer: string, megapixelsPerSecond: number, megabitsPerSecond: number, fps: number }} stream
|
|
960
1277
|
* @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
|
|
961
1278
|
*/
|
|
962
|
-
function
|
|
1279
|
+
function decodePipedStream(ffmpegBin, stream) {
|
|
963
1280
|
return new Promise((resolve) => {
|
|
964
1281
|
const args = [
|
|
965
|
-
"-hide_banner", "-loglevel", "
|
|
966
|
-
|
|
967
|
-
|
|
1282
|
+
"-hide_banner", "-loglevel", "error", "-nostats",
|
|
1283
|
+
// A raw stream states no frame rate, so the one the container declared is
|
|
1284
|
+
// given back to it. It decides how output time advances, and therefore
|
|
1285
|
+
// what "seconds of video per second of clock" means.
|
|
1286
|
+
"-f", stream.demuxer, "-framerate", String(stream.fps), "-i", "pipe:0",
|
|
968
1287
|
"-an", "-f", "null", "-",
|
|
969
1288
|
"-progress", "pipe:1"
|
|
970
1289
|
];
|
|
971
1290
|
/** @type {Array<{ wallSec: number, outSec: number }>} */
|
|
972
1291
|
const samples = [];
|
|
973
|
-
let stderr = "";
|
|
974
1292
|
let stdout = "";
|
|
1293
|
+
// Kept because this path depends on three things the old one did not: the
|
|
1294
|
+
// raw demuxer accepting the frame rate, the bitstream filter having
|
|
1295
|
+
// produced something parsable, and the fed concatenation being decodable.
|
|
1296
|
+
// Without it the only trace of any of those failing is "said nothing".
|
|
1297
|
+
let stderr = "";
|
|
975
1298
|
let settled = false;
|
|
976
1299
|
let child;
|
|
977
1300
|
const startedAt = Date.now();
|
|
@@ -981,48 +1304,61 @@ function measureDecodeSlope(ffmpegBin, clipPath) {
|
|
|
981
1304
|
}
|
|
982
1305
|
settled = true;
|
|
983
1306
|
clearTimeout(timer);
|
|
1307
|
+
try {
|
|
1308
|
+
child?.stdin?.destroy();
|
|
1309
|
+
} catch {
|
|
1310
|
+
// already gone
|
|
1311
|
+
}
|
|
984
1312
|
try {
|
|
985
1313
|
child?.kill("SIGKILL");
|
|
986
1314
|
} catch {
|
|
987
1315
|
// already gone
|
|
988
1316
|
}
|
|
989
|
-
// The first sample
|
|
990
|
-
//
|
|
991
|
-
//
|
|
1317
|
+
// The first sample still carries the startup — it reports whatever was
|
|
1318
|
+
// processed while the process was coming up. Everything is measured from
|
|
1319
|
+
// the second onwards.
|
|
992
1320
|
const first = samples[1];
|
|
993
1321
|
const last = samples[samples.length - 1];
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
resolve(null);
|
|
1322
|
+
if (!first || !last) {
|
|
1323
|
+
resolve({ error: lastErrorLine(stderr) || "the decoder reported no progress" });
|
|
997
1324
|
return;
|
|
998
1325
|
}
|
|
999
1326
|
const windowSec = last.wallSec - first.wallSec;
|
|
1000
1327
|
const producedSec = last.outSec - first.outSec;
|
|
1001
1328
|
if (!(windowSec >= DECODE_WINDOW_MIN_SEC) || !(producedSec > 0)) {
|
|
1002
|
-
resolve(
|
|
1329
|
+
resolve({ error: lastErrorLine(stderr) || `the window was ${windowSec.toFixed(2)}s of ${producedSec.toFixed(2)}s produced` });
|
|
1003
1330
|
return;
|
|
1004
1331
|
}
|
|
1005
1332
|
resolve({
|
|
1006
1333
|
speed: producedSec / windowSec,
|
|
1007
1334
|
windowSec,
|
|
1008
|
-
megapixelsPerSecond:
|
|
1009
|
-
megabitsPerSecond:
|
|
1335
|
+
megapixelsPerSecond: stream.megapixelsPerSecond,
|
|
1336
|
+
megabitsPerSecond: stream.megabitsPerSecond
|
|
1010
1337
|
});
|
|
1011
1338
|
};
|
|
1012
1339
|
const timer = setTimeout(finish, DECODE_WINDOW_MAX_MS);
|
|
1013
1340
|
try {
|
|
1014
|
-
child = spawn(ffmpegBin, args, { stdio: ["
|
|
1015
|
-
} catch {
|
|
1016
|
-
// The timer would otherwise hold the event loop for its full wait and
|
|
1017
|
-
// then run against a child that was never created.
|
|
1341
|
+
child = spawn(ffmpegBin, args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
1342
|
+
} catch (error) {
|
|
1018
1343
|
clearTimeout(timer);
|
|
1019
1344
|
settled = true;
|
|
1020
|
-
resolve(
|
|
1345
|
+
resolve({ error: error instanceof Error ? error.message : String(error) });
|
|
1021
1346
|
return;
|
|
1022
1347
|
}
|
|
1023
1348
|
child.stderr.on("data", (chunk) => {
|
|
1024
1349
|
stderr += String(chunk);
|
|
1025
1350
|
});
|
|
1351
|
+
// Keep the decoder fed. `write` returning false means the pipe is full, and
|
|
1352
|
+
// the next copy goes on the `drain` — so the decoder is never starved and
|
|
1353
|
+
// this process never buffers more than the pipe holds.
|
|
1354
|
+
const feed = () => {
|
|
1355
|
+
while (!settled && child.stdin.writable && child.stdin.write(stream.bytes)) {
|
|
1356
|
+
// Written straight through; go round again.
|
|
1357
|
+
}
|
|
1358
|
+
};
|
|
1359
|
+
child.stdin.on("drain", feed);
|
|
1360
|
+
// The kill closes the pipe under the writer; that is the intended end.
|
|
1361
|
+
child.stdin.on("error", () => {});
|
|
1026
1362
|
child.stdout.on("data", (chunk) => {
|
|
1027
1363
|
stdout += String(chunk);
|
|
1028
1364
|
let newline = stdout.indexOf("\n");
|
|
@@ -1041,15 +1377,26 @@ function measureDecodeSlope(ffmpegBin, clipPath) {
|
|
|
1041
1377
|
finish();
|
|
1042
1378
|
}
|
|
1043
1379
|
});
|
|
1044
|
-
child.on("error", () => {
|
|
1380
|
+
child.on("error", (error) => {
|
|
1045
1381
|
if (settled) {
|
|
1046
1382
|
return;
|
|
1047
1383
|
}
|
|
1048
1384
|
clearTimeout(timer);
|
|
1049
1385
|
settled = true;
|
|
1050
|
-
|
|
1386
|
+
try {
|
|
1387
|
+
child?.stdin?.destroy();
|
|
1388
|
+
} catch {
|
|
1389
|
+
// already gone
|
|
1390
|
+
}
|
|
1391
|
+
try {
|
|
1392
|
+
child?.kill("SIGKILL");
|
|
1393
|
+
} catch {
|
|
1394
|
+
// already gone
|
|
1395
|
+
}
|
|
1396
|
+
resolve({ error: error instanceof Error ? error.message : String(error) });
|
|
1051
1397
|
});
|
|
1052
1398
|
child.on("close", finish);
|
|
1399
|
+
feed();
|
|
1053
1400
|
});
|
|
1054
1401
|
}
|
|
1055
1402
|
|