Matroska geht jetzt an AVPlayer, nicht mehr an VLC
Ein Blu-ray-Rip liegt als Matroska vor, und AVFoundation oeffnet Matroska nicht — obwohl das Bild darin H.264 oder HEVC ist und der Ton AC3, also alles, was AVPlayer bestens kann. Im Weg stand allein die Verpackung. Dafuer gibt es jetzt einen dritten Weg neben Durchreichen und Neurechnen: umpacken. Dieselben Stroeme, Byte fuer Byte, in eine MP4. Kein Bild wird angefasst, es geht nichts an Qualitaet verloren, und den Server kostet es fast nichts. Entscheidend ist dabei `Protocol: http` statt `hls`. Eine Abspielliste laesst sich nicht vorausladen — AVPlayer holt Segment fuer Segment, und Apple laesst uns dazwischen nicht eingreifen. Eine fortlaufende Datei kommt ueber gewoehnliche Bereichsanfragen, und die kann man so weit vorausholen, wie man will. Der Vorratswunsch steht dort deshalb bei zehn Minuten statt bei einer. Der Gewinn ist gross: solche Titel bekommen Bild-in-Bild, AirPlay und die Systembedienung, die es bei VLC nicht gibt. Umgepackt wird nur, wenn **allein** die Schachtel im Weg steht. Stimmt auch nur ein Codec nicht — DTS, TrueHD, AV1 —, fehlt nicht der Zugang zur Datei, sondern das Koennen; dann bleibt es bei VLC. Sechs Tests halten die Grenze fest, in beide Richtungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
806eb381c7
commit
62691fc2bc
@@ -6602,6 +6602,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"technical.remux": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Umgepackt"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Remuxed"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"technical.resolution": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
|
||||
@@ -70,6 +70,7 @@ final class PlaybackDiagnostics {
|
||||
case .avPlayer: "Direkt · AVPlayer"
|
||||
case .vlc: "Direkt · VLC"
|
||||
}
|
||||
case .remux: "Umgepackt · MP4, ohne Neukodierung"
|
||||
case .transcode(let limit):
|
||||
limit.map { "Transcode · max. \($0 / 1000) Mbit" } ?? "Transcode"
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ struct TechnicalDetails: View {
|
||||
case .avPlayer: String(localized: "technical.directPlayNative")
|
||||
case .vlc: String(localized: "technical.directPlayVLC")
|
||||
}
|
||||
case .remux: String(localized: "technical.remux")
|
||||
case .transcode:
|
||||
String(localized: "technical.transcode")
|
||||
}
|
||||
|
||||
@@ -240,8 +240,12 @@ final class PlayerModel {
|
||||
/// Durchreichen ist das verschmerzbar: die Datei wird ohnehin als Ganzes
|
||||
/// geholt, wenn man sie zu Ende sieht.
|
||||
private static func forwardBuffer(for method: PlaybackDecision.Method) -> Duration {
|
||||
if case .directPlay = method { return .seconds(600) }
|
||||
return .seconds(60)
|
||||
switch method {
|
||||
// Beides eine fortlaufende Datei über Bereichsanfragen — da lässt sich
|
||||
// vorausladen, so weit man will.
|
||||
case .directPlay, .remux: .seconds(600)
|
||||
case .transcode: .seconds(60)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie oft der Zustand des Streams geprüft wird.
|
||||
@@ -678,6 +682,11 @@ final class PlayerModel {
|
||||
|
||||
let transcode: TranscodeSettings? = switch decision.method {
|
||||
case .directPlay: nil
|
||||
// Umpacken heisst: keine Grenze, keine Auflösung, keine Leiter. Die
|
||||
// Ströme sollen unverändert durchgehen — jede Angabe hier wäre eine
|
||||
// Aufforderung, doch zu rechnen.
|
||||
case .remux:
|
||||
TranscodeSettings(maximumBitrate: nil, resolution: nil, prefersRemux: true)
|
||||
case .transcode(let maximumBitrate):
|
||||
TranscodeSettings(
|
||||
maximumBitrate: maximumBitrate,
|
||||
|
||||
@@ -32,4 +32,11 @@ public struct DeviceCapabilities: Sendable, Equatable {
|
||||
// tadelloser MP4-Film trotzdem stumm bliebe.
|
||||
audioCodecs: ["aac", "mp3", "ac3", "eac3", "alac", "pcm", "lpcm"]
|
||||
)
|
||||
|
||||
/// Schachteln, die sich verlustfrei in eine MP4 umpacken lassen.
|
||||
///
|
||||
/// Nicht jede: ein Container kann Dinge enthalten, für die MP4 keinen
|
||||
/// Platz hat. Diese drei sind die, die in echten Beständen vorkommen und
|
||||
/// deren Ströme regelmässig MP4-tauglich sind.
|
||||
public static let remuxableContainers: Set<String> = ["mkv", "matroska", "ts", "m2ts", "avi"]
|
||||
}
|
||||
|
||||
@@ -70,6 +70,27 @@ public struct PlaybackDecider: Sendable {
|
||||
|
||||
guard blockers.isEmpty else {
|
||||
rationale.append(contentsOf: blockers)
|
||||
|
||||
// **Steht nur die Schachtel im Weg, wird umgepackt.**
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
if nurDieSchachtel, policy.allowsTranscoding,
|
||||
let container, DeviceCapabilities.remuxableContainers.contains(container) {
|
||||
return PlaybackDecision(method: .remux, rationale: rationale)
|
||||
}
|
||||
|
||||
return fallback(policy: policy, rationale: rationale)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,23 @@ 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 dritte Weg, und der wichtigste für diesen Bestand.** Ein
|
||||
/// Blu-ray-Rip liegt als Matroska vor, und AVFoundation öffnet
|
||||
/// Matroska nicht — obwohl das Bild darin H.264 oder HEVC ist und der
|
||||
/// 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.
|
||||
///
|
||||
/// 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.
|
||||
case remux
|
||||
/// Der Server rechnet um. Ab Phase 2; `maximumBitrate` in Kilobit.
|
||||
case transcode(maximumBitrate: Int?)
|
||||
}
|
||||
@@ -52,6 +69,8 @@ public struct PlaybackDecision: Sendable, Equatable {
|
||||
// automatischem Bitratenwechsel": den gibt es nur, wenn die Playlist
|
||||
// mehrere Stufen führt, und das muss ausdrücklich bestellt werden.
|
||||
case .transcode: .avPlayer
|
||||
// Umgepackt kommt eine MP4 heraus — genau das, wofür es AVPlayer gibt.
|
||||
case .remux: .avPlayer
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ extension JellyfinBackend {
|
||||
sourceID: sourceID,
|
||||
transcode: transcode,
|
||||
audioStreamIndex: intent.audioStreamIndex,
|
||||
subtitleStreamIndex: intent.subtitleStreamIndex
|
||||
subtitleStreamIndex: intent.subtitleStreamIndex,
|
||||
remux: transcode.prefersRemux
|
||||
)
|
||||
|
||||
let source = info.mediaSources?.first { $0.id == sourceID } ?? info.mediaSources?.first
|
||||
@@ -59,7 +60,12 @@ extension JellyfinBackend {
|
||||
}
|
||||
|
||||
var adresse = Self.limiting(height: transcode.resolution, in: url)
|
||||
if transcode.prefersAdaptive { adresse = Self.adaptive(adresse) }
|
||||
// Beim Umpacken **keine** Leiter: es gibt nur eine Fassung, nämlich
|
||||
// die Datei selbst. Und `master.m3u8` wäre ohnehin das Gegenteil
|
||||
// dessen, worum es geht — eine Abspielliste statt einer Datei.
|
||||
if transcode.prefersAdaptive, !transcode.prefersRemux {
|
||||
adresse = Self.adaptive(adresse)
|
||||
}
|
||||
|
||||
return PlaybackTarget(
|
||||
url: adresse,
|
||||
@@ -123,18 +129,40 @@ extension JellyfinBackend {
|
||||
sourceID: String,
|
||||
transcode: TranscodeSettings,
|
||||
audioStreamIndex: Int? = nil,
|
||||
subtitleStreamIndex: Int? = nil
|
||||
subtitleStreamIndex: Int? = nil,
|
||||
remux: Bool = false
|
||||
) async throws(MediaBackendError) -> JellyfinPlaybackInfo {
|
||||
// Jellyfin rechnet in Bit, die App in Kilobit.
|
||||
let bitsPerSecond = (transcode.maximumBitrate ?? 8_000) * 1_000
|
||||
|
||||
// **Umpacken: eine fortlaufende MP4, und die Ströme unverändert.**
|
||||
//
|
||||
// `Protocol: http` statt `hls` ist der entscheidende Unterschied. Eine
|
||||
// Abspielliste lässt sich nicht vorausladen — AVPlayer holt Segment
|
||||
// für Segment und Apple lässt uns dazwischen nicht eingreifen. Eine
|
||||
// fortlaufende Datei kommt dagegen über gewöhnliche Bereichsanfragen,
|
||||
// und die kann man so weit vorausholen, wie man will.
|
||||
//
|
||||
// Die Codecs stehen genau so breit wie das, was AVPlayer wirklich
|
||||
// kann: trifft der Server eine Übereinstimmung, kopiert er den Strom,
|
||||
// statt ihn neu zu rechnen. Genau dafür ist der Weg da.
|
||||
let umpacken: [String: Any] = [
|
||||
"Type": "Video",
|
||||
"Container": "mp4",
|
||||
"Protocol": "http",
|
||||
"VideoCodec": "h264,hevc",
|
||||
"AudioCodec": "aac,ac3,eac3,mp3",
|
||||
"Context": "Streaming",
|
||||
"MaxAudioChannels": "8",
|
||||
]
|
||||
|
||||
let profile: [String: Any] = [
|
||||
"MaxStreamingBitrate": bitsPerSecond,
|
||||
// Leer heißt: nichts geht unverändert. An dieser Stelle ist bereits
|
||||
// entschieden, dass umgerechnet werden soll — dem Server die Wahl
|
||||
// zu lassen hieße, die Entscheidung zweimal zu treffen.
|
||||
"DirectPlayProfiles": [],
|
||||
"TranscodingProfiles": [[
|
||||
"TranscodingProfiles": remux ? [umpacken] : [[
|
||||
"Type": "Video",
|
||||
"Container": "ts",
|
||||
"Protocol": "hls",
|
||||
|
||||
@@ -76,11 +76,25 @@ public struct TranscodeSettings: Sendable, Equatable {
|
||||
/// antippt, soll das bekommen und nicht „höchstens 3, meistens weniger"
|
||||
/// unter unverändertem Etikett.
|
||||
public let prefersAdaptive: Bool
|
||||
/// Nur umpacken, nicht neu rechnen.
|
||||
///
|
||||
/// Dann werden die Ströme kopiert statt kodiert, und heraus kommt eine
|
||||
/// **fortlaufende** MP4 statt einer HLS-Abspielliste. Beides ist wichtig:
|
||||
/// kopieren verliert nichts und kostet den Server fast nichts, und eine
|
||||
/// fortlaufende Datei lässt sich beliebig weit vorausladen — eine
|
||||
/// Abspielliste nicht.
|
||||
public let prefersRemux: Bool
|
||||
|
||||
public init(maximumBitrate: Int?, resolution: String?, prefersAdaptive: Bool = false) {
|
||||
public init(
|
||||
maximumBitrate: Int?,
|
||||
resolution: String?,
|
||||
prefersAdaptive: Bool = false,
|
||||
prefersRemux: Bool = false
|
||||
) {
|
||||
self.maximumBitrate = maximumBitrate
|
||||
self.resolution = resolution
|
||||
self.prefersAdaptive = prefersAdaptive
|
||||
self.prefersRemux = prefersRemux
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -215,3 +215,101 @@ struct PlaybackDeciderTests {
|
||||
#expect(decision.method == .directPlay(engine: .avPlayer))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Umpacken statt neu rechnen")
|
||||
struct RemuxDecisionTests {
|
||||
private let decider = PlaybackDecider()
|
||||
|
||||
private var policy: PlaybackPolicy {
|
||||
PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: true)
|
||||
}
|
||||
|
||||
/// Der Blu-ray-Rip: Matroska aussen, lauter Dinge innen, die AVPlayer
|
||||
/// bestens kann. Im Weg steht allein die Verpackung — und dafür gibt es
|
||||
/// das Umpacken, nicht VLC und schon gar nicht den Transcoder.
|
||||
@Test("Matroska mit brauchbarem Inhalt wird umgepackt")
|
||||
func matroskaIsRemuxed() {
|
||||
let entscheidung = decider.decide(
|
||||
profile: MediaProfile(
|
||||
container: "mkv", videoCodec: "hevc", audioCodec: "ac3",
|
||||
bitrate: 20_000, hasKnownStreams: true
|
||||
),
|
||||
policy: policy
|
||||
)
|
||||
#expect(entscheidung.method == .remux)
|
||||
// Und damit AVPlayer: Bild-in-Bild, AirPlay, weites Vorausladen.
|
||||
#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() {
|
||||
let entscheidung = decider.decide(
|
||||
profile: MediaProfile(
|
||||
container: "mkv", videoCodec: "hevc", audioCodec: "dts",
|
||||
bitrate: 20_000, hasKnownStreams: true
|
||||
),
|
||||
policy: policy
|
||||
)
|
||||
#expect(entscheidung.method == .directPlay(engine: .vlc))
|
||||
}
|
||||
|
||||
@Test("Bei AV1 ebenso wenig")
|
||||
func av1StillGoesToVLC() {
|
||||
let entscheidung = decider.decide(
|
||||
profile: MediaProfile(
|
||||
container: "mkv", videoCodec: "av1", audioCodec: "aac",
|
||||
bitrate: 20_000, hasKnownStreams: true
|
||||
),
|
||||
policy: policy
|
||||
)
|
||||
#expect(entscheidung.method == .directPlay(engine: .vlc))
|
||||
}
|
||||
|
||||
/// Eine MP4, die AVPlayer ohnehin öffnet, wird nicht angefasst. Umpacken
|
||||
/// wäre dort Arbeit ohne Wirkung.
|
||||
@Test("Was schon passt, bleibt unverändert")
|
||||
func mp4IsUntouched() {
|
||||
let entscheidung = decider.decide(
|
||||
profile: MediaProfile(
|
||||
container: "mp4", videoCodec: "h264", audioCodec: "aac",
|
||||
bitrate: 8_000, hasKnownStreams: true
|
||||
),
|
||||
policy: policy
|
||||
)
|
||||
#expect(entscheidung.method == .directPlay(engine: .avPlayer))
|
||||
}
|
||||
|
||||
/// Reicht die Leitung nicht, nützt Umpacken nichts: die Datei bliebe
|
||||
/// gleich gross. Dann muss wirklich gerechnet werden.
|
||||
@Test("Zu grosse Bitrate geht weiterhin an den Transcoder")
|
||||
func bitrateStillTranscodes() {
|
||||
let entscheidung = decider.decide(
|
||||
profile: MediaProfile(
|
||||
container: "mkv", videoCodec: "hevc", audioCodec: "ac3",
|
||||
bitrate: 80_000, hasKnownStreams: true
|
||||
),
|
||||
policy: PlaybackPolicy(
|
||||
allowsTranscoding: true, allowsFallbackEngine: true, maximumBitrate: 8_000
|
||||
)
|
||||
)
|
||||
#expect(entscheidung.method == .transcode(maximumBitrate: 8_000))
|
||||
}
|
||||
|
||||
/// Ohne Transcoder gibt es auch kein Umpacken — beides läuft über
|
||||
/// denselben Weg im Server.
|
||||
@Test("Ohne Serverhilfe bleibt es bei VLC")
|
||||
func withoutServerHelpVLC() {
|
||||
let entscheidung = decider.decide(
|
||||
profile: MediaProfile(
|
||||
container: "mkv", videoCodec: "hevc", audioCodec: "ac3",
|
||||
bitrate: 20_000, hasKnownStreams: true
|
||||
),
|
||||
policy: PlaybackPolicy(
|
||||
allowsTranscoding: false, allowsFallbackEngine: true
|
||||
)
|
||||
)
|
||||
#expect(entscheidung.method == .directPlay(engine: .vlc))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user