Schneller anfangen, dann hinaufgehen

Es dauerte eine halbe Minute bis zum ersten Bild. Der Grund war nicht die
Leitung, sondern der Server: „Auto\" begann mit acht Megabit, und das heisst
1080p von der ersten Sekunde an. So lange braucht er, um das zu rechnen.

Jetzt wird mit drei Megabit begonnen — also 720p. Das laeuft in wenigen
Sekunden an, sieht einen Augenblick lang weicher aus, und von dort geht es
hinauf, sobald sich zeigt, dass die Leitung mehr traegt.

Dabei steckt eine Falle, die den ganzen Gewinn aufgezehrt haette: der
Server baut seine Leiter aus der Bestellung. Wer mit drei Megabit anfaengt,
bekommt eine Leiter, deren oberste Sprosse drei Megabit ist — und der
billige Hebel bewegt sich nur **innerhalb** dieser Leiter. Aus dem
schnellen Start waere ein Film geworden, der den ganzen Abend in 720p
bleibt.

Hoeher geht es deshalb ueber einen Neuaufbau, und der braucht Regeln: nicht
zu frueh, nicht nach einem Ruckler, nicht ohne Vorrat, nicht oefter als
zweimal. Sie stehen in `UpgradeRule` — reiner Typ, dreizehn Tests, wie beim
Absenken. Und deutlich zurueckhaltender als dort: ein zu spaetes Absenken
bedeutet Ruckeln, ein zu spaetes Hinaufschalten ein etwas weicheres Bild.
Das erste ist ein Fehler, das zweite eine Geschmacksfrage.

Bei Live-TV wird nicht hinaufgegangen: dort kostet jeder Neuaufbau den
Tuner, und ein schaerferes Bild ist diesen Einsatz nicht wert.

`StallLedger` kann jetzt sagen, wann der letzte Ruckler war — das brauchte
die Regel, und „noch nie gestockt\" ist etwas anderes als „vor langer Zeit\".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-29 06:25:15 +02:00
co-authored by Claude Opus 5
parent 57170b20ea
commit 4323c47db6
5 changed files with 422 additions and 5 deletions
+98 -5
View File
@@ -215,6 +215,9 @@ final class PlayerModel {
/// Einmal gesetzt, bleibt er aus: ein zweiter Anlauf mit demselben
/// Aufbau scheiterte am selben Grund.
private var prefetchGaveUp = false
/// Wie oft in dieser Wiedergabe nach oben neu aufgebaut wurde.
private var upgradesUsed = 0
private var lastUpgradeAt: Duration?
/// Die Notbremse, die eine hängengebliebene Abdeckung wegnimmt.
@ObservationIgnored private var resumeGiveUp: Task<Void, Never>?
/// Der Server, von dem der Player holt — und der Vorrat dahinter.
@@ -327,10 +330,20 @@ final class PlayerModel {
/// Unterscheidung ist nicht geraten — sie steht in `isLocalConnection`.
private var startingCeiling: Int? {
guard !isLocalConnection else { return nil }
// Acht Megabit tragen 1080p ordentlich und laufen auch über eine
// mässige Fernverbindung an. Wer mehr will und mehr hat, kommt über
// das Hinaufschalten dorthin.
return 8_000
// **Drei Megabit, nicht acht.**
//
// Nicht wegen der Leitung — acht Megabit trägt fast jede. Wegen des
// **Servers**: er muss den Film erst rechnen, und wie lange das
// dauert, hängt vor allem an der Auflösung. Acht Megabit bedeuteten
// 1080p von der ersten Sekunde an, und darauf wartet man eine halbe
// Minute vor einem schwarzen Bild.
//
// Drei Megabit bedeuten 720p. Das läuft in wenigen Sekunden an, sieht
// einen Augenblick lang weicher aus — und von dort geht es hinauf,
// sobald sich zeigt, dass es trägt. Ein Film, der sofort anfängt und
// nach einer Minute schärfer wird, ist besser als einer, auf den man
// erst wartet.
return 3_000
}
/// Die Auflösung, die zur aktuellen Stufe gehört.
@@ -946,8 +959,10 @@ final class PlayerModel {
noteMissingLadder(urteil)
}
// Und dann die Frage, ob der teure Weg nötig ist.
// Und dann die beiden teuren Wege: hinunter, wenn es hakt —
// und hinauf, wenn es trägt und die Leiter ausgereizt ist.
considerTheHardWay(urteil, statistics: statistics, at: now)
considerGoingHigher(urteil, statistics: statistics, at: now)
}
// MARK: - Die Sicherheitsleine
@@ -989,6 +1004,84 @@ final class PlayerModel {
}
}
/// Prüft, ob sich ein Neuaufbau **nach oben** lohnt — und tut es.
///
/// **Warum es den überhaupt braucht.** Damit ein Film zügig anläuft,
/// beginnt er mit einer niedrigen Stufe. Der Server baut seine Leiter
/// aber aus genau dieser Bestellung: wer mit drei Megabit anfängt,
/// bekommt eine Leiter, deren oberste Sprosse drei Megabit ist. Der
/// billige Hebel bewegt sich **innerhalb** dieser Leiter und käme nie
/// darüber hinaus — aus dem schnellen Start würde ein Film, der den
/// ganzen Abend in 720p bleibt.
///
/// Höher geht es nur mit einem Neuaufbau. Die Bedingungen dafür stehen
/// in `UpgradeRule`, nicht hier.
private func considerGoingHigher(
_ condition: StreamCondition,
statistics: PlaybackStatistics,
at now: Duration
) {
guard !hasGivenUp, !isChangingQuality, resumeTarget == nil else { return }
let decke = quality.policy(basedOn: policy).maximumBitrate
let aktuell = automaticCeiling ?? startingCeiling
let facts = UpgradeRule.Facts(
isAutomatic: quality == .automatic,
condition: condition,
// Noch eine Sprosse in der **gelieferten** Leiter? Dann ist der
// billige Weg der richtige.
hasHigherRung: statistics.variantBitrates.contains { $0 > (aktuell ?? .max) },
ceilingAllowsMore: aktuell.map { jetzt in decke.map { jetzt < $0 } ?? true } ?? false,
bufferedAhead: statistics.bufferedAhead,
playedFor: now,
sinceLastStall: stallLedger.lastStall(at: now).map { now - $0 },
sinceLastSeek: lastSeekAt.map { now - $0 },
sinceLastUpgrade: lastUpgradeAt.map { now - $0 },
used: upgradesUsed,
budget: 2
)
guard UpgradeRule.verdict(facts) == .rebuildHigher else { return }
Task { await upgradeTheHardWay(at: now) }
}
/// Baut den Stream mit **grösserer** Bitrate neu auf.
private func upgradeTheHardWay(at now: Duration) async {
guard let ziel = nextHigherRung() else { return }
automaticCeiling = ziel
upgradesUsed += 1
lastUpgradeAt = now
diagnostics.note("Neuaufbau mit bis zu \(ziel) kbit — die Leitung trägt mehr")
// Bei Live-TV nicht: dort kostet jeder Neuaufbau den Tuner, und ein
// schärferes Bild ist diesen Einsatz nicht wert.
guard !isLive else { return }
await restart()
}
/// Die nächste Sprosse **oberhalb** der aktuellen — aus unserer Leiter,
/// gedeckelt von dem, was eingestellt ist.
private func nextHigherRung() -> Int? {
let aktuell = automaticCeiling ?? startingCeiling ?? 0
let decke = quality.policy(basedOn: policy).maximumBitrate
let sprossen = PlayerQuality.available(forSourceHeight: sourceHeight)
.compactMap { option -> Int? in
guard case .limited(let rung) = option else { return nil }
return rung.bitrate
}
.sorted()
// Die nächste darüber, die die Einstellung noch zulässt. Gibt es
// keine mehr, ist die Decke erreicht — dann bleibt es dabei.
return sprossen.first { sprosse in
guard sprosse > aktuell else { return false }
guard let decke else { return true }
return sprosse <= decke
}
}
/// Baut den Stream mit kleinerer Bitrate neu auf.
private func downgradeTheHardWay(at now: Duration) async {
guard let ziel = nextLowerRung() else {