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"];
}
}