Files
Flaro/server/src/Flaro.Media/Bildabtaster.cs
T
ScarriffleandClaude Opus 5 791c00dacc Nicht wie hell das Bild ist, sondern wie viel davon schwarz
Gemessen wurde der Mittelwert über das ganze Bild, und daran scheiterte
die Abspannsuche wiederholt: weisse Schrift hebt den Mittelwert. Derselbe
Abspann misst 19, wenn wenig Schrift darin steht, und 37, wenn viel darin
steht — die Schwelle musste zwischen diesen Wert und die Handlung passen,
und der Abstand schrumpfte mit jeder Textzeile. Zweimal nachgezogen,
zweimal wieder getroffen.

Jellyfin zählt stattdessen Pixel, und das ist kein besserer Schwellenwert,
sondern ein besseres Merkmal. Nachgemessen an denselben erzeugten
Abspännen, durch dieselbe Kette wie im Betrieb:

                      Mittelwert   Anteil dunkler Pixel
    Handlung                 128                    0 %
    Abspann, schriftreich     37                   84 %
    Abspann, schlank          19                   87 %
    dunkle Nachtszene         28                   37 %

Schrift belegt ein bis zehn Prozent der Fläche; der Rest bleibt schwarz.
Der Anteil hängt deshalb nicht an der Schriftmenge, und der Abstand zur
Handlung ist nach beiden Seiten riesig.

`signalstats` weicht `blackframe=amount=0:threshold=24` — die Schwelle
für das einzelne Pixel über eine Reihe von Werten eingemessen: darunter
(16) fällt der Abspann selbst auf zehn Prozent ab, darüber (32) fängt die
Handlung an mitzuzählen. Die Ausgabe kommt über denselben
`metadata=print`-Weg wie vorher, nur unter einem anderen Schlüssel.

**Und die relative Schwelle fällt ersatzlos weg.** Sie war nötig, solange
der Mittelwert gemessen wurde: wie hell ein Abspann ist, hing am Film,
also musste sich die Schwelle am Film ausrichten. Der Pixelanteil eicht
sich selbst — `Abspannkante.Schwelle` und `Abspannregeln.Anteil` sind
gestrichen, und mit ihnen die Rechnerei um den Grundton.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 15:14:52 +02:00

220 lines
8.4 KiB
C#

using System.Diagnostics;
using System.Globalization;
using System.Text.RegularExpressions;
using Flaro.Core.Media;
using Microsoft.Extensions.Logging;
namespace Flaro.Media;
/// <summary>Misst, wie hell ein Film an einer Stelle ist.</summary>
///
/// <remarks>
/// <para>
/// <b>Der einzige Teil der Abspannsuche, der ffmpeg anfasst.</b> Was
/// daraus folgt, rechnet <see cref="Abspannkante"/> ohne Datei — dieselbe
/// Naht wie zwischen <c>Tonabdruckleser</c> und <c>Tonabgleich</c>.
/// </para>
/// <para>
/// <b>Zwei Griffe, weil zwei Fragen.</b> „Gibt es am Ende überhaupt eine
/// dunkle Strecke" verlangt einen groben Überblick über zwanzig Minuten;
/// „wo genau fängt sie an" verlangt eine einzelne Stelle. Mit demselben
/// Werkzeug beantwortet wären beide zu teuer: der Überblick fein
/// abgetastet heisst zwanzig Minuten dekodieren, die Stelle grob gesucht
/// heisst zehn Sekunden Ungenauigkeit.
/// </para>
/// </remarks>
public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger<Bildabtaster> log)
{
/// <summary>Auf welche Breite verkleinert wird, bevor gerechnet wird.</summary>
///
/// <remarks>
/// Für einen Mittelwert über das ganze Bild braucht es keine 1920
/// Spalten. Gemessen: bei 160 Spalten kostet der Filtergraph nichts
/// Nennenswertes mehr, und der Mittelwert ist derselbe.
/// </remarks>
private const string Breite = "160";
/// <summary>Unter welchem Lumawert ein einzelnes Pixel dunkel heisst.</summary>
///
/// <remarks>
/// <para>
/// <b>Nicht der Mittelwert des Bildes, sondern jedes Pixel für sich.</b>
/// <c>blackframe</c> zählt, wie viel Prozent eines Bildes darunter
/// liegen — und das ist ein ganz anderes Merkmal als der Mittelwert:
/// weisse Schrift hebt den Mittelwert, belegt aber nur ein paar
/// Prozent der Fläche.
/// </para>
/// <para>
/// Vierundzwanzig, gemessen über eine Reihe von Schwellen. Darunter
/// (16) fällt der Abspann selbst auf zehn Prozent ab, darüber (32)
/// fängt die Handlung an mitzuzählen. Bei 24: Abspann 84 bis 87
/// Prozent, Handlung 0, eine dunkle Nachtszene 37.
/// </para>
/// </remarks>
private const string Dunkelschwelle = "24";
/// <summary>Der Dunkelanteil an den Schlüsselbildern eines Abschnitts.</summary>
///
/// <remarks>
/// <b>Nur Schlüsselbilder, und das ist der ganze Trick.</b> Gemessen an
/// 180 Sekunden 1080p: jedes zweite Bild zu dekodieren kostet 2,89
/// Sekunden, nur die Schlüsselbilder 0,24. Dafür liegen die Messpunkte
/// fünf bis zehn Sekunden auseinander — genug, um eine dunkle Strecke
/// zu finden, zu wenig für ihre Kante. Die holt
/// <see cref="DunkelanteilAsync"/> nach.
/// </remarks>
public async Task<IReadOnlyList<Bildprobe>> UeberblickAsync(
string pfad, double ab, double dauer, CancellationToken ct = default)
{
var ausgabe = await LaufenAsync(
pfad, ab, dauer, nurSchluesselbilder: true, TimeSpan.FromMinutes(5), ct);
return Lesen(ausgabe, ab);
}
/// <summary>Der mittlere Dunkelanteil einer einzelnen Sekunde.</summary>
///
/// <remarks>
/// Alle Bilder, nicht nur Schlüsselbilder: eine Sekunde enthält sonst
/// vielleicht gar keines. Dass ffmpeg dafür vom vorigen Schlüsselbild
/// an dekodieren muss, ist der Preis — bei zehn Sekunden Abstand sind
/// das zehn Sekunden Bildmaterial, also Millisekunden.
/// </remarks>
public async Task<double?> DunkelanteilAsync(
string pfad, double bei, CancellationToken ct = default)
{
var ausgabe = await LaufenAsync(
pfad, Math.Max(0, bei), dauer: 1, nurSchluesselbilder: false,
TimeSpan.FromSeconds(60), ct);
var proben = Lesen(ausgabe, 0);
if (proben.Count == 0) return null;
return proben.Average(p => p.Dunkelanteil);
}
private async Task<string> LaufenAsync(
string pfad, double ab, double dauer, bool nurSchluesselbilder,
TimeSpan frist, CancellationToken ct)
{
var start = new ProcessStartInfo(optionen.Ffmpeg)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
// **Kein Lesen von der Tastatur.** Ohne das greift ffmpeg nach der
// Standardeingabe des Servers und kann einen Dienst anhalten, der
// ohne Terminal läuft.
start.ArgumentList.Add("-nostdin");
start.ArgumentList.Add("-v");
start.ArgumentList.Add("error");
if (nurSchluesselbilder)
{
start.ArgumentList.Add("-skip_frame");
start.ArgumentList.Add("nokey");
}
// **Suchen vor dem Öffnen.** Danach müsste ffmpeg die Datei von
// vorn dekodieren — bei einem Zweistünder zwei Stunden Bild für
// zwanzig Minuten Antwort.
start.ArgumentList.Add("-ss");
start.ArgumentList.Add(ab.ToString("0.###", CultureInfo.InvariantCulture));
start.ArgumentList.Add("-i");
start.ArgumentList.Add(pfad);
start.ArgumentList.Add("-t");
start.ArgumentList.Add(dauer.ToString("0.###", CultureInfo.InvariantCulture));
start.ArgumentList.Add("-an");
start.ArgumentList.Add("-sn");
start.ArgumentList.Add("-vf");
// **`format=gray`, weil die Skala sonst an der Bittiefe hängt.**
// Gemessen am selben erzeugten Bild einmal als 8-Bit und einmal
// als 10-Bit: 127 gegen 500. Ohne diese Umrechnung bedeutete
// dieselbe Schwelle bei der einen Datei etwas anderes als bei der
// nächsten — „Schwelle 247,6" im Protokoll war der Hinweis darauf.
start.ArgumentList.Add(
$"scale={Breite}:-2,format=gray,"
+ $"blackframe=amount=0:threshold={Dunkelschwelle},"
+ "metadata=print:key=lavfi.blackframe.pblack:file=-");
start.ArgumentList.Add("-f");
start.ArgumentList.Add("null");
start.ArgumentList.Add("-");
using var zeit = CancellationTokenSource.CreateLinkedTokenSource(ct);
zeit.CancelAfter(frist);
try
{
using var prozess = Process.Start(start);
if (prozess is null) return "";
var lesen = prozess.StandardOutput.ReadToEndAsync(zeit.Token);
var fehlerLesen = prozess.StandardError.ReadToEndAsync(zeit.Token);
await prozess.WaitForExitAsync(zeit.Token);
var ausgabe = await lesen;
var fehlertext = await fehlerLesen;
if (prozess.ExitCode != 0)
{
log.LogWarning("ffmpeg gab {Code} beim Abtasten von {Pfad} zurück: {Fehler}",
prozess.ExitCode, pfad, fehlertext.Trim());
return "";
}
return ausgabe;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception fehler)
{
log.LogWarning(fehler, "Abtasten von {Pfad} misslungen.", pfad);
return "";
}
}
/// <summary>Liest die Ausgabe des <c>metadata</c>-Filters.</summary>
///
/// <remarks>
/// Sie besteht aus Paaren: eine Zeile mit <c>pts_time</c>, darunter eine
/// mit dem Wert. Der Zeitstempel zählt ab dem Suchpunkt, nicht ab dem
/// Dateianfang — deshalb der Aufschlag.
/// </remarks>
public static IReadOnlyList<Bildprobe> Lesen(string ausgabe, double versatz)
{
var proben = new List<Bildprobe>();
double? zeit = null;
foreach (var zeile in ausgabe.Split('\n'))
{
if (Zeitzeile().Match(zeile) is { Success: true } z
&& double.TryParse(z.Groups[1].Value, NumberStyles.Float,
CultureInfo.InvariantCulture, out var t))
{
zeit = t;
continue;
}
if (Wertzeile().Match(zeile) is { Success: true } w
&& zeit is { } sekunde
&& double.TryParse(w.Groups[1].Value, NumberStyles.Float,
CultureInfo.InvariantCulture, out var wert))
{
proben.Add(new Bildprobe(versatz + sekunde, wert));
zeit = null;
}
}
return proben;
}
[GeneratedRegex(@"pts_time:([\d.]+)")]
private static partial Regex Zeitzeile();
[GeneratedRegex(@"lavfi\.blackframe\.pblack=([\d.]+)")]
private static partial Regex Wertzeile();
}