Er packte um, was gar nicht am Umpacken lag

Der Browser bekam stillschweigend Apples Geräteprofil — und mit ihm HEVC, AC3
und EAC3, von denen er keines öffnet. Der Server packte die Datei
pflichtschuldig nach MP4 um, liess Bild und Ton dabei unberührt, und heraus
kam eine tadellose Datei, mit der Chrome nichts anfangen konnte. Umpacken
fasst das Bild nun einmal nicht an.

Jetzt sagt das Gerät selbst, was es kann, und es gibt einen zweiten Weg neben
der fertigen Datei: HLS, das nach Sekunden anfängt statt nach Minuten.
Dasselbe Rohr erledigt beides — mkv nach fMP4 mit „-c:v copy", 4K nach 480p
mit libx264 — und eine MP4, die der Browser ohnehin öffnet, geht ganz ohne
ffmpeg hinaus.

Dazu eine eigene Steuerleiste: Tonspur und Untertitel während der Wiedergabe
umschaltbar, vier Auflösungsstufen, Tastenkürzel, und bei einem Fehler ein
deutscher Satz statt eines schwarzen Bildes.

Gemessen statt vermutet: Chromium antwortet auf canPlayType für HLS mit
"maybe" und kann es dann doch nicht — deshalb entscheidet Hls.isSupported().
hls.js wiegt 372 KB und wird erst beim Abspielen geholt; die Oberfläche
bleibt bei 109.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-05 18:52:27 +02:00
co-authored by Claude Opus 5
parent 557708b769
commit 4853200d5c
16 changed files with 2894 additions and 65 deletions
@@ -0,0 +1,133 @@
using Flaro.Core.Library;
namespace Flaro.Core.Delivery;
/// <summary>Wie eine Datei in einen laufenden Spieler kommt.</summary>
///
/// <remarks>
/// <b>Nicht zu verwechseln mit <see cref="DeliveryMethod"/>.</b> Dort geht es
/// um eine fertige Datei zum Mitnehmen; hier um einen Strom, der sofort
/// anfängt. Beides hat dieselbe Frage — passt das Gerät zur Datei? — und ganz
/// verschiedene Antworten: eine Datei darf Minuten brauchen, ein Spieler nicht.
/// </remarks>
public enum Stromart
{
/// <summary>Die Datei geht unverändert hinaus, der Browser spielt sie direkt.</summary>
Direkt,
/// <summary>
/// Umgepackt, aber nicht neu gerechnet — mkv nach fMP4, Bild Byte für Byte.
/// </summary>
///
/// <remarks>
/// <b>Das ist der häufigste Fall und der billigste.</b> Eine MKV mit H.264
/// und AAC kann jeder Browser abspielen; nur die Schachtel kennt er nicht.
/// Sie zu wechseln kostet den Server fast nichts.
/// </remarks>
Umpacken,
/// <summary>Das Bild wird neu gerechnet — anderer Codec oder kleinere Auflösung.</summary>
Umkodieren,
}
/// <summary>Warum es nicht beim Durchreichen bleibt.</summary>
public enum Stromgrund
{
PasstSchon,
SchachtelUnbekannt,
BildcodecUnbekannt,
TonUnbekannt,
KleinereAufloesungGewuenscht,
}
/// <summary>Was für diesen Strom zu tun ist.</summary>
///
/// <param name="Hoehe">
/// Auf wie viele Bildzeilen herunterskaliert wird — <c>null</c> heisst:
/// Originalauflösung.
/// </param>
public sealed record Streamplan(
Stromart Art,
IReadOnlyList<Stromgrund> Gruende,
int? Hoehe)
{
public bool BildKopieren => Art != Stromart.Umkodieren;
}
/// <summary>Entscheidet, was ein Strom braucht, damit der Spieler ihn öffnet.</summary>
///
/// <remarks>
/// <para>
/// <b>Getrennt von <see cref="DeliveryDecider"/>, und das ist Absicht.</b> Der
/// entscheidet über eine Datei zum Herunterladen und kennt nur zwei Ausgänge:
/// durchreichen oder umpacken. Ein unbekannter Bildcodec führte dort ebenfalls
/// zum Umpacken — und das half nichts, weil Umpacken das Bild nicht anfasst.
/// Für den Browser war das der Fehler, an dem jede Wiedergabe mit HEVC
/// scheiterte: er bekam eine tadellose MP4, in der ein Codec steckte, den er
/// nicht kennt.
/// </para>
/// <para>
/// <b>Das Bild wird nur angefasst, wenn es sein muss.</b> Neu zu rechnen
/// kostet ein Vielfaches des Umpackens und verliert Bildqualität. Wer eine
/// kleinere Auflösung will, bekommt sie; wer nicht, bekommt das Original.
/// </para>
/// </remarks>
public static class Stromentscheid
{
/// <summary>Die Stufen, die ein Spieler anbieten darf.</summary>
///
/// <remarks>
/// <b>Keine freie Zahl.</b> Vier Stufen decken ab, wofür es Gründe gibt:
/// das Original, zwei Zwischenstufen und eine für die langsame Leitung.
/// Eine beliebige Höhe zuzulassen hiesse, für jede Zahl eine eigene
/// Sitzung zu rechnen.
/// </remarks>
public static readonly IReadOnlyList<int> Stufen = [1080, 720, 480];
/// <param name="hoeheGewuenscht">
/// Die vom Spieler gewählte Stufe, oder <c>null</c> für das Original.
/// </param>
public static Streamplan Fuer(
string? schachtel,
IReadOnlyList<ProbedStream> spuren,
DeviceProfile profil,
int? hoeheGewuenscht = null)
{
var gruende = new List<Stromgrund>();
var bild = spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Video);
var bildPasst = Passt(bild?.Codec, profil.VideoCodecs);
if (!bildPasst) gruende.Add(Stromgrund.BildcodecUnbekannt);
// **Eine Stufe über der Quelle ist keine Stufe.** Ein 720p-Film in
// „1080p" abzuspielen hiesse, ihn hochzurechnen: mehr Last, mehr
// Daten, kein besseres Bild.
var hoehe = hoeheGewuenscht is { } gewuenscht
&& (bild?.Height is not { } vorhanden || gewuenscht < vorhanden)
? gewuenscht
: (int?)null;
if (hoehe is not null) gruende.Add(Stromgrund.KleinereAufloesungGewuenscht);
if (!bildPasst || hoehe is not null)
return new Streamplan(Stromart.Umkodieren, gruende, hoehe);
var schachtelPasst = Passt(schachtel, profil.Containers);
if (!schachtelPasst) gruende.Add(Stromgrund.SchachtelUnbekannt);
// **Beim Ton genügt eine passende Spur.** Welche gespielt wird,
// entscheidet der Benutzer; dass eine siebenkanalige DTS-Spur daneben
// liegt, ist kein Grund, den ganzen Film anzufassen.
var tonspuren = spuren.Where(s => s.Kind == MediaStreamKind.Audio).ToList();
var tonPasst = tonspuren.Count == 0 || tonspuren.Any(s => Passt(s.Codec, profil.AudioCodecs));
if (!tonPasst) gruende.Add(Stromgrund.TonUnbekannt);
if (!schachtelPasst || !tonPasst)
return new Streamplan(Stromart.Umpacken, gruende, null);
return new Streamplan(Stromart.Direkt, [Stromgrund.PasstSchon], null);
}
/// <summary>Was fehlt, zählt als passend — eine Lücke ist kein Urteil.</summary>
private static bool Passt(string? wert, IReadOnlySet<string> erlaubt) =>
string.IsNullOrWhiteSpace(wert) || erlaubt.Contains(wert);
}