Files
Flaro/server/src/Flaro.Media/HlsArgumente.cs
T
ScarriffleandClaude Opus 5 ed13d5fa43 Kratzplatz, Markenerkennung, Tonabdruck — erfundene Wörter für Fachbegriffe
„Scratch disk" wörtlich zu übersetzen ist derselbe Fehler wie „Wachhund"
für Watchdog und „Keks" für Cookie. Und „Marke" ist kein Fachbegriff für
das, was gemeint ist: Jellyfin nennt diese Abschnitte Media Segments, und
das ist auch das Wort, in dem sich darüber reden lässt.

    Kratzplatz*       → Transcode*          (was es ist, steht in FLARO_TRANSCODE)
    Marke, Marken     → Segment, Segmente
    Markenerkennung   → Segmenterkennung
    Markenart/-suche  → Segmentart/-suche
    Tonabdruck        → Fingerprint         (Chromaprint nennt es so)
    Tonabgleich       → Fingerprintabgleich
    Schlüsselbild     → Keyframe
    Bildabtaster      → Frameabtaster

Gewöhnliches Deutsch bleibt gewöhnliches Deutsch: Wartung, Freigabe,
Meldung, Abspann, Vorspann. Das war nie das Problem.

**Was sich nicht ändert, und zwar mit Absicht.** Die drei
Einstellungsschlüssel `wartung.kratzplatz.paket.*` bleiben, wie sie sind —
sie stehen in der Datenbank, und ein neuer Name hiesse, dass die Werte
des Nutzers stillschweigend auf die Vorgabe zurückfallen. Dasselbe für
die Endpunktpfade `/marken`: sie sind nicht sichtbar, und ein Umbenennen
wäre Diff ohne Gewinn.

**Die Migration ist von Hand geschrieben.** `dotnet ef` hat ein Gerüst
erzeugt, das beide Tabellen löscht und neu anlegt — es sieht eine
verschwundene und eine neue Tabelle, nicht eine umbenannte. Damit wäre
jedes erkannte Segment und jeder Vermerk „hier wurde schon gesucht" weg
gewesen. `RenameTable` und `RenameIndex` tun dasselbe, ohne eine Zeile
anzufassen; nachgemessen an einer echten SQLite-Datei: Zeilen vorher 1
und 1, nachher 1 und 1, Inhalt unverändert.

Nachgezogen: in `bilder.ts` und `main.ts` heisst „marke" ein Häkchen und
das Logo — die blieben unberührt. Und „Segment" ist sächlich, was eine
Blindersetzung nicht weiss: „eine Segment" stand danach an vierzehn
Stellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 16:26:57 +02:00

344 lines
14 KiB
C#

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,
Kodierstufe Stufe = Kodierstufe.Ausgewogen,
/// <param name="Tonversatz">
/// Um wie viele Sekunden der Ton gegenüber dem Bild verschoben wird.
/// Positiv heisst später, negativ früher. <c>0</c> heisst: nichts tun.
/// </param>
double Tonversatz = 0);
/// <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 Keyframesn liegen.</summary>
///
/// <remarks>
/// <b>Ein Segment muss mit einem Keyframe 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)
{
// **`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<string> { "-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;
}
/// <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>
/// <summary>Eine Sekundenangabe für ffmpeg — mit Punkt und voller Genauigkeit.</summary>
///
/// <remarks>
/// <b>Sechs Nachkommastellen, nicht drei.</b> 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.
/// </remarks>
private static string Sekunden(double wert) =>
wert.ToString("0.######", CultureInfo.InvariantCulture);
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", Qp(anfrage.Stufe).ToString(CultureInfo.InvariantCulture),
"-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}"]);
}
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;
}
/// <summary>Was die Stufe für libx264 bedeutet.</summary>
///
/// <remarks>
/// <b>Preset und CRF zusammen, nicht einzeln.</b> 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.
/// </remarks>
private static (string Preset, int Crf) Weichkodierung(Kodierstufe stufe) => stufe switch
{
Kodierstufe.Qualitaet => ("medium", 19),
Kodierstufe.Tempo => ("ultrafast", 23),
_ => ("veryfast", 21),
};
/// <summary>Dasselbe für die Grafikeinheit.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
private static int Qp(Kodierstufe stufe) => stufe switch
{
Kodierstufe.Qualitaet => 21,
Kodierstufe.Tempo => 26,
_ => 23,
};
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"];
}
}