@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 +1 -1
- package/server.js +4 -1
- package/services/encode/EncodeInputs.js +12 -0
- package/services/encode/EncodeRuns.js +2 -1
- package/services/encode/OutputOpening.js +11 -1
- package/services/encode/Renditions.js +133 -19
- package/services/encode/output/Timeline.js +21 -0
- package/services/encode/quality/EncodeCost.js +76 -9
- package/services/encode/quality/QualityController.js +18 -0
- package/services/encode/quality/effective-speed.js +43 -0
- package/services/encode/quality/output-format.js +21 -6
- package/services/server/wire-outputs.js +26 -0
- package/services/torrent/PlaybackProjection.js +22 -10
package/package.json
CHANGED
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
|
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
|
-
|
|
572
|
-
|
|
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} (${
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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"}`
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
596
|
-
if (!(zone.priority > 0) || !(zone.from < segment.
|
|
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
|
}
|