using System.Globalization; using Flaro.Core.Delivery; using Flaro.Core.Library; namespace Flaro.Media; /// Welche Bildbeschleunigung benutzt werden soll. public enum Beschleunigung { /// Alles im Hauptprozessor — geht überall, kostet am meisten. Software, /// VAAPI über /dev/dri — Intel und AMD unter Linux. Vaapi, } /// Die Datei auf der Platte. /// Wohin Playlist und Segmente geschrieben werden. /// /// Welche Tonspur gespielt wird — die Nummer aus der Datei. null nimmt /// die erste. /// /// /// Ab welcher Sekunde. Vor -i, nicht danach — nur dann springt /// ffmpeg im Vorspann statt die ganze Datei durchzulesen. /// public sealed record Stromanfrage( string Quelle, string Zielordner, IReadOnlyList Spuren, Streamplan Plan, int? Tonspur = null, double Ab = 0, Beschleunigung Hardware = Beschleunigung.Software, Kodierstufe Stufe = Kodierstufe.Ausgewogen, /// /// Um wie viele Sekunden der Ton gegenüber dem Bild verschoben wird. /// Positiv heisst später, negativ früher. 0 heisst: nichts tun. /// double Tonversatz = 0); /// Baut die ffmpeg-Zeile für einen laufenden Strom. /// /// /// /// Eine reine Funktion, wie bei . Aus /// demselben Grund: eine falsche Parameterkombination lässt sich sonst nur /// gegen einen echten Server entdecken, und dort kostet sie Tage. /// /// /// Ein Rohr für beides. Eine MKV nach fMP4 umzupacken und einen /// 4K-Film auf 480p herunterzurechnen ist derselbe Weg — einmal mit /// -c:v copy, einmal mit einem Kodierer. Zwei getrennte Wege hiesse, /// Sitzungsverwaltung, Segmentierung und Aufräumen zweimal zu haben. /// /// /// Warum HLS und nicht eine fertige Datei. 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. /// /// public static class HlsArgumente { /// Wie lang ein Segment ist. /// /// /// Sechs Sekunden, wie bei Apple. Kürzer heisst mehr Dateien und /// mehr Anfragen; länger heisst, dass der Anfang später kommt und jeder /// Sprung gröber wird. /// public const int SegmentSekunden = 6; /// Wie viele Bilder zwischen zwei Keyframesn liegen. /// /// /// Ein Segment muss mit einem Keyframe anfangen. Ohne feste /// Abstände schneidet ffmpeg dort, wo zufällig eines liegt — und die /// Segmente werden unterschiedlich lang, was jeden Sprung ungenau macht. /// 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 Fuer(Stromanfrage anfrage) { // **`warning`, nicht `error`.** Genau die Meldungen, die einen // Tonversatz ankündigen — „Non-monotonous DTS", „first pts value … // forcing" —, sind Warnungen. Auf `error` waren sie unsichtbar, und // ein Lauf, der schief aussieht, aber mit 0 endet, hinterliess keine // Spur. var argumente = new List { "-y", "-loglevel", "warning" }; // **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) { // **Beim Kopieren wird ungenau gesprungen, und das mit Absicht.** // // Genau gesprungen schneidet ffmpeg den Ton auf die Sekunde, das // kopierte Bild aber nicht — es kann erst beim Keyframe davor // anfangen. Dazwischen liegt Bild ohne Ton, und jeder Spieler darf // diese Lücke deuten, wie er will; hls.js schiebt den Ton nach // vorn. Gemessen an einer Datei mit Keyframesn alle fünf // Sekunden: bei einem Sprung auf 17 begann der Ton erst bei 1,96, // bei 15,014 sogar erst bei 4,98. // // `-noaccurate_seek` lässt auch den Ton beim Keyframe // anfangen. Dieselben Sprünge, gemessen: 0,004 und 0,015 — und // zwar bei *jedem* Wert, nicht nur bei gut getroffenen. Das ist // der Unterschied zum Runden auf das Keyframe: das muss auf // die Millisekunde stimmen, sonst wird es schlimmer als vorher. // // Beim Umkodieren nicht: dort wird das Bild neu gerechnet und // schneidet ohnehin genau. if (anfrage.Plan.BildKopieren) argumente.Add("-noaccurate_seek"); argumente.Add("-ss"); argumente.Add(Sekunden(anfrage.Ab)); } argumente.Add("-i"); argumente.Add(anfrage.Quelle); // **Der Tonversatz braucht eine zweite Eingabe.** `-itsoffset` gilt // für alles, was hinter ihm steht; mit nur einer Eingabe verschöbe es // Bild und Ton gemeinsam und wäre damit wirkungslos. Dieselbe Datei // ein zweites Mal zu öffnen kostet einen Demuxer und sonst nichts — // gelesen wird ohnehin nur eine Spur daraus. var zweiteEingabe = Math.Abs(anfrage.Tonversatz) > 0.0005; if (zweiteEingabe) { argumente.Add("-itsoffset"); argumente.Add(Sekunden(anfrage.Tonversatz)); if (anfrage.Ab > 0) { if (anfrage.Plan.BildKopieren) argumente.Add("-noaccurate_seek"); argumente.Add("-ss"); argumente.Add(Sekunden(anfrage.Ab)); } 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($"{(zweiteEingabe ? 1 : 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; } /// Welche Tonspur gespielt wird. /// /// /// /// Eine gewünschte, die es nicht gibt, ist keine Fehlermeldung wert. /// Nach einem erneuten Scan kann sich die Nummerierung verschoben haben; /// dann ist die erste Spur die vernünftige Antwort, nicht ein stummer Film. /// /// /// Öffentlich, damit die Antwort an den Spieler dieselbe Regel /// benutzt. Vorher meldete der Server die gewünschte Spur /// zurück — also meist null —, und im Menü stand bei keiner ein /// Haken, obwohl eine davon lief. /// /// /// Eine Sekundenangabe für ffmpeg — mit Punkt und voller Genauigkeit. /// /// /// Sechs Nachkommastellen, nicht drei. Auf drei gerundet kann ein /// Sprungpunkt um eine Millisekunde zu klein werden, und eine Millisekunde /// entscheidet, ob ffmpeg das gemeinte Keyframe erwischt oder das /// davor. Gemessen: bei 15,015 begann der Ton nach 0,004 s, bei 15,014 /// nach 4,98 s. /// private static string Sekunden(double wert) => wert.ToString("0.######", CultureInfo.InvariantCulture); public static ProbedStream? Tonspur(IReadOnlyList 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 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", Qp(anfrage.Stufe).ToString(CultureInfo.InvariantCulture), "-g", schluesselabstand, "-bf", "0", ]; } var weich = new List(); 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}"]); } var (preset, crf) = Weichkodierung(anfrage.Stufe); weich.AddRange([ "-c:v", "libx264", // **Voreingestellt `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. Wer eine // Maschine hat, die mehr kann, stellt es um; wer eine schwache // hat, stellt auf Tempo. "-preset", preset, "-crf", crf.ToString(CultureInfo.InvariantCulture), "-profile:v", "high", "-level", "4.1", "-pix_fmt", "yuv420p", // Feste Keyframeabstände, damit jedes Segment gleich lang wird. "-g", schluesselabstand, "-keyint_min", schluesselabstand, "-sc_threshold", "0", ]); _ = bild; return weich; } /// Was die Stufe für libx264 bedeutet. /// /// /// Preset und CRF zusammen, nicht einzeln. Ein langsameres Preset /// rechnet länger an demselben CRF und holt dabei Bild heraus; ein /// schnelleres braucht einen höheren CRF, sonst wächst nur die Datei. Die /// drei Paare sind deshalb Paare und keine zwei Regler. /// private static (string Preset, int Crf) Weichkodierung(Kodierstufe stufe) => stufe switch { Kodierstufe.Qualitaet => ("medium", 19), Kodierstufe.Tempo => ("ultrafast", 23), _ => ("veryfast", 21), }; /// Dasselbe für die Grafikeinheit. /// /// /// VAAPI kennt kein Preset — die Festfunktionseinheit rechnet, wie sie /// rechnet. Es bleibt der Quantisierer, und der wirkt in dieselbe /// Richtung: kleiner ist schöner und grösser. /// private static int Qp(Kodierstufe stufe) => stufe switch { Kodierstufe.Qualitaet => 21, Kodierstufe.Tempo => 26, _ => 23, }; private static IEnumerable 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"]; } }