@torrent-tv/proxy 2.97.30 → 2.98.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/bin/cli.js CHANGED
@@ -88,6 +88,7 @@ program
88
88
  .option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
89
89
  .option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
90
90
  .option("--delivery-sink", "Serve /api/delivery-sink, a torrent-free byte stream for delivery testing")
91
+ .option("--pin-preset <name>", "Encode every software picture at this libx264 preset only, for runs that must be compared at one quality (off by default)")
91
92
  .option("--profile-start <seconds>", "Profile the main thread's CPU for this many seconds from the first source opened, written beside the state (off by default)")
92
93
  .option(
93
94
  "--send-chunk-bytes <bytes>",
@@ -394,6 +395,7 @@ try {
394
395
  logFile: options.logFile ?? "",
395
396
  deliverySink: options.deliverySink === true,
396
397
  profileStartSeconds: Number(options.profileStart) || 0,
398
+ pinnedPreset: typeof options.pinPreset === "string" && options.pinPreset.trim() ? options.pinPreset.trim() : null,
397
399
  // Late-bound the same way `webRtcManager` is below: the torrent pool is
398
400
  // built inside `startProxyServer`, before `dataChannelHandler` — the
399
401
  // thing that actually owns a channel to push down — exists.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.97.30",
3
+ "version": "2.98.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -127,6 +127,9 @@ export async function startProxyServer({
127
127
  deliverySink = false,
128
128
  // Seconds of main-thread CPU profile from the first source opened; 0 is off.
129
129
  profileStartSeconds = 0,
130
+ // The one software preset every picture is encoded at, for runs compared at
131
+ // one quality; null leaves the choice to the machine.
132
+ pinnedPreset = null,
130
133
  // What viewers' connections carry beyond the film, per byte of film; the
131
134
  // transport counts it and is built after this server, so it is late-bound.
132
135
  serviceShare = () => null,
@@ -543,7 +546,7 @@ export async function startProxyServer({
543
546
  // ranges closer than a piece hold the same pieces as one.
544
547
  const pieceLength = Number(torrentPool.knownTorrent(map.sourceKey)?.pieceLength);
545
548
  const passStartedAt = performance.now();
546
- let segments = 0;
549
+ let rangeCount = 0;
547
550
  const converted = [];
548
551
  const demands = [];
549
552
  // What each output's next run would copy, asked of the swarm as one group
@@ -629,12 +632,18 @@ export async function startProxyServer({
629
632
  const { tracks: _choices, owner: _owner, sourceInterval: _sourceInterval, ...demand } = zone;
630
633
  const convert = ranges => {
631
634
  const joined = joinedRangesOf(ranges, pieceLength);
632
- segments += 1;
635
+ rangeCount += joined.length;
633
636
  // What the segment reads is kept for the forecast in a table of its
634
637
  // own, whatever form the torrent is told it in.
635
638
  segmentInputs.set(map.sourceKey, map.fileIndex, zone.outputKey, zone.index, joined);
636
- for (const [byteStart, byteEnd] of joined) converted.push({ ...demand,
637
- downloadInterval: { from: zone.owner.from, to: zone.owner.to }, byteStart, byteEnd });
639
+ // ONE ZONE FOR THE SEGMENT, its demand stated once for all the
640
+ // ranges it reads. The demand — level, priority, deadline, the map
641
+ // zone it belongs to — is this pass's; the ranges are the list kept
642
+ // for the segment, unchanged from pass to pass. A zone for each
643
+ // range copied the demand into every one: ten thousand objects a
644
+ // pass over a two-hour AVI, made again 21-61 times a minute on Home
645
+ // Assistant (r26, torrent-tv/meta#178).
646
+ converted.push({ ...demand, downloadInterval: { from: zone.owner.from, to: zone.owner.to }, ranges: joined });
638
647
  };
639
648
  // The bytes one segment needs are a fact of the file and the output, so
640
649
  // they are worked out once. A whole film is thousands of segments and
@@ -694,7 +703,7 @@ export async function startProxyServer({
694
703
  convert(input.ranges);
695
704
  }
696
705
  const result = expanded();
697
- notePass({ complete, segments, ranges: converted.length, zones: result.length, ms: performance.now() - passStartedAt });
706
+ notePass({ complete, segments: converted.length, ranges: rangeCount, zones: result.length, ms: performance.now() - passStartedAt });
698
707
  if (complete) resolvedPasses.set(fileKey, { ...pass, result });
699
708
  else resolvedPasses.delete(fileKey);
700
709
  return result;
@@ -792,6 +801,7 @@ export async function startProxyServer({
792
801
  });
793
802
  let sourcePreparation;
794
803
  const outputParts = wireOutputs({
804
+ pinnedPreset,
795
805
  indexMemory,
796
806
  serviceShare,
797
807
  readMetadataActivity: () => containerOrchestrator.activity(),
@@ -1358,8 +1358,23 @@ export class Renditions {
1358
1358
  if (!base) {
1359
1359
  return null;
1360
1360
  }
1361
- if (!this.#host.qualityOffer.offeredHeightsFor(base).includes(height)) {
1362
- return null;
1361
+ const offered = this.#host.qualityOffer.offeredHeightsFor(base);
1362
+ if (!offered.includes(height)) {
1363
+ if (!this.#host.outputs.splicableHeights(base).includes(height)) {
1364
+ return null;
1365
+ }
1366
+ // A STEP THE MASTER LISTS AND THE OFFER NO LONGER HOLDS. The offer moves
1367
+ // with what the machine measures, and a page preparing a step it was
1368
+ // asked for can arrive after the ask was withdrawn: field r28, 360p
1369
+ // asked at 21:40:44.308, the offer down to 400p at 21:40:46.143, the
1370
+ // warm-up at 46.170 answered "no such variant" (torrent-tv/meta#178).
1371
+ // The step exists; it is not available now, and the page keeps what it
1372
+ // plays and follows the next ask.
1373
+ this.#host.logger.info(`transcode ${base.id} warming ${height}p for ${consumerId}: not offered now (offering ${offered.map(one => `${one}p`).join(" ") || "nothing"})`);
1374
+ const error = new Error(`${height}p is not offered now.`);
1375
+ error.code = OUTPUT_UNAVAILABLE;
1376
+ error.details = { reason: `${height}p is not offered on this machine now`, figures: { height, offeredHeights: offered } };
1377
+ throw error;
1363
1378
  }
1364
1379
  const index = this.#host.outputTimes.segmentIndexForTime(base, positionSeconds);
1365
1380
  const variant = await this.resolveVariantSession(baseSessionId, height, index, consumerId);
@@ -1715,7 +1715,8 @@ export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
1715
1715
  * @param {{ width: number, height: number }} ceiling
1716
1716
  * @param {number} outputFps
1717
1717
  * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, requiredSpeed?: number | null }} [cost]
1718
- * @returns {{ width: number, height: number, preset: string, ladder: Array<{ width: number, height: number }>, rungIndex: number } | null}
1718
+ * @returns {{ width: number, height: number, preset: string, ladder: Array<{ width: number, height: number }>, rungIndex: number, bar: number } | null}
1719
+ * `bar` is the speed the choice had to clear.
1719
1720
  */
1720
1721
  export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost = {}) {
1721
1722
  if (!Array.isArray(benchmark) || benchmark.length === 0) {
@@ -1750,7 +1751,7 @@ export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost
1750
1751
  }
1751
1752
  const chosen = ladder[chosenIndex];
1752
1753
  const preset = pickSoftwarePreset(benchmark, chosen.width * chosen.height * fps, cost, chosen);
1753
- return { width: chosen.width, height: chosen.height, preset, ladder, rungIndex: chosenIndex };
1754
+ return { width: chosen.width, height: chosen.height, preset, ladder, rungIndex: chosenIndex, bar };
1754
1755
  }
1755
1756
 
1756
1757
  /**
@@ -1103,7 +1103,8 @@ export class EncodeCost {
1103
1103
  sourceHeight,
1104
1104
  outputFps,
1105
1105
  source = null,
1106
- requiredSpeed = null
1106
+ requiredSpeed = null,
1107
+ observedDecodeCostSec = null
1107
1108
  }) {
1108
1109
  if (!transcodeVideo || this.#host().encoderKind !== "software" || !this.#host().benchmark) {
1109
1110
  return null;
@@ -1113,26 +1114,44 @@ export class EncodeCost {
1113
1114
  return null;
1114
1115
  }
1115
1116
  const concurrentCostSec = this.#committedMachineCostSec();
1117
+ // THE FILE'S OWN DECODE COST, where its encoder has already been read: the
1118
+ // opening had it and the choice priced decoding from the startup model
1119
+ // instead (review of r28, torrent-tv/meta#178).
1120
+ const decodeCostSec = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0 ? observedDecodeCostSec : null;
1121
+ /** Every size and preset the choice priced, with the speed it was priced at. */
1122
+ const considered = [];
1116
1123
  const speedForPreset = (benchmark, frame, outputPixelsPerSec) => {
1117
- if (concurrentCostSec === null) {
1118
- return null;
1119
- }
1120
- return this.#speedOnThisMachine({
1124
+ const speed = concurrentCostSec === null ? null : this.#speedOnThisMachine({
1121
1125
  benchmark,
1122
1126
  source,
1123
1127
  width: frame.width,
1124
1128
  height: frame.height,
1125
1129
  fps: outputPixelsPerSec / (frame.width * frame.height),
1126
- observedDecodeCostSec: null,
1130
+ observedDecodeCostSec: decodeCostSec,
1127
1131
  concurrentCostSec
1128
1132
  });
1133
+ considered.push({ preset: benchmark[0]?.preset, width: frame.width, height: frame.height, speed });
1134
+ return speed;
1129
1135
  };
1130
- return chooseSoftwareEncodeSettings(
1136
+ const choice = chooseSoftwareEncodeSettings(
1131
1137
  this.#host().benchmark,
1132
1138
  { width: ceiling.w, height: ceiling.h },
1133
1139
  outputFps,
1134
- { decodeModel: this.#host().decodeModel, source, requiredSpeed, speedForPreset }
1140
+ { decodeModel: this.#host().decodeModel, source, requiredSpeed, speedForPreset, observedDecodeCostSec: decodeCostSec }
1135
1141
  );
1142
+ // WHAT THE CHOICE WAS MADE FROM, said once per choice: three runs of one
1143
+ // file on one host opened at three presets and nothing said why (r25, r26,
1144
+ // r28, torrent-tv/meta#178).
1145
+ if (choice) {
1146
+ const availability = this.#host().availability;
1147
+ const priced = considered.map(({ preset, width, height, speed }) =>
1148
+ `${width}x${height} ${preset}=${Number.isFinite(speed) ? `${speed.toFixed(2)}x` : "unpriced"}`).join(", ");
1149
+ logger.info(`encode choice: ${choice.width}x${choice.height}@${Number(outputFps).toFixed(3)} ${choice.preset} against ${choice.bar.toFixed(2)}x; ` +
1150
+ `priced ${priced}; decoding ${decodeCostSec === null ? "from the startup model" : `${decodeCostSec.toFixed(4)} s per second, measured on this file`}; ` +
1151
+ `machine ${availability?.known ? `${Math.round(availability.share * 100)}% to spare` : "share unknown"}; ` +
1152
+ `committed work ${concurrentCostSec === null ? "unmeasured" : `${concurrentCostSec.toFixed(3)} s per second`}`);
1153
+ }
1154
+ return choice;
1136
1155
  }
1137
1156
  }
1138
1157
 
@@ -144,7 +144,8 @@ export function decideOutputFormat({
144
144
  sourceHeight: source.height,
145
145
  outputFps: fps,
146
146
  source: source.decode,
147
- requiredSpeed: cost.requiredSpeed
147
+ requiredSpeed: cost.requiredSpeed,
148
+ observedDecodeCostSec: cost.observedDecodeCostSec ?? null
148
149
  });
149
150
  const width = budget?.width ?? ceiling?.w ?? 0;
150
151
  const height = budget?.height ?? ceiling?.h ?? 0;
@@ -189,6 +189,9 @@ export function wireOutputs({
189
189
  keyframeTables = new KeyframeTables(),
190
190
  videoEncoder = null,
191
191
  softwarePresetBenchmark = null,
192
+ // A software preset every picture is encoded at, for runs compared at one
193
+ // quality (`--pin-preset`); null leaves the choice to the machine.
194
+ pinnedPreset = null,
192
195
  // What every encoder this host may use was qualified and measured to do at
193
196
  // startup, by kind (`encode/calibration.js`). Takes the place of the one
194
197
  // array above, which a test may still hand in on its own.
@@ -771,8 +774,26 @@ export function wireOutputs({
771
774
  // Null where nothing was measured, and then nothing is re-encoded here.
772
775
  parts.calibration = calibration ?? null;
773
776
  const givenModes = Array.isArray(softwarePresetBenchmark) ? softwarePresetBenchmark : null;
777
+ // ONE PRESET FOR RUNS COMPARED AT ONE QUALITY. Three runs of one film on one
778
+ // host opened at `faster`, `veryfast` and `fast`, so no change could be
779
+ // compared at the same picture (r25, r26, r28, torrent-tv/meta#178). Pinned
780
+ // here, every decision that prices or chooses a software mode — the choice,
781
+ // the offer, the machine's admission and the command — sees the one mode,
782
+ // measured as calibrated. A preset calibration did not qualify is not
783
+ // pinned: the machine's own list stands, and the log says so.
784
+ let pinWarned = false;
785
+ const pinned = (modes) => {
786
+ if (!pinnedPreset || !Array.isArray(modes) || parts.encoders.current?.kind !== "software") return modes;
787
+ const kept = modes.filter(mode => mode.preset === pinnedPreset);
788
+ if (kept.length > 0) return kept;
789
+ if (!pinWarned) {
790
+ pinWarned = true;
791
+ logger.warn(`encode: --pin-preset ${pinnedPreset} names no calibrated mode (${modes.map(mode => mode.preset).join(", ")}); not pinned`);
792
+ }
793
+ return modes;
794
+ };
774
795
  Object.defineProperty(parts, "softwarePresetBenchmark", {
775
- get: () => (parts.calibration
796
+ get: () => pinned(parts.calibration
776
797
  ? parts.calibration.modesFor(parts.encoders.current?.kind)
777
798
  : givenModes),
778
799
  enumerable: true
@@ -63,6 +63,18 @@ export function deadlineOfMapZone(zone, now = Date.now()) {
63
63
  Number.isFinite(zone.withinSeconds) ? now + Math.max(0, zone.withinSeconds) * 1000 : Number.POSITIVE_INFINITY;
64
64
  }
65
65
 
66
+ /**
67
+ * The byte ranges a zone names: a segment's zone names all the ranges its
68
+ * segment reads, and states its demand once for them (`ranges`); any other
69
+ * zone names one (`byteStart`-`byteEnd`). Inclusive.
70
+ *
71
+ * @param {{ ranges?: Array<[number, number]>, byteStart?: number, byteEnd?: number }} zone
72
+ * @returns {Array<[number, number]>}
73
+ */
74
+ export function rangesOfZone(zone) {
75
+ return Array.isArray(zone.ranges) ? zone.ranges : [[zone.byteStart, zone.byteEnd]];
76
+ }
77
+
66
78
  /**
67
79
  * The zones with every repeat of the same bytes left out but the one the swarm
68
80
  * would rank first.
@@ -80,14 +92,9 @@ export function deadlineOfMapZone(zone, now = Date.now()) {
80
92
  * @returns {Zone[]} In their order, without the repeats.
81
93
  */
82
94
  export function withoutRepeatedZones(zones, now = Date.now()) {
83
- const keys = zones.map(zone => `${zone.byteStart}:${zone.byteEnd}`);
84
- const best = new Map();
85
- zones.forEach((zone, index) => {
86
- const kept = best.get(keys[index]);
87
- const band = { urgency: levelOfMapZone(zone), priority: priorityOfMapZone(zone), deadlineAt: deadlineOfMapZone(zone, now) };
88
- if (!kept || compareBands(band, kept.band) < 0) best.set(keys[index], { index, band });
89
- });
90
- return zones.filter((_zone, index) => best.get(keys[index]).index === index);
95
+ const bands = zones.map(zone => bandOf(zone, now));
96
+ const best = bestOfRepeats(zones.length, index => zones[index].byteStart, index => zones[index].byteEnd, index => bands[index]);
97
+ return zones.filter((_zone, index) => best.get(zones[index].byteStart).get(zones[index].byteEnd) === index);
91
98
  }
92
99
 
93
100
  /**
@@ -105,45 +112,103 @@ export function withoutRepeatedZones(zones, now = Date.now()) {
105
112
  * consecutive segments are consecutive bytes. A zone a metadata read states
106
113
  * keeps its own request and order, and is left as it is.
107
114
  *
108
- * @template {{ byteStart: number, byteEnd: number }} Zone
109
- * @param {Zone[]} zones
115
+ * A segment's zone states its demand once for all its ranges
116
+ * ({@link rangesOfZone}); each range is a part of its own here, so a repeat is
117
+ * found range by range, and the band and deadline every range is packed with
118
+ * are the zone's current ones.
119
+ *
120
+ * @param {Array<{ byteStart?: number, byteEnd?: number, ranges?: Array<[number, number]> }>} zones
110
121
  * @param {number} [now]
111
122
  * @returns {object[]}
112
123
  */
113
124
  export function coalescedZones(zones, now = Date.now()) {
125
+ // Every range of every zone is a part, kept in numbers rather than objects:
126
+ // a film's map is ten thousand of them (r26, torrent-tv/meta#178).
127
+ let count = 0;
128
+ for (const zone of zones) count += Array.isArray(zone.ranges) ? zone.ranges.length : 1;
129
+ const starts = new Float64Array(count);
130
+ const ends = new Float64Array(count);
131
+ const owners = new Int32Array(count);
132
+ const bands = zones.map(zone => bandOf(zone, now));
133
+ let at = 0;
134
+ zones.forEach((zone, index) => {
135
+ for (const [start, end] of rangesOfZone(zone)) {
136
+ starts[at] = start;
137
+ ends[at] = end;
138
+ owners[at] = index;
139
+ at += 1;
140
+ }
141
+ });
142
+ const best = bestOfRepeats(count, part => starts[part], part => ends[part], part => bands[owners[part]]);
114
143
  const kept = [];
144
+ /** Band key → the band and its parts, in the order the bands first appear. */
115
145
  const groups = new Map();
116
- for (const zone of withoutRepeatedZones(zones, now)) {
146
+ for (let part = 0; part < count; part++) {
147
+ if (best.get(starts[part]).get(ends[part]) !== part) continue;
148
+ const zone = zones[owners[part]];
117
149
  if (zone.requestId !== undefined || zone.downloadOnly || (Number.isSafeInteger(zone.order) && zone.order > 0)) {
118
150
  kept.push(zone);
119
151
  continue;
120
152
  }
121
- const urgency = levelOfMapZone(zone);
122
- const priority = priorityOfMapZone(zone);
123
- const key = `${urgency}:${priority}`;
153
+ const band = bands[owners[part]];
154
+ const key = band.urgency * 1000 + band.priority;
124
155
  let group = groups.get(key);
125
- if (!group) groups.set(key, group = { urgency, priority, parts: [] });
126
- group.parts.push({ start: zone.byteStart, end: zone.byteEnd, deadlineAt: deadlineOfMapZone(zone, now) });
156
+ if (!group) groups.set(key, group = { band, parts: [] });
157
+ group.parts.push(part);
127
158
  }
128
- for (const { urgency, priority, parts } of groups.values()) {
129
- parts.sort((left, right) => left.start - right.start);
130
- let window = null;
159
+ for (const { band: { urgency, priority }, parts } of groups.values()) {
160
+ parts.sort((left, right) => starts[left] - starts[right]);
161
+ let windowStart = 0;
162
+ let windowEnd = -1;
163
+ let windowDeadline = Number.POSITIVE_INFINITY;
164
+ let open = false;
131
165
  const close = () => {
132
- if (!window) return;
133
- kept.push({ byteStart: window.start, byteEnd: window.end, priority, urgent: urgency === Urgency.NEAR,
166
+ if (!open) return;
167
+ kept.push({ byteStart: windowStart, byteEnd: windowEnd, priority, urgent: urgency === Urgency.NEAR,
134
168
  behind: urgency === Urgency.BEHIND, deferred: urgency === Urgency.TAIL,
135
- ...(Number.isFinite(window.deadlineAt) ? { deadlineAt: window.deadlineAt } : {}) });
169
+ ...(Number.isFinite(windowDeadline) ? { deadlineAt: windowDeadline } : {}) });
136
170
  };
137
171
  for (const part of parts) {
138
- if (window && part.start <= window.end + 1) {
139
- window.end = Math.max(window.end, part.end);
140
- window.deadlineAt = Math.min(window.deadlineAt, part.deadlineAt);
172
+ const deadlineAt = bands[owners[part]].deadlineAt;
173
+ if (open && starts[part] <= windowEnd + 1) {
174
+ windowEnd = Math.max(windowEnd, ends[part]);
175
+ windowDeadline = Math.min(windowDeadline, deadlineAt);
141
176
  } else {
142
177
  close();
143
- window = { ...part };
178
+ open = true;
179
+ windowStart = starts[part];
180
+ windowEnd = ends[part];
181
+ windowDeadline = deadlineAt;
144
182
  }
145
183
  }
146
184
  close();
147
185
  }
148
186
  return kept;
149
187
  }
188
+
189
+ /** The band a zone's bytes are fetched in. */
190
+ function bandOf(zone, now) {
191
+ return { urgency: levelOfMapZone(zone), priority: priorityOfMapZone(zone), deadlineAt: deadlineOfMapZone(zone, now) };
192
+ }
193
+
194
+ /**
195
+ * Which of the parts naming each exact byte range is the best: by band
196
+ * ({@link compareBands}), the first of equals. Kept by the range's two numbers
197
+ * rather than a string made of them: making a key of each of a film's ten
198
+ * thousand ranges was most of what packing its map cost (r26,
199
+ * torrent-tv/meta#178).
200
+ *
201
+ * @returns {Map<number, Map<number, number>>} start → end → the best part.
202
+ */
203
+ function bestOfRepeats(count, startOf, endOf, bandOfPart) {
204
+ const best = new Map();
205
+ for (let part = 0; part < count; part++) {
206
+ const start = startOf(part);
207
+ const end = endOf(part);
208
+ let byEnd = best.get(start);
209
+ if (!byEnd) best.set(start, byEnd = new Map());
210
+ const kept = byEnd.get(end);
211
+ if (kept === undefined || compareBands(bandOfPart(part), bandOfPart(kept)) < 0) byEnd.set(end, part);
212
+ }
213
+ return best;
214
+ }
@@ -102,8 +102,11 @@ function merged(ranges) {
102
102
  * The map's byte zones with each output's next run input raised to its first
103
103
  * segment's place.
104
104
  *
105
- * @template {{ outputKey: string, index: number, priority: number, urgent?: boolean, behind?: boolean, deferred?: boolean, byteStart: number, byteEnd: number }} Zone
106
- * @param {Zone[]} zones - One or more per segment, as the map's pass made them.
105
+ * @template {{ outputKey: string, index: number, priority: number, urgent?: boolean, behind?: boolean, deferred?: boolean,
106
+ * ranges?: Array<[number, number]>, byteStart?: number, byteEnd?: number }} Zone
107
+ * @param {Zone[]} zones - One or more per segment, as the map's pass made
108
+ * them: a segment's zone names all its ranges (`ranges`), any other one
109
+ * range (`byteStart`-`byteEnd`).
107
110
  * @param {(outputKey: string) => number | null} targetBytesOf - What the
108
111
  * output's next run would be given to copy; nothing is raised without it.
109
112
  * @returns {Zone[]} In their order; raised zones are copies.
@@ -138,7 +141,7 @@ export function expandToTargetInputs(zones, targetBytesOf) {
138
141
  if (zone.index === run.first.index) {
139
142
  let ranges = held.get(zone.outputKey);
140
143
  if (!ranges) held.set(zone.outputKey, ranges = []);
141
- ranges.push([zone.byteStart, zone.byteEnd]);
144
+ ranges.push(...rangesOf(zone));
142
145
  } else if (zone.index > run.first.index) {
143
146
  const list = run.after.get(zone.index);
144
147
  if (list) list.push(zone);
@@ -153,7 +156,7 @@ export function expandToTargetInputs(zones, targetBytesOf) {
153
156
  // or not yet worked out, ends it.
154
157
  for (let index = run.first.index + 1; run.after.has(index); index++) {
155
158
  const list = run.after.get(index);
156
- const ranges = list.map(zone => [zone.byteStart, zone.byteEnd]);
159
+ const ranges = list.flatMap(rangesOf);
157
160
  if (holding.total + holding.adds(ranges) > run.target) break;
158
161
  holding.add(ranges);
159
162
  for (const zone of list) {
@@ -164,3 +167,8 @@ export function expandToTargetInputs(zones, targetBytesOf) {
164
167
  }
165
168
  return raised.size === 0 ? zones : zones.map(zone => raised.get(zone) ?? zone);
166
169
  }
170
+
171
+ /** The ranges a zone names, inclusive. */
172
+ function rangesOf(zone) {
173
+ return Array.isArray(zone.ranges) ? zone.ranges : [[zone.byteStart, zone.byteEnd]];
174
+ }