@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.
@@ -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 = nominalKbpsForHeight(height);
169
+ function bitrateCapArgs(height, nominalKbps = null) {
170
+ const nominal = Number.isFinite(nominalKbps) && nominalKbps > 0
171
+ ? nominalKbps
172
+ : nominalKbpsForHeight(height);
129
173
  return [
130
- "-maxrate", `${Math.round(nominal * CAP_MAXRATE_FACTOR)}k`,
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. A second of decoding is thousands of frames on a
724
- // quick host and dozens on a weak one; both give a slope, and neither costs the
725
- // startup more than a second per clip.
726
- const DECODE_WINDOW_MIN_SEC = 1;
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
- const alone = await measureDecodeSlope(ffmpegBin, clip);
805
- if (!alone) {
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 measureDecodeSlope(ffmpegBin, clip);
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
- for (const clip of clips) {
908
- const measured = await measureDecodeSlope(ffmpegBin, path.join(clipsDir, clip));
909
- if (!measured) {
910
- log.warn(`hwaccel: decode benchmark "${clip}" failed or said nothing; ${family} not measured`);
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
- * Measure how fast this host DECODES a clip, from ffmpeg’s own report of how
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
- * Wall-clock around the process cannot answer this: starting ffmpeg costs about
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 clip is looped forever and the process killed as soon as the window is
955
- * wide enough, so the cost is bounded by the clock rather than by the clip:
956
- * roughly a second of measurement on any host, quick or slow.
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 measureDecodeSlope(ffmpegBin, clipPath) {
1279
+ function decodePipedStream(ffmpegBin, stream) {
963
1280
  return new Promise((resolve) => {
964
1281
  const args = [
965
- "-hide_banner", "-loglevel", "info", "-nostats",
966
- "-stream_loop", "-1",
967
- "-i", clipPath,
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 is the one that still carries the startup — it reports
990
- // whatever was processed while the process was coming up. Everything is
991
- // measured from the second onwards.
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
- const clipInfo = parseClipCharacteristics(stderr);
995
- if (!first || !last || !clipInfo) {
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(null);
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: clipInfo.megapixelsPerSecond,
1009
- megabitsPerSecond: clipInfo.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: ["ignore", "pipe", "pipe"], windowsHide: true });
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(null);
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
- resolve(null);
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