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:
co-authored by
Claude Opus 5
parent
557708b769
commit
4853200d5c
@@ -0,0 +1,243 @@
|
||||
using System.Globalization;
|
||||
using Flaro.Core.Delivery;
|
||||
using Flaro.Core.Library;
|
||||
|
||||
namespace Flaro.Media;
|
||||
|
||||
/// <summary>Welche Bildbeschleunigung benutzt werden soll.</summary>
|
||||
public enum Beschleunigung
|
||||
{
|
||||
/// <summary>Alles im Hauptprozessor — geht überall, kostet am meisten.</summary>
|
||||
Software,
|
||||
|
||||
/// <summary>VAAPI über <c>/dev/dri</c> — Intel und AMD unter Linux.</summary>
|
||||
Vaapi,
|
||||
}
|
||||
|
||||
/// <param name="Quelle">Die Datei auf der Platte.</param>
|
||||
/// <param name="Zielordner">Wohin Playlist und Segmente geschrieben werden.</param>
|
||||
/// <param name="Tonspur">
|
||||
/// Welche Tonspur gespielt wird — die Nummer aus der Datei. <c>null</c> nimmt
|
||||
/// die erste.
|
||||
/// </param>
|
||||
/// <param name="Ab">
|
||||
/// Ab welcher Sekunde. <b>Vor <c>-i</c>, nicht danach</b> — nur dann springt
|
||||
/// ffmpeg im Vorspann statt die ganze Datei durchzulesen.
|
||||
/// </param>
|
||||
public sealed record Stromanfrage(
|
||||
string Quelle,
|
||||
string Zielordner,
|
||||
IReadOnlyList<ProbedStream> Spuren,
|
||||
Streamplan Plan,
|
||||
int? Tonspur = null,
|
||||
double Ab = 0,
|
||||
Beschleunigung Hardware = Beschleunigung.Software);
|
||||
|
||||
/// <summary>Baut die ffmpeg-Zeile für einen laufenden Strom.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Eine reine Funktion, wie bei <see cref="RemuxArguments"/>.</b> Aus
|
||||
/// demselben Grund: eine falsche Parameterkombination lässt sich sonst nur
|
||||
/// gegen einen echten Server entdecken, und dort kostet sie Tage.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Ein Rohr für beides.</b> Eine MKV nach fMP4 umzupacken und einen
|
||||
/// 4K-Film auf 480p herunterzurechnen ist derselbe Weg — einmal mit
|
||||
/// <c>-c:v copy</c>, einmal mit einem Kodierer. Zwei getrennte Wege hiesse,
|
||||
/// Sitzungsverwaltung, Segmentierung und Aufräumen zweimal zu haben.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Warum HLS und nicht eine fertige Datei.</b> Bisher wurde der ganze Film
|
||||
/// umgepackt, bevor das erste Bild lief — bei 4K sind das Minuten, in denen
|
||||
/// nichts geschieht. HLS liefert das erste Segment nach Sekunden.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class HlsArgumente
|
||||
{
|
||||
/// <summary>Wie lang ein Segment ist.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Sechs Sekunden, wie bei Apple.</b> Kürzer heisst mehr Dateien und
|
||||
/// mehr Anfragen; länger heisst, dass der Anfang später kommt und jeder
|
||||
/// Sprung gröber wird.
|
||||
/// </remarks>
|
||||
public const int SegmentSekunden = 6;
|
||||
|
||||
/// <summary>Wie viele Bilder zwischen zwei Schlüsselbildern liegen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Ein Segment muss mit einem Schlüsselbild anfangen.</b> Ohne feste
|
||||
/// Abstände schneidet ffmpeg dort, wo zufällig eines liegt — und die
|
||||
/// Segmente werden unterschiedlich lang, was jeden Sprung ungenau macht.
|
||||
/// </remarks>
|
||||
private const int BilderJeSekunde = 25;
|
||||
|
||||
public const string Playlist = "index.m3u8";
|
||||
private const string Anfangsstueck = "init.mp4";
|
||||
private const string Segmentmuster = "seg%05d.m4s";
|
||||
|
||||
public static List<string> Fuer(Stromanfrage anfrage)
|
||||
{
|
||||
var argumente = new List<string> { "-y", "-loglevel", "error" };
|
||||
|
||||
// **Die Beschleunigung steht vor der Eingabe.** Danach wäre sie eine
|
||||
// Angabe über die Ausgabe und bliebe wirkungslos — ffmpeg sagt dazu
|
||||
// nichts, es dekodiert einfach im Hauptprozessor weiter.
|
||||
if (anfrage.Hardware == Beschleunigung.Vaapi && !anfrage.Plan.BildKopieren)
|
||||
{
|
||||
argumente.AddRange([
|
||||
"-hwaccel", "vaapi",
|
||||
"-hwaccel_output_format", "vaapi",
|
||||
"-vaapi_device", "/dev/dri/renderD128",
|
||||
]);
|
||||
}
|
||||
|
||||
if (anfrage.Ab > 0)
|
||||
{
|
||||
argumente.Add("-ss");
|
||||
argumente.Add(anfrage.Ab.ToString("0.###", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
argumente.Add("-i");
|
||||
argumente.Add(anfrage.Quelle);
|
||||
|
||||
var bild = anfrage.Spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Video);
|
||||
var ton = Tonspur(anfrage.Spuren, anfrage.Tonspur);
|
||||
|
||||
// **Nur Bild und eine Tonspur.** Mehr trägt eine HLS-Ausgabe nicht,
|
||||
// ohne in getrennte Listen zu zerfallen; die Spurwahl geschieht
|
||||
// deshalb über eine neue Sitzung. Untertitel kommen als eigene
|
||||
// WebVTT-Datei daneben und brauchen den Strom gar nicht anzufassen.
|
||||
if (bild is not null) { argumente.Add("-map"); argumente.Add($"0:{bild.Index}"); }
|
||||
if (ton is not null) { argumente.Add("-map"); argumente.Add($"0:{ton.Index}"); }
|
||||
|
||||
argumente.AddRange(Bildargumente(anfrage, bild));
|
||||
argumente.AddRange(Tonargumente(anfrage, ton));
|
||||
|
||||
// **Keine Untertitel im Strom.** mov_text in einer fMP4-Segmentierung
|
||||
// bringt ffmpeg zum Abbruch, und bildbasierte Untertitel passen
|
||||
// ohnehin nicht hinein.
|
||||
argumente.Add("-sn");
|
||||
|
||||
argumente.AddRange([
|
||||
"-f", "hls",
|
||||
"-hls_time", SegmentSekunden.ToString(CultureInfo.InvariantCulture),
|
||||
|
||||
// **`event` und nicht `vod`.** Die Liste wächst, während ffmpeg
|
||||
// schreibt; `vod` behauptete, sie sei schon vollständig, und der
|
||||
// Spieler bräche am Ende des ersten Segments ab.
|
||||
"-hls_playlist_type", "event",
|
||||
|
||||
// fMP4 statt MPEG-TS: dieselben Segmente, die auch ein
|
||||
// Browser-MSE-Puffer ohne Umweg annimmt — und HEVC ginge in TS gar
|
||||
// nicht erst.
|
||||
"-hls_segment_type", "fmp4",
|
||||
"-hls_fmp4_init_filename", Anfangsstueck,
|
||||
"-hls_segment_filename", Path.Combine(anfrage.Zielordner, Segmentmuster),
|
||||
|
||||
// Jedes Segment für sich dekodierbar — sonst darf ein Spieler
|
||||
// nicht mittendrin einsteigen.
|
||||
"-hls_flags", "independent_segments",
|
||||
"-start_number", "0",
|
||||
]);
|
||||
|
||||
argumente.Add(Path.Combine(anfrage.Zielordner, Playlist));
|
||||
return argumente;
|
||||
}
|
||||
|
||||
/// <summary>Welche Tonspur gespielt wird.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Eine gewünschte, die es nicht gibt, ist keine Fehlermeldung wert.</b>
|
||||
/// Nach einem erneuten Scan kann sich die Nummerierung verschoben haben;
|
||||
/// dann ist die erste Spur die vernünftige Antwort, nicht ein stummer Film.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Öffentlich, damit die Antwort an den Spieler dieselbe Regel
|
||||
/// benutzt.</b> Vorher meldete der Server die <i>gewünschte</i> Spur
|
||||
/// zurück — also meist <c>null</c> —, und im Menü stand bei keiner ein
|
||||
/// Haken, obwohl eine davon lief.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static ProbedStream? Tonspur(IReadOnlyList<ProbedStream> spuren, int? gewuenscht)
|
||||
{
|
||||
var alle = spuren.Where(s => s.Kind == MediaStreamKind.Audio).ToList();
|
||||
if (alle.Count == 0) return null;
|
||||
|
||||
return gewuenscht is { } wunsch
|
||||
? alle.FirstOrDefault(s => s.Index == wunsch) ?? alle[0]
|
||||
: alle[0];
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Bildargumente(Stromanfrage anfrage, ProbedStream? bild)
|
||||
{
|
||||
if (anfrage.Plan.BildKopieren) return ["-c:v", "copy"];
|
||||
|
||||
var schluesselabstand = (SegmentSekunden * BilderJeSekunde).ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
if (anfrage.Hardware == Beschleunigung.Vaapi)
|
||||
{
|
||||
// **`format=nv12|vaapi,hwupload` vor dem Skalieren.** Ohne diese
|
||||
// Kette liegt das Bild je nach Quelle mal im Hauptspeicher und mal
|
||||
// in der Grafikkarte, und `scale_vaapi` nimmt nur das zweite.
|
||||
var kette = anfrage.Plan.Hoehe is { } h
|
||||
? $"format=nv12|vaapi,hwupload,scale_vaapi=w=-2:h={h}"
|
||||
: "format=nv12|vaapi,hwupload";
|
||||
|
||||
return [
|
||||
"-vf", kette,
|
||||
"-c:v", "h264_vaapi",
|
||||
"-qp", "23",
|
||||
"-g", schluesselabstand,
|
||||
"-bf", "0",
|
||||
];
|
||||
}
|
||||
|
||||
var weich = new List<string>();
|
||||
if (anfrage.Plan.Hoehe is { } hoehe)
|
||||
{
|
||||
// `-2` statt `-1`: die Breite muss gerade sein, sonst lehnt
|
||||
// libx264 sie ab. Das hat schon einmal eine Stunde gekostet.
|
||||
weich.AddRange(["-vf", $"scale=-2:{hoehe}"]);
|
||||
}
|
||||
|
||||
weich.AddRange([
|
||||
"-c:v", "libx264",
|
||||
|
||||
// **`veryfast`, nicht `medium`.** Was in Echtzeit laufen muss,
|
||||
// darf nicht langsamer rechnen als es abspielt — ein schöneres
|
||||
// Bild, das stockt, ist kein schöneres Bild.
|
||||
"-preset", "veryfast",
|
||||
"-crf", "21",
|
||||
"-profile:v", "high",
|
||||
"-level", "4.1",
|
||||
"-pix_fmt", "yuv420p",
|
||||
|
||||
// Feste Schlüsselbildabstände, damit jedes Segment gleich lang wird.
|
||||
"-g", schluesselabstand,
|
||||
"-keyint_min", schluesselabstand,
|
||||
"-sc_threshold", "0",
|
||||
]);
|
||||
|
||||
_ = bild;
|
||||
return weich;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Tonargumente(Stromanfrage anfrage, ProbedStream? ton)
|
||||
{
|
||||
if (ton is null) return ["-an"];
|
||||
|
||||
// **Immer AAC, immer Stereo.** Beim Umpacken wäre Durchreichen
|
||||
// billiger, aber was hier herauskommt, läuft im Browser: der kennt
|
||||
// weder EAC3 noch DTS, und eine 5.1-Spur in Stereo zu falten ist
|
||||
// Aufgabe des Servers, nicht des Lautsprechers.
|
||||
//
|
||||
// Genau diese Stelle war der Fehler: beim Umpacken wurde der Ton
|
||||
// kopiert, weil Apples Profil EAC3 erlaubt — und im Browser blieb der
|
||||
// Film stumm oder lief gar nicht.
|
||||
_ = anfrage;
|
||||
return ["-c:a", "aac", "-ac", "2", "-b:a", "192k", "-ar", "48000"];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user