@torrent-tv/proxy 2.98.19 → 2.99.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.98.19",
3
+ "version": "2.99.1",
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
@@ -66,6 +66,7 @@ import { nativeSourceMap, nativeOriginalSourceMap } from "./services/viewer/Nati
66
66
  import { MediaReadRequests } from "./services/media/MediaReadRequests.js";
67
67
  import { SegmentInputs } from "./services/media/SegmentInputs.js";
68
68
  import { directSegmentInput } from "./services/media/direct-segment-input.js";
69
+ import { piecesRunPastEnd } from "./services/encode/output/Timeline.js";
69
70
  import { ContainerTrack } from "./services/media/tracks/ContainerTrack.js";
70
71
  import { pauseCoefficient, viewerStartsOn } from "./services/viewer/PriorityMap.js";
71
72
  import { coalescing } from "./utils/coalesce.js";
@@ -626,7 +627,9 @@ export async function startProxyServer({
626
627
  const revision = outputParts.segmentStore.revisionOf(output.outputKey);
627
628
  const links = consumers.map(viewer => viewer.linkReading()?.linkMbps)
628
629
  .filter(value => Number.isFinite(value) && value > 0);
629
- played.push({ outputKey: output.outputKey, grid,
630
+ // A piece copying a stream answers for the second its end falls in,
631
+ // as the encode plan reads it (`piecesRunPastEnd`).
632
+ played.push({ outputKey: output.outputKey, grid, runsPastEnd: piecesRunPastEnd(output.spec),
630
633
  encodeSpeed: outputParts.encodeCost.latestSpeedReadingOf(output)?.speed,
631
634
  bytesPerSecond: bytesPerSecondOf(output, revision), linkMbps: links.length ? Math.min(...links) : null,
632
635
  readyRevision: revision, ready: () => outputParts.segmentStore.sizesOf(output.outputKey),
@@ -334,6 +334,18 @@ export class EncodeInputs {
334
334
  return rate === null || !open ? null : { bytes: Math.floor(rate * open.seconds * 1000), openMeasured: open.measured };
335
335
  }
336
336
 
337
+ /**
338
+ * What a stretch is sized from, for an output that may not exist yet: the
339
+ * recent copy rate in bytes per millisecond, and the memory encoder inputs
340
+ * may hold, each null while not known (`effective-speed.js`).
341
+ *
342
+ * @returns {{ copyBytesPerMs: number | null, capacityBytes: number | null }}
343
+ */
344
+ stretchTerms() {
345
+ const capacity = this.#capacity();
346
+ return { copyBytesPerMs: middleOf(this.#copyRates), capacityBytes: Number.isFinite(capacity) ? capacity : null };
347
+ }
348
+
337
349
  /**
338
350
  * @param {object} output
339
351
  * @returns {number | null}
@@ -25,6 +25,7 @@ import { cutOf, cutShiftOf, judgeNeighbors, judgePiece, leadingSecondsOf, lacksO
25
25
  import { leadingPictures, presentationSegment } from "./segment-formats/presentation-segment.js";
26
26
  import { InputFailures, failedAdmittedInput } from "./InputFailures.js";
27
27
  import { readProcessCpuSeconds } from "./host-load.js";
28
+ import { piecesRunPastEnd } from "./output/Timeline.js";
28
29
 
29
30
 
30
31
  /**
@@ -599,7 +600,7 @@ export class EncodeRuns {
599
600
  timeline?.inSegments?.(
600
601
  this.#host.priority.mapForOutput(address),
601
602
  Number(timeline?.segmentCount) || 0,
602
- { runsPastEnd: Boolean(spec?.audio) && spec?.transcodesAudio !== true }
603
+ { runsPastEnd: piecesRunPastEnd(spec) }
603
604
  ) ?? []
604
605
  );
605
606
  }
@@ -120,6 +120,7 @@ export class OutputOpening {
120
120
  * @param {number} [options.audioTrackIndex=0] - Type-relative audio track to map (0:a:N).
121
121
  * @param {boolean} [options.exactSize=false] - Produce the target box exactly (capped to source), with no budget downscale and no runtime downswitch. Says nothing about who asked: every rung of a master sets it.
122
122
  * @param {number | null} [options.capKbps=null] - Nominal bitrate limit in kbit/s; null for the size's own. Above the size's own it is refused.
123
+ * @param {string | null} [options.preset=null] - Speed setting of a software encode at a size produced exactly; null for the one this machine holds. Part of the output's identity.
123
124
  * @param {{ width: number, height: number } | null} [options.visiblePicture=null] - The picture as the requesting viewer sees it, in physical pixels. With no target box given, a re-encoded picture's box is the smallest rung whose frame is not smaller than this (roadmap item 98).
124
125
  * @returns {Promise<{ output: EncodedOutput, existed: boolean, audio: { trackIndex: number, transcode: boolean }, verdict: object | null }>}
125
126
  */
@@ -163,6 +164,9 @@ export class OutputOpening {
163
164
  // The nominal bitrate limit asked for, in kbit/s, or null for the size's
164
165
  // own. Part of the output's identity: another limit is another output.
165
166
  capKbps = null,
167
+ // The speed setting asked for, or null for the one the machine holds at
168
+ // the size. Named only with a size produced exactly (torrent-tv/meta#178).
169
+ preset = null,
166
170
  // The soundtrack THIS viewer receives with the picture — from this output
167
171
  // or beside it — as `{ transcode, entry }`. Left out, the one this
168
172
  // request names is theirs. A step opened on a viewer's behalf passes the
@@ -561,12 +565,18 @@ export class OutputOpening {
561
565
  observedDecodeCostSec: this.#host.encodeCost.decodeCostFor(file.key)?.costSec ?? null,
562
566
  requiredSpeed: this.#host.hostLoad.requiredSpeedFor(sourceKey, fileIndex)
563
567
  },
564
- chooseBudget: (params) => this.#host.encodeCost.chooseEncodeBudget(params),
568
+ // Each mode priced over a run, its open included: the file's rate says
569
+ // how much film the stretch an input is sized to holds.
570
+ chooseBudget: (params) => this.#host.encodeCost.chooseEncodeBudget({ ...params, run: {
571
+ sourceBytesPerSecond: file.megabitsPerSecond > 0 ? file.megabitsPerSecond * 125000 : null,
572
+ segmentSeconds: this.#host.segmentDurationSec
573
+ } }),
565
574
  machineAdmission: this.#host.admission
566
575
  ? (candidate) => this.#host.admission.previewCandidate(candidate, file)
567
576
  : null,
568
577
  tonemap: applyTonemap,
569
578
  capKbps,
579
+ preset: forceExactSize ? preset : null,
570
580
  limitsFor: (frame) => this.#host.limitsFor(frame, file),
571
581
  audioLoad: viewerAudio !== undefined
572
582
  ? soundtrackLoadOf(viewerAudio?.entry ?? null, viewerAudio ? viewerAudio.transcode : null)
@@ -408,10 +408,10 @@ export class Renditions {
408
408
  * those would not be a step of it.
409
409
  *
410
410
  * @param {HlsSession} base
411
- * @param {{ height: number, startSeconds: number, mode: "auto" | "manual", linkMbps: number | null, consumerId: string, capKbps: number | null }} params
411
+ * @param {{ height: number, startSeconds: number, mode: "auto" | "manual", linkMbps: number | null, consumerId: string, capKbps: number | null, preset?: string | null }} params
412
412
  * @returns {object}
413
413
  */
414
- #stepOpening(base, { height, startSeconds, mode, linkMbps, consumerId, capKbps }) {
414
+ #stepOpening(base, { height, startSeconds, mode, linkMbps, consumerId, capKbps, preset = null }) {
415
415
  return {
416
416
  sourceKey: base.file.sourceKey,
417
417
  fileIndex: base.file.fileIndex,
@@ -442,6 +442,9 @@ export class Renditions {
442
442
  // A limit named outright, or null for the rule to choose one. Named, it
443
443
  // is answered with that limit or not at all (`output-format.js`).
444
444
  capKbps,
445
+ // A speed setting named outright, or null for the one this machine holds
446
+ // at the size (torrent-tv/meta#178).
447
+ preset,
445
448
  // The soundtrack THIS viewer hears, which is part of what their link
446
449
  // has to carry and need not be the one the picture was opened with.
447
450
  viewerAudio: this.#viewerAudioOf(base, consumerId),
@@ -525,6 +528,7 @@ export class Renditions {
525
528
  if (!encode || !encode.rateControl) {
526
529
  return refuse("the output on screen has no limit to change (a copy, or a hardware encode)");
527
530
  }
531
+ const changes = "limit";
528
532
  const askedHeights = this.#host.heightsChosenAs(consumerId, current.outputKey ?? "");
529
533
  if (askedHeights.length === 0) {
530
534
  return refuse("no height is chosen as the output on screen");
@@ -546,9 +550,9 @@ export class Renditions {
546
550
  ? `no lower limit of ${encode.height}p is admitted by this viewer's link`
547
551
  : `no higher limit of ${encode.height}p is admitted by this viewer's link, or ${encode.height}p is at its highest`);
548
552
  }
549
- let prepared;
550
- try {
551
- prepared = await this.#host.createOrGetSession(this.#stepOpening(base, {
553
+ return this.#beginMove({
554
+ base, current, consumerId, askedHeights, direction, reasonText, changes,
555
+ opening: this.#stepOpening(base, {
552
556
  height: encode.height,
553
557
  startSeconds: this.#host.viewerPositionOf(base.id, consumerId),
554
558
  // Exactly the size on screen: a move between limits never changes it,
@@ -557,10 +561,124 @@ export class Renditions {
557
561
  linkMbps,
558
562
  consumerId,
559
563
  capKbps: target
560
- }));
564
+ }),
565
+ what: `${encode.height}p at ${target}kbps`,
566
+ matches: (prepared) => prepared?.height === encode.height && prepared?.width === encode.width &&
567
+ prepared?.rateControl?.maxrateKbps === maxrateKbpsFor(target),
568
+ described: `${encode.height}p, ${target}kbps`
569
+ });
570
+ }
571
+
572
+ /**
573
+ * Begin moving this viewer onto another speed setting of the encoder, at the
574
+ * size and limit on their screen (torrent-tv/meta#178).
575
+ *
576
+ * The same move as between two limits (`prepareSameHeightSwitch`): the
577
+ * address does not change, and the output on their screen changes once the
578
+ * segment they will ask for next is closed on the new one.
579
+ *
580
+ * WHICH SETTING. Down, when this machine makes their picture slower than
581
+ * realtime: the best setting faster than the one on screen that this
582
+ * machine holds here over a run, its open included
583
+ * (`EncodeCost.presetSpeedOf`); a smaller picture comes after it. Up,
584
+ * when they have room to spare: the next better setting only, and only if
585
+ * this machine holds it the same way. Every setting is priced by the whole
586
+ * model of what its encoding costs here, never by a ratio applied to what
587
+ * the setting on screen was measured doing.
588
+ *
589
+ * @param {HlsSession} named - The output the browser addresses.
590
+ * @param {string} consumerId
591
+ * @param {"down" | "up"} direction
592
+ * @param {string} reasonText - Why, for the log.
593
+ * @returns {Promise<{ started: boolean, reason: string, noPlace?: boolean }>}
594
+ */
595
+ async preparePresetSwitch(named, consumerId, direction, reasonText) {
596
+ const base = named ? this.#host.outputs.pictureOf(named) : null;
597
+ const refuse = (reason) => ({ started: false, reason });
598
+ if (!consumerId || !base) {
599
+ return refuse("nobody named, or no picture");
600
+ }
601
+ if (!this.#host.outputs.publishesVariants(base)) {
602
+ return refuse("this stream publishes no variants");
603
+ }
604
+ if (this.#host.sameHeightSwitchOf(consumerId)) {
605
+ return refuse("a move is already being prepared for this viewer");
606
+ }
607
+ const current = this.#host.activeOutputFor({ base, consumerId, outputs: this.#host.outputs });
608
+ const encode = current?.spec?.video?.encode ?? null;
609
+ const ladder = this.#host.presetLadder?.() ?? [];
610
+ const at = encode?.preset ? ladder.indexOf(encode.preset) : -1;
611
+ if (!encode || at < 0) {
612
+ return refuse("the output on screen has no speed setting of this machine's encoder to change");
613
+ }
614
+ // A container with an init is read against the init the player fetched
615
+ // once, and that init holds the encoder's parameter sets, which another
616
+ // speed setting changes (the reference count, among others): the move
617
+ // would be refused at the init's check once prepared. Segments that carry
618
+ // their own parameter sets (MPEG-TS) have no init to disagree with.
619
+ if (current.segmentFormat?.initFileName) {
620
+ return refuse(`${current.segmentFormat.id ?? "this format"} segments are read against one init, which another speed setting does not match`);
621
+ }
622
+ const askedHeights = this.#host.heightsChosenAs(consumerId, current.outputKey ?? "");
623
+ if (askedHeights.length === 0) {
624
+ return refuse("no height is chosen as the output on screen");
625
+ }
626
+ const bar = this.#host.requiredSpeedOf?.(base) ?? 1;
627
+ const priced = [];
628
+ const holds = (preset) => {
629
+ const speed = this.#host.presetSpeedOf?.(current, preset) ?? null;
630
+ priced.push(`${preset}=${speed ? `${speed.effective.toFixed(2)}x` : "unpriced"}`);
631
+ return speed !== null && speed.effective >= bar;
632
+ };
633
+ // The ladder is in quality order: the faster settings come after the one on screen.
634
+ const target = direction === "down"
635
+ ? ladder.slice(at + 1).find(holds)
636
+ : (at > 0 && holds(ladder[at - 1]) ? ladder[at - 1] : undefined);
637
+ if (target === undefined) {
638
+ return refuse(`no ${direction === "down" ? "faster" : "better"} setting than ${encode.preset} holds ` +
639
+ `${bar.toFixed(2)}x at ${encode.width}x${encode.height} here (${priced.join(", ") || "none"})`);
640
+ }
641
+ // The limit on screen goes with the picture: only the setting changes.
642
+ const limit = encode.rateControl
643
+ ? this.#host.limitsFor({ width: encode.width, height: encode.height }, base.file)
644
+ .find((one) => Number.isFinite(one) && maxrateKbpsFor(one) === encode.rateControl.maxrateKbps) ?? null
645
+ : null;
646
+ return this.#beginMove({
647
+ base, current, consumerId, askedHeights, direction, changes: "preset",
648
+ reasonText: `${reasonText}; priced ${priced.join(", ")} against ${bar.toFixed(2)}x`,
649
+ opening: this.#stepOpening(base, {
650
+ height: encode.height,
651
+ startSeconds: this.#host.viewerPositionOf(base.id, consumerId),
652
+ mode: "manual",
653
+ linkMbps: this.#host.linkMbpsOf(base, consumerId),
654
+ consumerId,
655
+ capKbps: limit,
656
+ preset: target
657
+ }),
658
+ what: `${encode.height}p at ${target}`,
659
+ matches: (prepared) => prepared?.height === encode.height && prepared?.width === encode.width &&
660
+ prepared?.preset === target &&
661
+ (prepared?.rateControl?.maxrateKbps ?? null) === (encode.rateControl?.maxrateKbps ?? null),
662
+ described: `${encode.height}p, ${encode.preset} → ${target}`
663
+ });
664
+ }
665
+
666
+ /**
667
+ * The rest of a move onto another output of the size on a viewer's screen,
668
+ * whatever differs — the limit or the speed setting: open it, check it is
669
+ * what was asked, ask the machine for a place, and record the move.
670
+ *
671
+ * @param {object} move
672
+ * @returns {Promise<{ started: boolean, reason: string, noPlace?: boolean }>}
673
+ */
674
+ async #beginMove({ base, current, consumerId, askedHeights, direction, changes, reasonText, opening, what, matches, described }) {
675
+ const refuse = (reason) => ({ started: false, reason });
676
+ let prepared;
677
+ try {
678
+ prepared = await this.#host.createOrGetSession(opening);
561
679
  } catch (error) {
562
680
  if (error?.code === OUTPUT_UNAVAILABLE) {
563
- return refuse(`the ${target}kbps output of ${encode.height}p does not suit this viewer: ${error.details?.reason ?? ""}`);
681
+ return refuse(`the output of ${what} does not suit this viewer: ${error.details?.reason ?? ""}`);
564
682
  }
565
683
  throw error;
566
684
  }
@@ -568,15 +686,8 @@ export class Renditions {
568
686
  if (!this.#host.encodeRuns.isLive(base) || !prepared) {
569
687
  return refuse("the picture ended while the output was being made");
570
688
  }
571
- const preparedEncode = prepared.spec?.video?.encode ?? null;
572
- if (
573
- prepared === current ||
574
- preparedEncode?.height !== encode.height ||
575
- preparedEncode?.width !== encode.width ||
576
- preparedEncode?.rateControl?.maxrateKbps !== maxrateKbpsFor(target) ||
577
- !isSameMaterial(prepared.spec, base.spec)
578
- ) {
579
- return refuse(`what was opened (${prepared.outputKey}) is not ${encode.height}p at ${target}kbps of this picture`);
689
+ if (prepared === current || !matches(prepared.spec?.video?.encode ?? null) || !isSameMaterial(prepared.spec, base.spec)) {
690
+ return refuse(`what was opened (${prepared.outputKey}) is not ${what} of this picture`);
580
691
  }
581
692
  // An output whose encoding has failed for good will close no segment, so a
582
693
  // move onto it would wait for ever — and, cancelled on that failure, be
@@ -606,7 +717,7 @@ export class Renditions {
606
717
  // with the picture, and the choice about to point at it is looked up among
607
718
  // this picture's steps and not found.
608
719
  if (prepared !== base) {
609
- prepared.variantHeight ??= encode.height;
720
+ prepared.variantHeight ??= current.spec.video.encode.height;
610
721
  this.#host.outputs.markStep(prepared);
611
722
  }
612
723
  this.#host.noteSameHeightSwitch(consumerId, {
@@ -614,6 +725,9 @@ export class Renditions {
614
725
  outputId: prepared.id,
615
726
  outputKey: prepared.outputKey ?? "",
616
727
  direction,
728
+ // What the move changes, which says what may undo it: a lower limit is
729
+ // for their link, a faster setting for this machine.
730
+ changes,
617
731
  reason: reasonText,
618
732
  since: Date.now()
619
733
  });
@@ -625,7 +739,7 @@ export class Renditions {
625
739
  this.#host.planEncodersSoon();
626
740
  this.#host.logger.info(
627
741
  `transcode ${base.id} preparing ${consumerId}'s move ${direction} from ${current.outputKey} to ` +
628
- `${prepared.outputKey} (${encode.height}p, ${target}kbps): ${reasonText}`
742
+ `${prepared.outputKey} (${described}): ${reasonText}`
629
743
  );
630
744
  // It may already be made there — another viewer on it, or material kept.
631
745
  this.#reconsiderSameHeightSwitch(base, consumerId, null);
@@ -836,7 +950,7 @@ export class Renditions {
836
950
  this.#cancelSameHeightSwitch(base, consumerId, "the output being prepared no longer suits this viewer's link");
837
951
  return;
838
952
  }
839
- if (move.direction === "down" && this.#suitsViewer(current, base, consumerId).suits) {
953
+ if (move.direction === "down" && move.changes !== "preset" && this.#suitsViewer(current, base, consumerId).suits) {
840
954
  this.#cancelSameHeightSwitch(base, consumerId, "this viewer's link carries the output on screen again");
841
955
  return;
842
956
  }
@@ -42,6 +42,27 @@
42
42
  * reveal where the file's cuts really are.
43
43
  */
44
44
 
45
+ /**
46
+ * Whether an output's pieces copy a stream, picture or sound, and so may hold
47
+ * a little past their published end: a copy ends at the first packet boundary
48
+ * at or after it, and the player places the next piece where this one truly
49
+ * ends (hls.js `updateFragPTSDTS`). A viewer standing at a published edge is
50
+ * then asked for by the piece BEFORE it.
51
+ *
52
+ * Field 2026-10-09, MP3 copied out of an AVI: a seek to 4000 s asked for the
53
+ * piece ending at 4000 s. Field r43 (Home Assistant, 2026-10-15), a copied
54
+ * Matroska picture: a seek to 1000.000 s, the published start of #96, asked for
55
+ * #95 on both tracks; the picture's pieces were not counted as copies, nothing
56
+ * made #95, and the picture stood 131 s until the viewer moved
57
+ * (torrent-tv/meta#178).
58
+ *
59
+ * @param {{ video?: object | null, audio?: object | null, transcodesVideo?: boolean, transcodesAudio?: boolean } | null | undefined} spec
60
+ * @returns {boolean}
61
+ */
62
+ export function piecesRunPastEnd(spec) {
63
+ return (Boolean(spec?.video) && spec.transcodesVideo !== true) || (Boolean(spec?.audio) && spec.transcodesAudio !== true);
64
+ }
65
+
45
66
  export class Timeline {
46
67
  /**
47
68
  * @param {object} params
@@ -62,6 +62,7 @@ import { computeOutputDimensions, TRANSCODE_FPS } from "../args.js";
62
62
  import { logger } from "../../../utils/logger.js";
63
63
  import { qualityStateOf } from "./OutputQualityState.js";
64
64
  import { audioReadingFor } from "../audio-work-rate.js";
65
+ import { effectiveSpeed } from "./effective-speed.js";
65
66
 
66
67
  export class EncodeCost {
67
68
  /**
@@ -127,6 +128,54 @@ export class EncodeCost {
127
128
  }
128
129
  }
129
130
 
131
+ /**
132
+ * What this output's picture would do at another speed setting of its
133
+ * encoder, at the same size, on this machine as it is: the startup
134
+ * measurement of THAT mode at this frame, this file's decoding as measured
135
+ * on it, and everything else this family runs — the output itself left out,
136
+ * since the candidate takes its place. Then the same over a run, its open
137
+ * included (`effective-speed.js`), at this output's own measured open and the
138
+ * stretch its inputs give. No ratio between two modes is applied to the
139
+ * whole of FFmpeg's work: the decoding, the copy of the input and the
140
+ * contention do not get faster with the preset (review of r41,
141
+ * torrent-tv/meta#178).
142
+ *
143
+ * @param {object} session - A re-encoded picture.
144
+ * @param {string} preset
145
+ * @param {{ segmentSeconds: number }} run
146
+ * @returns {{ processing: number, effective: number, openSeconds: number | null, filmSeconds: number | null } | null}
147
+ * Null where the mode is not measured at this size, or something running is unpriced.
148
+ */
149
+ presetSpeedOf(session, preset, { segmentSeconds }) {
150
+ const encode = session?.spec?.video?.encode ?? null;
151
+ const benchmark = this.#host().benchmark;
152
+ const entry = Array.isArray(benchmark) ? modeEntryFor(benchmark, preset) : null;
153
+ if (!encode || !entry || throughputAt(entry, { width: encode.width, height: encode.height }) === null) return null;
154
+ const own = processCanBeSignalled(this.#stateFor(session)) ? this.#pictureCostOf(session) : 0;
155
+ const processing = this.#speedOnThisMachine({
156
+ benchmark: [entry],
157
+ source: session.file?.decode ?? null,
158
+ width: encode.width,
159
+ height: encode.height,
160
+ fps: Number(encode.fps) || TRANSCODE_FPS,
161
+ observedDecodeCostSec: this.decodeCostFor(session.file?.key ?? "")?.costSec ?? null,
162
+ concurrentCostSec: Math.max(0, this.committedCostOf(session) - own)
163
+ });
164
+ if (!(processing > 0)) return null;
165
+ // WHAT THIS MODE WAS SEEN DOING HERE, alone, on material no easier than
166
+ // this: no better here than that. A setting a viewer was moved off for
167
+ // running below realtime is then not priced back above it by the model.
168
+ const encodeOf = { ...encode, preset };
169
+ const seen = correctForAvailability(this.#host().observedAloneSpeed?.({ spec: { video: { encode: encodeOf } },
170
+ file: session.file }), this.#host().availability);
171
+ const held = Number.isFinite(seen) && seen > 0 ? Math.min(processing, seen) : processing;
172
+ const megabits = Number(session.file?.megabitsPerSecond);
173
+ const shape = this.#runShapeFor({ outputKey: session.outputKey ?? null,
174
+ sourceBytesPerSecond: megabits > 0 ? megabits * 125000 : null, segmentSeconds });
175
+ return { processing: held, effective: shape ? effectiveSpeed({ processingSpeed: held, ...shape }) : held,
176
+ openSeconds: shape?.openSeconds ?? null, filmSeconds: shape?.filmSeconds ?? null };
177
+ }
178
+
130
179
  /**
131
180
  * The speed an encode is predicted to run at on this machine: the startup
132
181
  * measurement of the cheapest mode in `benchmark` at this size, the decode of
@@ -200,6 +249,7 @@ export class EncodeCost {
200
249
  #torrentCostSecFor;
201
250
  #runsFor;
202
251
  #stateFor;
252
+ #runShapeFor;
203
253
 
204
254
  /**
205
255
  * @param {{
@@ -209,10 +259,12 @@ export class EncodeCost {
209
259
  * encodersRunningNow: () => number,
210
260
  * torrentCostSecFor: (session: object) => number,
211
261
  * runsFor: (output: object) => object[],
212
- * stateFor: (output: object) => string
262
+ * stateFor: (output: object) => string,
263
+ * runShapeFor?: (run: { outputKey: string | null, sourceBytesPerSecond: number | null, segmentSeconds: number }) =>
264
+ * { openSeconds: number, filmSeconds: number } | null
213
265
  * }} deps
214
266
  */
215
- constructor({ outputs, host, runningEncoders, encodersRunningNow, torrentCostSecFor, runsFor, stateFor }) {
267
+ constructor({ outputs, host, runningEncoders, encodersRunningNow, torrentCostSecFor, runsFor, stateFor, runShapeFor = () => null }) {
216
268
  this.#outputs = outputs;
217
269
  // Asked at the moment of the question, not copied: the share of the machine
218
270
  // that is free is re-read every few seconds, and a copy taken when this was
@@ -229,6 +281,7 @@ export class EncodeCost {
229
281
  }
230
282
  this.#runsFor = runsFor;
231
283
  this.#stateFor = stateFor;
284
+ this.#runShapeFor = runShapeFor;
232
285
  }
233
286
 
234
287
  /**
@@ -1092,7 +1145,15 @@ export class EncodeCost {
1092
1145
  * (no video transcode, hardware encoder, or missing benchmark/source size) —
1093
1146
  * the encode then keeps the ceiling resolution and the default preset.
1094
1147
  *
1095
- * @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null, outputFps: number, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} params
1148
+ * EACH MODE IS PRICED WITH ITS RUN'S OPEN (`effective-speed.js`): a run
1149
+ * opens its input before it encodes the stretch it is given, so a mode that
1150
+ * encodes just above realtime falls below it over a short stretch. Priced on
1151
+ * encoding alone, `fast` was chosen on Home Assistant at 1.03x and its runs
1152
+ * of one piece took 5.9-7.5 s for 4 s of film (r41, torrent-tv/meta#178).
1153
+ * `run` names the output's file rate and piece length; without it each mode
1154
+ * is priced on encoding alone.
1155
+ *
1156
+ * @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null, outputFps: number, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, run?: { sourceBytesPerSecond: number | null, segmentSeconds: number } | null }} params
1096
1157
  * @returns {{ width: number, height: number, preset: string } | null}
1097
1158
  */
1098
1159
  chooseEncodeBudget({
@@ -1104,7 +1165,8 @@ export class EncodeCost {
1104
1165
  outputFps,
1105
1166
  source = null,
1106
1167
  requiredSpeed = null,
1107
- observedDecodeCostSec = null
1168
+ observedDecodeCostSec = null,
1169
+ run = null
1108
1170
  }) {
1109
1171
  if (!transcodeVideo || this.#host().encoderKind !== "software" || !this.#host().benchmark) {
1110
1172
  return null;
@@ -1118,10 +1180,12 @@ export class EncodeCost {
1118
1180
  // opening had it and the choice priced decoding from the startup model
1119
1181
  // instead (review of r28, torrent-tv/meta#178).
1120
1182
  const decodeCostSec = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0 ? observedDecodeCostSec : null;
1183
+ // The output does not exist yet: its open is the host's (`RunCosts.openOf`).
1184
+ const shape = run ? this.#runShapeFor({ outputKey: null, ...run }) : null;
1121
1185
  /** Every size and preset the choice priced, with the speed it was priced at. */
1122
1186
  const considered = [];
1123
1187
  const speedForPreset = (benchmark, frame, outputPixelsPerSec) => {
1124
- const speed = concurrentCostSec === null ? null : this.#speedOnThisMachine({
1188
+ const processing = concurrentCostSec === null ? null : this.#speedOnThisMachine({
1125
1189
  benchmark,
1126
1190
  source,
1127
1191
  width: frame.width,
@@ -1130,7 +1194,8 @@ export class EncodeCost {
1130
1194
  observedDecodeCostSec: decodeCostSec,
1131
1195
  concurrentCostSec
1132
1196
  });
1133
- considered.push({ preset: benchmark[0]?.preset, width: frame.width, height: frame.height, speed });
1197
+ const speed = shape ? effectiveSpeed({ processingSpeed: processing, ...shape }) : processing;
1198
+ considered.push({ preset: benchmark[0]?.preset, width: frame.width, height: frame.height, speed, processing });
1134
1199
  return speed;
1135
1200
  };
1136
1201
  const choice = chooseSoftwareEncodeSettings(
@@ -1144,10 +1209,12 @@ export class EncodeCost {
1144
1209
  // r28, torrent-tv/meta#178).
1145
1210
  if (choice) {
1146
1211
  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(", ");
1212
+ const priced = considered.map(({ preset, width, height, speed, processing }) =>
1213
+ `${width}x${height} ${preset}=${Number.isFinite(speed) ? `${speed.toFixed(2)}x` : "unpriced"}` +
1214
+ (shape && Number.isFinite(processing) ? ` (${processing.toFixed(2)}x encoding)` : "")).join(", ");
1215
+ const opened = shape ? `each run opening in ${shape.openSeconds.toFixed(2)}s for ${shape.filmSeconds.toFixed(1)}s of film; ` : "";
1149
1216
  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`}; ` +
1217
+ `priced ${priced}; ${opened}decoding ${decodeCostSec === null ? "from the startup model" : `${decodeCostSec.toFixed(4)} s per second, measured on this file`}; ` +
1151
1218
  `machine ${availability?.known ? `${Math.round(availability.share * 100)}% to spare` : "share unknown"}; ` +
1152
1219
  `committed work ${concurrentCostSec === null ? "unmeasured" : `${concurrentCostSec.toFixed(3)} s per second`}`);
1153
1220
  }
@@ -307,6 +307,15 @@ export class QualityController {
307
307
  logger.info(`[budget] transcode ${session.id} no step up for ${consumerId}: ${move.reason}`);
308
308
  return;
309
309
  }
310
+ // Then a better speed setting of the same size, the way back from a move
311
+ // to a faster one, and the best picture of that size this machine holds
312
+ // (torrent-tv/meta#178).
313
+ if (this.#host.preparePresetSwitch) {
314
+ const better = await this.#host.preparePresetSwitch(session, consumerId, "up", reasonText);
315
+ if (better.started || better.noPlace) {
316
+ return;
317
+ }
318
+ }
310
319
  // One rung at a time: the lowest height above the one on screen, never
311
320
  // above the source (upscaling invents detail and costs more than the source
312
321
  // itself). A rung this host has been measured failing at is not offered
@@ -520,6 +529,15 @@ export class QualityController {
520
529
  }
521
530
  logger.info(`[budget] transcode ${session.id} ${reasonText}: no lower limit to move to (${move.reason})`);
522
531
  }
532
+ // For the machine, first a faster speed setting of the size on their
533
+ // screen, which keeps their picture's size (torrent-tv/meta#178).
534
+ if (machineShort && this.#host.preparePresetSwitch) {
535
+ const move = await this.#host.preparePresetSwitch(session, consumerId, "down", `machine-bound ${reasonText}`);
536
+ if (move.started) {
537
+ return true;
538
+ }
539
+ logger.info(`[budget] transcode ${session.id} ${reasonText}: no faster setting to move to (${move.reason})`);
540
+ }
523
541
  const base = this.#host.outputs.pictureOf(session);
524
542
  if (this.#askLowerHeight(session, `${linkShort ? "viewer-link-bound" : "machine-bound"} ${reasonText}`, [consumerId], {
525
543
  urgent: true,
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @file How fast a run makes film over its whole life, its open included
3
+ * (torrent-tv/meta#178).
4
+ *
5
+ * A run first opens its input and then encodes the stretch it was given. Over
6
+ * the run, film comes out at
7
+ *
8
+ * effectiveSpeed = filmSeconds / (openSeconds + filmSeconds / processingSpeed)
9
+ *
10
+ * where `processingSpeed` is what the encoding alone does, `openSeconds` the
11
+ * time to its first output less that output's encoding, and `filmSeconds` the
12
+ * stretch it is given. A run of one piece pays the whole open for that piece:
13
+ * on Home Assistant (r41) runs of one 4 s piece took 5.9-7.5 s each at a
14
+ * processing speed priced above realtime, and the picture stood 22 times.
15
+ */
16
+
17
+ /**
18
+ * @param {{ processingSpeed: number | null, openSeconds: number | null, filmSeconds: number | null }} run
19
+ * @returns {number | null} Null when the processing speed is not known; the
20
+ * processing speed itself when the run's open or stretch is not.
21
+ */
22
+ export function effectiveSpeed({ processingSpeed, openSeconds, filmSeconds }) {
23
+ if (!(processingSpeed > 0)) return null;
24
+ if (!(filmSeconds > 0) || !Number.isFinite(openSeconds) || openSeconds < 0) return processingSpeed;
25
+ return filmSeconds / (openSeconds + filmSeconds / processingSpeed);
26
+ }
27
+
28
+ /**
29
+ * The film a run is given, as the inputs size it (`EncodeInputs.stretchFor`):
30
+ * the copy rate times the open, within the memory inputs may hold, and one
31
+ * piece while no copy rate has been measured.
32
+ *
33
+ * @param {{ copyBytesPerMs: number | null, openSeconds: number, capacityBytes: number | null,
34
+ * sourceBytesPerSecond: number | null, segmentSeconds: number }} params
35
+ * @returns {number} Seconds of film; never less than one piece.
36
+ */
37
+ export function runFilmSeconds({ copyBytesPerMs, openSeconds, capacityBytes, sourceBytesPerSecond, segmentSeconds }) {
38
+ const piece = segmentSeconds > 0 ? segmentSeconds : 0;
39
+ if (!(copyBytesPerMs > 0) || !(openSeconds > 0) || !(sourceBytesPerSecond > 0)) return piece;
40
+ const stretch = copyBytesPerMs * openSeconds * 1000;
41
+ const bytes = Number.isFinite(capacityBytes) && capacityBytes >= 0 ? Math.min(stretch, capacityBytes) : stretch;
42
+ return Math.max(piece, bytes / sourceBytesPerSecond);
43
+ }
@@ -37,6 +37,7 @@
37
37
  import { computeOutputDimensions, nominalKbpsFor, softwareRateControlFor } from "../args.js";
38
38
  import { buildResolutionLadder, canSustainOutput, pickSoftwarePreset } from "../hwaccel.js";
39
39
  import { OutputSpec } from "../output/OutputSpec.js";
40
+ import { throughputAt } from "../throughput.js";
40
41
  import { chooseServingOutput, nextRungAreaBelow, servingCandidates } from "./serving-output.js";
41
42
  import { linkAnswerFigures, linkCouldCarry, linkRefusalReason, loadOf, videoLoadOfSpec } from "./link-budget.js";
42
43
 
@@ -56,6 +57,10 @@ import { linkAnswerFigures, linkCouldCarry, linkRefusalReason, loadOf, videoLoad
56
57
  * @param {boolean} params.tonemap
57
58
  * @param {number | null} [params.capKbps] - A nominal limit asked for outright;
58
59
  * above the size's own it is refused (`softwareRateControlFor`).
60
+ * @param {string | null} [params.preset] - A speed setting asked for outright,
61
+ * at a size produced exactly: a viewer moved to another mode of the size they
62
+ * watch (torrent-tv/meta#178). One this encoder did not measure at that size
63
+ * is not given.
59
64
  * @param {(frame: { width: number, height: number }, source: object) => Array<number | null>} [params.limitsFor] -
60
65
  * The nominal limit derived from this source's picture rate and frame area.
61
66
  * @param {import("./link-budget.js").LoadPart | null} [params.audioLoad] - The
@@ -84,6 +89,7 @@ export function decideOutputFormat({
84
89
  chooseBudget,
85
90
  tonemap,
86
91
  capKbps = null,
92
+ preset = null,
87
93
  limitsFor = (frame, source) => [nominalKbpsFor(frame, {
88
94
  width: source.width, height: source.height, pictureKbps: source.pictureKbps
89
95
  })],
@@ -175,11 +181,19 @@ export function decideOutputFormat({
175
181
  }
176
182
  return canSustainOutput({ benchmark, ...priced, outputPixelsPerSec: w * h * fps, frame: { width: w, height: h } }).sustainable;
177
183
  };
178
- const presetFor = (w, h) => (budget && w === width && h === height
179
- ? budget.preset
180
- : measured && w > 0
181
- ? pickSoftwarePreset(benchmark, w * h * fps, priced, { width: w, height: h })
182
- : null);
184
+ const presetNamed = typeof preset === "string" && preset.length > 0;
185
+ if (presetNamed && !(measured && benchmark.some((entry) => entry.preset === preset &&
186
+ throughputAt(entry, { width, height }) !== null))) {
187
+ return { ...refused(specWith(null), null, `the speed setting ${preset} is not measured at ${width}x${height} on this machine`),
188
+ unavailable: { reason: `the speed setting ${preset} is not measured at ${width}x${height} on this machine`, figures: null, bound: "machine" } };
189
+ }
190
+ const presetFor = (w, h) => (presetNamed && w === width && h === height
191
+ ? preset
192
+ : budget && w === width && h === height
193
+ ? budget.preset
194
+ : measured && w > 0
195
+ ? pickSoftwarePreset(benchmark, w * h * fps, priced, { width: w, height: h })
196
+ : null);
183
197
  const budgetFor = (spec) => {
184
198
  const encode = spec.video?.encode;
185
199
  if (!budget || !encode) {
@@ -226,7 +240,8 @@ export function decideOutputFormat({
226
240
  // leaving. An output with exactly this limit is still reused: it has the same
227
241
  // key, and the key is what `OutputOpening` looks up.
228
242
  const limitNamed = capKbps !== null && capKbps !== undefined;
229
- if (ceiling && !limitNamed) {
243
+ // And so is a speed setting named: the output being left suits them too.
244
+ if (ceiling && !limitNamed && !presetNamed) {
230
245
  const served = chooseServingOutput({
231
246
  mode: serving.mode,
232
247
  wanted: { width, height },
@@ -79,6 +79,7 @@ import { EncodeRuns } from "../encode/EncodeRuns.js";
79
79
  import { EncodeInputs } from "../encode/EncodeInputs.js";
80
80
  import { OutputTimes } from "../encode/OutputTimes.js";
81
81
  import { HostLoad } from "../encode/quality/HostLoad.js";
82
+ import { runFilmSeconds } from "../encode/quality/effective-speed.js";
82
83
  import { ColdStarts, secondsToFirstPiece } from "../encode/quality/ColdStarts.js";
83
84
  import { BUDGET_CHECK_INTERVAL_MS, QualityController } from "../encode/quality/QualityController.js";
84
85
  import { EncodeOrchestrator } from "../encode/EncodeOrchestrator.js";
@@ -435,6 +436,14 @@ export function wireOutputs({
435
436
  // under, the segment they were last given, the move being prepared, and
436
437
  // whether a segment is closed on the output being prepared.
437
438
  limitsFor: (frame, file) => parts.limitsFor(frame, file),
439
+ // A move between two speed settings of the size on a viewer's screen
440
+ // (torrent-tv/meta#178): the settings this machine measured, in quality
441
+ // order; what one of them would do here for an output, over a run; and
442
+ // the speed this file's supply asks of it.
443
+ presetLadder: () => (parts.encoders.current?.kind === "software" && Array.isArray(parts.softwarePresetBenchmark)
444
+ ? parts.softwarePresetBenchmark.map((entry) => entry.preset) : []),
445
+ presetSpeedOf: (output, preset) => parts.encodeCost.presetSpeedOf(output, preset, { segmentSeconds: parts.segmentDurationSec }),
446
+ requiredSpeedOf: (output) => parts.hostLoad.requiredSpeedFor(output.file.sourceKey, output.file.fileIndex) ?? 1,
438
447
  heightsChosenAs: (consumerId, outputKey) => heightsChosenAs(parts.viewers, consumerId, outputKey),
439
448
  highestGivenSegmentOf: (consumerId, askedHeight) => highestGivenSegmentOf(parts.viewers, consumerId, askedHeight),
440
449
  sameHeightSwitchOf: (consumerId) => sameHeightSwitchOf(parts.viewers, consumerId),
@@ -889,6 +898,20 @@ export function wireOutputs({
889
898
  encodersRunningNow: () => parts.encodeOrchestrator.runningCount(),
890
899
  torrentCostSecFor: (session) => parts.hostLoad.torrentCostSecFor(session),
891
900
  runsFor: (session) => parts.encodeRuns.runsOf(session),
901
+ // A run's open and the film its input holds (`effective-speed.js`): the
902
+ // output's own open, else the host's, and the stretch the inputs size.
903
+ // Not known before a copy rate is measured: the inputs then give one
904
+ // piece, but a mode is chosen for the whole film, and the first runs of a
905
+ // cold start are short for want of bytes, not because of the mode. Priced
906
+ // as runs of one 4 s piece at the host's 3.21 s open, even `ultrafast` at
907
+ // 4.79x would make 0.99x, and the choice would fall to the lowest rung.
908
+ runShapeFor: ({ outputKey, sourceBytesPerSecond, segmentSeconds }) => {
909
+ const open = parts.encodeOrchestrator?.openOf(outputKey ?? "") ?? null;
910
+ const { copyBytesPerMs = null, capacityBytes = null } = parts.encodeInputs?.stretchTerms?.() ?? {};
911
+ if (!open || !(copyBytesPerMs > 0)) return null;
912
+ return { openSeconds: open.seconds,
913
+ filmSeconds: runFilmSeconds({ copyBytesPerMs, openSeconds: open.seconds, capacityBytes, sourceBytesPerSecond, segmentSeconds }) };
914
+ },
892
915
  stateFor: (session) => parts.encodeRuns.runStateOf(session)
893
916
  });
894
917
  // WHICH HEIGHTS ARE ON THE MENU, which is the arithmetic above plus three
@@ -1163,6 +1186,9 @@ export function wireOutputs({
1163
1186
  // screen, prepared and moved to without their player being told.
1164
1187
  prepareSameHeightSwitch: (output, consumerId, direction, reason) =>
1165
1188
  parts.renditions.prepareSameHeightSwitch(output, consumerId, direction, reason),
1189
+ // And on the machine: another speed setting of that size (torrent-tv/meta#178).
1190
+ preparePresetSwitch: (output, consumerId, direction, reason) =>
1191
+ parts.renditions.preparePresetSwitch(output, consumerId, direction, reason),
1166
1192
  sameHeightSwitchPending: (consumerId) => parts.renditions.sameHeightSwitchPending(consumerId),
1167
1193
  sameHeightSwitchDirection: (consumerId) => parts.renditions.sameHeightSwitchDirection(consumerId),
1168
1194
  cancelSameHeightSwitch: (consumerId, reason) => parts.renditions.cancelSameHeightSwitch(consumerId, reason),
@@ -267,11 +267,18 @@ class RangeEntry {
267
267
 
268
268
  /** One output segment as the projection holds it. */
269
269
  class Segment {
270
- constructor(output, index, from, to) {
270
+ constructor(output, index, from, to, reach = to) {
271
271
  this.output = output;
272
272
  this.index = index;
273
273
  this.from = from;
274
274
  this.to = to;
275
+ /**
276
+ * Up to which second the map's zones are read for it: its end, and for a
277
+ * piece that copies a stream the second its end falls in as well, as the
278
+ * encode plan reads it (`Timeline.inSegments`): the player asks a viewer
279
+ * standing at its end for this piece, and its bytes are fetched for them.
280
+ */
281
+ this.reach = reach;
275
282
  /** Whether a zone of the map overlaps it. */
276
283
  this.demanded = false;
277
284
  /** Whether the output has it made. */
@@ -304,13 +311,16 @@ class Segment {
304
311
 
305
312
  /** One output: its grid, how long its segments take to make and deliver, what is made, and its next input. */
306
313
  class OutputState {
307
- constructor(outputKey, grid) {
314
+ constructor(outputKey, grid, runsPastEnd = false) {
308
315
  this.outputKey = outputKey;
309
316
  this.grid = grid;
317
+ this.runsPastEnd = runsPastEnd;
310
318
  /** @type {Segment[]} */
311
319
  this.segments = [];
312
320
  for (let index = 0; index + 1 < grid.length; index++) {
313
- this.segments.push(new Segment(this, index, grid[index], grid[index + 1]));
321
+ const end = grid[index + 1];
322
+ this.segments.push(new Segment(this, index, grid[index], end,
323
+ runsPastEnd && index + 2 < grid.length ? Math.max(end, Math.floor(end) + 1) : end));
314
324
  }
315
325
  this.encodeSpeed = undefined;
316
326
  this.bytesPerSecond = undefined;
@@ -370,7 +380,7 @@ export class PlaybackProjection {
370
380
  * @param {object} params
371
381
  * @param {Array<{ from: number, to: number, priority: number, urgent?: boolean, behind?: boolean, deferred?: boolean,
372
382
  * deadlineAt?: number }>} params.zones - The file's map zones, deadlines as instants.
373
- * @param {Array<{ outputKey: string, grid: number[], encodeSpeed?: number, bytesPerSecond?: number | null,
383
+ * @param {Array<{ outputKey: string, grid: number[], runsPastEnd?: boolean, encodeSpeed?: number, bytesPerSecond?: number | null,
374
384
  * linkMbps?: number | null, readyRevision: unknown, ready: () => Map<number, number>,
375
385
  * target: number | null }>} params.outputs - Every output of the file somebody is watching, with
376
386
  * what decides how long its segments take to make and deliver (`sourceDeadline`), what it has made
@@ -391,13 +401,13 @@ export class PlaybackProjection {
391
401
  for (const info of outputs) {
392
402
  seen.add(info.outputKey);
393
403
  let output = this.#outputs.get(info.outputKey);
394
- if (output && output.grid !== info.grid) {
404
+ if (output && (output.grid !== info.grid || output.runsPastEnd !== Boolean(info.runsPastEnd))) {
395
405
  this.#dropOutput(output);
396
406
  output = undefined;
397
407
  }
398
408
  const fresh = !output;
399
409
  if (fresh) {
400
- output = new OutputState(info.outputKey, info.grid);
410
+ output = new OutputState(info.outputKey, info.grid, Boolean(info.runsPastEnd));
401
411
  this.#outputs.set(info.outputKey, output);
402
412
  }
403
413
  // How long a segment of it takes to make and deliver.
@@ -426,7 +436,9 @@ export class PlaybackProjection {
426
436
  for (const segment of output.segments) if (this.#demand(segment, zones, ordered, nextBase, leadMoved)) reworked++;
427
437
  } else {
428
438
  for (const { from, to } of stretches) {
429
- for (let index = segmentAt(output.grid, from); index < output.segments.length && output.segments[index].from < to; index++) {
439
+ // A piece reaching past its end is read for the stretch after it too.
440
+ for (let index = Math.max(0, segmentAt(output.grid, from) - (output.runsPastEnd ? 1 : 0));
441
+ index < output.segments.length && output.segments[index].from < to; index++) {
430
442
  if (this.#demand(output.segments[index], zones, ordered, nextBase, false)) reworked++;
431
443
  }
432
444
  }
@@ -542,7 +554,7 @@ export class PlaybackProjection {
542
554
  #demand(segment, zones, ordered, base, leadMoved) {
543
555
  // The zones it overlaps, folded as they are met.
544
556
  let owner = null, ownerDeadline = Infinity, priority = 0, urgent = false, behind = true, deferred = true;
545
- const { from, to } = segment;
557
+ const { from, reach: to } = segment;
546
558
  for (let at = ordered ? firstEndingAfter(zones, from) : 0; at < zones.length; at += 1) {
547
559
  const zone = zones[at];
548
560
  if (ordered && !(zone.from < to)) break;
@@ -592,8 +604,8 @@ export class PlaybackProjection {
592
604
  let owner = null, ownerDeadline = Infinity;
593
605
  for (let at = this.#ordered ? firstEndingAfter(zones, segment.from) : 0; at < zones.length; at += 1) {
594
606
  const zone = zones[at];
595
- if (this.#ordered && !(zone.from < segment.to)) break;
596
- if (!(zone.priority > 0) || !(zone.from < segment.to && zone.to > segment.from)) continue;
607
+ if (this.#ordered && !(zone.from < segment.reach)) break;
608
+ if (!(zone.priority > 0) || !(zone.from < segment.reach && zone.to > segment.from)) continue;
597
609
  const deadline = Number.isFinite(zone.deadlineAt) ? zone.deadlineAt + Math.max(0, segment.from - zone.from) * 1000 : Infinity;
598
610
  if (owner === null || deadline < ownerDeadline) { owner = zone; ownerDeadline = deadline; }
599
611
  }