From 9e7f3393b42c903eb5d0156c500f3cec0bc6dae0 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 1 Oct 2026 07:46:39 +0200 Subject: [PATCH] DTS, TrueHD und Vorbis gehen jetzt an den Server statt an VLC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher entschied der Decider: steht nur die Schachtel im Weg, wird umgepackt — stimmt ein Codec nicht, geht es an VLC. Das war beim Ton zu grob gedacht. Beim Umpacken nennt das Geräteprofil dem Server, was ankommen darf: "VideoCodec": "h264,hevc" "AudioCodec": "aac,ac3,eac3,mp3" Trifft ein Strom eine dieser Angaben, kopiert der Server ihn; trifft er sie nicht, rechnet er **genau diesen einen** um. Eine Matroska mit HEVC und DTS braucht also kein VLC: das Bild geht Byte für Byte durch, und nur die Tonspur wird nach AC3 oder AAC kodiert. Das kostet Tonqualität und sonst nichts. Die Bedingung heisst deshalb nicht mehr „nur die Schachtel", sondern „das Bild bleibt": Container und Ton dürfen im Weg stehen, der Videocodec nicht. Dazu musste die Prüfung der Schachtel aufhören, sich immer zu stellen — eine MP4 mit DTS stand mit einem reinen Tonhindernis da und fiel trotzdem durch die Liste umpackbarer Container. **Der grössere Gewinn liegt auf dem Apple TV.** Dort gibt es VLC nicht, und bisher landete eine Datei mit DTS-Ton beim vollen Neurechnen — Bild inbegriffen, obwohl mit dem Bild nichts war. Jetzt bleibt das Bild unberührt. Auf iPhone, iPad und Mac verhält sich derselbe Film jetzt gleich, und zwar über AVPlayer: mit Bild-in-Bild, AirPlay und Sperrbildschirm, die es auf dem VLC-Weg nie gab. Geprüft im Simulator gegen die Attrappe mit `--vlc` (meldet DTS): der Film läuft, und in der Leiste steht jetzt der AirPlay-Knopf, der vorher fehlte — der sichtbarste Beleg dafür, dass AVPlayer spielt und nicht VLC. „Umgepackt" und „umgepackt, Ton umgerechnet" sind in den technischen Infos und in der Diagnose auseinandergehalten. Ein Weg, der den Ton neu kodiert, darf nicht „ohne Neukodierung" heissen. Was bleibt bei VLC: nicht unterstützte **Bild**codecs, allen voran AV1, und Dateien, die der Server noch nicht untersucht hat. Dafür gäbe es nur das volle Neurechnen — eine eigene Entscheidung, nicht diese. iOS und tvOS bauen, 463 Tests grün. Co-Authored-By: Claude Opus 5 --- Flixy/Resources/Localizable.xcstrings | 17 ++++ Flixy/Session/PlaybackDiagnostics.swift | 5 +- Flixy/Views/Detail/TechnicalDetails.swift | 7 +- .../Decision/PlaybackDecider.swift | 44 +++++++--- .../Decision/PlaybackDecision.swift | 21 +++-- .../PlaybackDeciderTests.swift | 86 ++++++++++++++++--- 6 files changed, 145 insertions(+), 35 deletions(-) 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",