„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>
344 lines
14 KiB
C#
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"];
|
|
}
|
|
}
|