diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index f80dfa5..76adeb7 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -7281,6 +7281,23 @@ } } }, + "technical.remuxAudio": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Umgepackt, Ton umgerechnet" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remuxed, audio converted" + } + } + } + }, "technical.resolution": { "extractionState": "stale", "localizations": { diff --git a/Flixy/Session/PlaybackDiagnostics.swift b/Flixy/Session/PlaybackDiagnostics.swift index 702520a..d4371f2 100644 --- a/Flixy/Session/PlaybackDiagnostics.swift +++ b/Flixy/Session/PlaybackDiagnostics.swift @@ -70,7 +70,10 @@ final class PlaybackDiagnostics { case .avPlayer: "Direkt · AVPlayer" case .vlc: "Direkt · VLC" } - case .remux: "Umgepackt · MP4, ohne Neukodierung" + case .remux: + decision.rationale.contains { if case .audioCodecUnsupported = $0 { true } else { false } } + ? "Umgepackt · Bild unverändert, Ton umgerechnet" + : "Umgepackt · MP4, ohne Neukodierung" case .transcode(let limit): limit.map { "Transcode · max. \($0 / 1000) Mbit" } ?? "Transcode" } diff --git a/Flixy/Views/Detail/TechnicalDetails.swift b/Flixy/Views/Detail/TechnicalDetails.swift index 951e008..c4631c9 100644 --- a/Flixy/Views/Detail/TechnicalDetails.swift +++ b/Flixy/Views/Detail/TechnicalDetails.swift @@ -101,7 +101,12 @@ struct TechnicalDetails: View { case .avPlayer: String(localized: "technical.directPlayNative") case .vlc: String(localized: "technical.directPlayVLC") } - case .remux: String(localized: "technical.remux") + // **Zwei Fälle unter einem Namen wären eine Lüge.** Umgepackt bleibt + // das Bild unberührt — der Ton nur dann, wenn AVPlayer ihn kennt. + case .remux: + decision.rationale.contains { if case .audioCodecUnsupported = $0 { true } else { false } } + ? String(localized: "technical.remuxAudio") + : String(localized: "technical.remux") case .transcode: String(localized: "technical.transcode") } diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecider.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecider.swift index 9ffccf1..a74a144 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecider.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecider.swift @@ -71,23 +71,39 @@ public struct PlaybackDecider: Sendable { guard blockers.isEmpty else { rationale.append(contentsOf: blockers) - // **Steht nur die Schachtel im Weg, wird umgepackt.** + // **Solange das Bild durchgereicht werden kann, hilft der Server.** // - // Genau das ist der Blu-ray-Rip: Matroska aussen, H.264 oder HEVC - // innen, AC3 dazu. AVPlayer könnte jeden dieser Ströme — er kommt - // nur nicht an sie heran. Umpacken kostet den Server fast nichts - // und verliert nichts, und der Titel landet danach bei AVPlayer - // statt bei VLC: mit Bild-in-Bild, AirPlay und einer Datei, die - // sich weit vorausladen lässt. + // Zwei Fälle, und beide enden hier. Steht nur die Schachtel im + // Weg — Matroska aussen, H.264 oder HEVC innen, AC3 dazu —, wird + // umgepackt: dieselben Ströme, andere Verpackung, nichts geht + // verloren. Steht der **Ton** im Weg — DTS, TrueHD, Vorbis —, + // rechnet der Server genau diese eine Spur um und kopiert das + // Bild. Beides zusammen geht ebenso. // - // Stimmt dagegen auch nur ein Codec nicht, hilft Umpacken nicht — - // dann fehlt nicht der Zugang, sondern das Können. - let nurDieSchachtel = blockers.allSatisfy { - if case .containerUnsupported = $0 { return true } - return false + // Das kostet Tonqualität, und zwar nur beim Ton. Dagegen steht, + // was es einbringt: der Titel landet bei AVPlayer statt bei VLC, + // also mit Bild-in-Bild, AirPlay und Sperrbildschirm — und er + // läuft auf dem Apple TV, wo es VLC nicht gibt, ohne dass das + // ganze Bild neu gerechnet werden muss. + // + // Stimmt dagegen der **Videocodec** nicht, ist hier Schluss: dann + // fehlt nicht der Zugang, sondern das Können, und jeder Weg über + // den Server hiesse, jedes Bild neu zu rechnen. + let bildBleibt = blockers.allSatisfy { + switch $0 { + case .containerUnsupported, .audioCodecUnsupported: true + default: false + } } - if nurDieSchachtel, policy.allowsTranscoding, - let container, DeviceCapabilities.remuxableContainers.contains(container) { + // Umpackbar muss die Schachtel nur sein, wenn sie überhaupt im + // Weg steht. Eine MP4 mit DTS-Ton bleibt die MP4, die sie ist — + // sie an dieser Liste zu messen, hiesse sie an einer Frage + // scheitern zu lassen, die sich gar nicht stellt. + let schachtelTraegt = container.map { + capabilities.containers.contains($0) + || DeviceCapabilities.remuxableContainers.contains($0) + } ?? true + if bildBleibt, schachtelTraegt, policy.allowsTranscoding { return PlaybackDecision(method: .remux, rationale: rationale) } diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift index cf55f7f..4268dbd 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift @@ -14,7 +14,8 @@ public struct PlaybackDecision: Sendable, Equatable { public enum Method: Sendable, Equatable { /// Die Datei wird unverändert übertragen und lokal dekodiert. case directPlay(engine: PlaybackEngineKind) - /// Der Server legt dieselben Ströme in eine andere Schachtel. + /// Der Server reicht das Bild durch und legt es in eine andere + /// Schachtel — den Ton rechnet er um, falls nötig. /// /// **Der dritte Weg, und der wichtigste für diesen Bestand.** Ein /// Blu-ray-Rip liegt als Matroska vor, und AVFoundation öffnet @@ -22,14 +23,20 @@ public struct PlaybackDecision: Sendable, Equatable { /// Ton AAC oder AC3, also alles, was AVPlayer bestens kann. Im Weg /// steht allein die Verpackung. /// - /// Umpacken heisst: dieselben Ströme, Byte für Byte, in eine MP4. Kein - /// Bild wird angefasst, es geht **nichts** an Qualität verloren, und - /// den Server kostet es fast nichts — anders als beim Neurechnen. + /// **Das Bild wird dabei nie angefasst.** Es geht Byte für Byte in + /// eine MP4, und das kostet den Server fast nichts — anders als beim + /// Neurechnen. + /// + /// **Der Ton kann es müssen.** Steht DTS, TrueHD oder Vorbis darin, + /// kennt AVPlayer die Spur nicht; der Server kodiert dann genau diese + /// eine Spur nach AC3 oder AAC. Das kostet Tonqualität — und sonst + /// nichts, vor allem keine einzige Bildzeile. Ob es im Einzelfall + /// passiert ist, steht in `rationale`: `audioCodecUnsupported`. /// /// Der Gewinn ist gross: solche Titel landen nicht mehr bei VLC, - /// sondern bei AVPlayer. Damit gibt es dort Bild-in-Bild, AirPlay, - /// die Systembedienung — und eine fortlaufende Datei, die sich - /// beliebig weit vorausladen lässt. + /// sondern bei AVPlayer. Damit gibt es dort Bild-in-Bild, AirPlay und + /// die Systembedienung — und auf einem Apple TV, wo es VLC gar nicht + /// gibt, muss nicht mehr das ganze Bild neu gerechnet werden. case remux /// Der Server rechnet um. Ab Phase 2; `maximumBitrate` in Kilobit. case transcode(maximumBitrate: Int?) diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift index 9041d7c..a0955a4 100644 --- a/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift @@ -66,8 +66,11 @@ struct PlaybackDeciderTests { #expect(decision.rationale.contains(.containerUnsupported("mkv"))) } + /// **Nur ohne Serverhilfe.** Darf umgepackt werden, geht dieselbe Datei + /// an AVPlayer — siehe `RemuxDecisionTests`. Hier ist Umrechnen verboten, + /// und dann bleibt VLC der einzige Weg. @Test( - "Nicht unterstützter Ton geht an VLC", + "Nicht unterstützter Ton geht ohne Transcoder an VLC", arguments: ["dts", "dca", "truehd", "vorbis", "opus"] ) func unsupportedAudioFallsBackToVLC(codec: String) { @@ -162,14 +165,30 @@ struct PlaybackDeciderTests { #expect(decision.method == .directPlay(engine: .avPlayer)) } - @Test("Ohne VLC übernimmt der Transcoder") - func withoutFallbackEngineTranscodingTakesOver() { + /// Auf dem Apple TV gibt es VLC nicht. Früher landete eine Datei mit + /// DTS-Ton deshalb beim vollen Neurechnen — Bild inbegriffen, obwohl mit + /// dem Bild nichts war. Jetzt bleibt es beim Umpacken, und nur der Ton + /// wird angefasst. + @Test("Ohne VLC reicht Umpacken, solange das Bild passt") + func withoutFallbackEngineRemuxIsEnough() { let policy = PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: false) let decision = PlaybackDecider().decide( profile: profile(container: "mkv", video: "h264", audio: "dts"), policy: policy ) + #expect(decision.method == .remux) + } + + /// Erst wenn auch das Bild nicht geht, muss wirklich gerechnet werden. + @Test("Ohne VLC übernimmt der Transcoder, sobald das Bild nicht passt") + func withoutFallbackEngineTranscodingTakesOver() { + let policy = PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: false) + + let decision = PlaybackDecider().decide( + profile: profile(container: "mkv", video: "av1", audio: "aac"), + policy: policy + ) #expect(decision.method == .transcode(maximumBitrate: nil)) } @@ -180,9 +199,10 @@ struct PlaybackDeciderTests { let policy = PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: false) let decision = PlaybackDecider().decide( - profile: profile(container: "mkv", video: "h264", audio: "dts"), + profile: profile(container: "mkv", video: "av1", audio: "aac"), policy: policy ) + #expect(decision.method == .transcode(maximumBitrate: nil)) #expect(decision.engine == .avPlayer) } @@ -241,22 +261,64 @@ struct RemuxDecisionTests { #expect(entscheidung.engine == .avPlayer) } - /// Der Gegenbeweis. Stimmt der Ton nicht, fehlt nicht der Zugang zur - /// Datei, sondern das Können — dann hilft eine andere Schachtel nichts. - @Test("Bei DTS hilft Umpacken nicht") - func dtsStillGoesToVLC() { + /// Steht der Ton im Weg, bleibt das Bild trotzdem unberührt: der Server + /// kopiert es und rechnet genau die eine Tonspur um. Das ist der Grund, + /// warum DTS auf dem Apple TV nicht mehr das ganze Bild kostet. + @Test( + "Nicht unterstützter Ton wird umgepackt, nicht an VLC gegeben", + arguments: ["dts", "dca", "truehd", "vorbis", "opus"] + ) + func unsupportedAudioIsRemuxed(codec: String) { let entscheidung = decider.decide( profile: MediaProfile( - container: "mkv", videoCodec: "hevc", audioCodec: "dts", + container: "mkv", videoCodec: "hevc", audioCodec: codec, bitrate: 20_000, hasKnownStreams: true ), policy: policy ) - #expect(entscheidung.method == .directPlay(engine: .vlc)) + #expect(entscheidung.method == .remux) + #expect(entscheidung.engine == .avPlayer) + // Die Begründung ist das, woran die Oberfläche später erkennt, dass + // der Ton angefasst wurde — ohne sie stünde dort „ohne Neukodierung". + #expect(entscheidung.rationale.contains(.audioCodecUnsupported(codec))) } - @Test("Bei AV1 ebenso wenig") - func av1StillGoesToVLC() { + /// Eine MP4 mit DTS steht mit einem reinen Tonhindernis da. Sie an der + /// Liste umpackbarer Schachteln zu messen, hiesse sie an einer Frage + /// scheitern zu lassen, die sich gar nicht stellt. + @Test("Auch eine MP4 mit DTS wird umgepackt") + func mp4WithDTSIsRemuxed() { + let entscheidung = decider.decide( + profile: MediaProfile( + container: "mp4", videoCodec: "h264", audioCodec: "dts", + bitrate: 20_000, hasKnownStreams: true + ), + policy: policy + ) + #expect(entscheidung.method == .remux) + } + + /// Schachtel und Ton zugleich — der häufigste Fall bei einem Remux mit + /// Tonspur aus der Blu-ray. + @Test("Schachtel und Ton zusammen gehen auch") + func containerAndAudioTogether() { + let entscheidung = decider.decide( + profile: MediaProfile( + container: "mkv", videoCodec: "h264", audioCodec: "truehd", + bitrate: 20_000, hasKnownStreams: true + ), + policy: policy + ) + #expect(entscheidung.method == .remux) + #expect(entscheidung.rationale.contains(.containerUnsupported("mkv"))) + #expect(entscheidung.rationale.contains(.audioCodecUnsupported("truehd"))) + } + + /// Der Gegenbeweis, und die Grenze: stimmt das **Bild** nicht, hilft kein + /// Durchreichen — dann müsste jede Bildzeile neu gerechnet werden, und + /// dafür ist dies nicht der Weg. + @Test("Bei falschem Bildcodec bleibt es beim Rückfall") + func wrongVideoCodecIsNotRemuxed() { let entscheidung = decider.decide( profile: MediaProfile( container: "mkv", videoCodec: "av1", audioCodec: "aac",