using System.Diagnostics;
using System.Globalization;
using System.Text.RegularExpressions;
using Flaro.Core.Media;
using Microsoft.Extensions.Logging;
namespace Flaro.Media;
/// Misst, wie hell ein Film an einer Stelle ist.
///
///
///
/// Der einzige Teil der Abspannsuche, der ffmpeg anfasst. Was
/// daraus folgt, rechnet ohne Datei — dieselbe
/// Naht wie zwischen Tonabdruckleser und Tonabgleich.
///
///
/// Zwei Griffe, weil zwei Fragen. „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.
///
///
public sealed partial class Bildabtaster(FfmpegOptions optionen, ILogger log)
{
/// Auf welche Breite verkleinert wird, bevor gerechnet wird.
///
///
/// 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.
///
private const string Breite = "160";
/// Unter welchem Lumawert ein einzelnes Pixel dunkel heisst.
///
///
///
/// Nicht der Mittelwert des Bildes, sondern jedes Pixel für sich.
/// blackframe 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.
///
///
/// 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.
///
///
private const string Dunkelschwelle = "24";
/// Der Dunkelanteil an den Schlüsselbildern eines Abschnitts.
///
///
/// Nur Schlüsselbilder, und das ist der ganze Trick. 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
/// nach.
///
public async Task> 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);
}
/// Der mittlere Dunkelanteil einer einzelnen Sekunde.
///
///
/// 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.
///
public async Task 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);
}
///
/// Ein crop-Ausdruck vor der Messung, oder null für das
/// ganze Bild. Nur das Messwerkzeug benutzt das.
///
/// Drei Kurven über denselben Abschnitt — zum Nachsehen.
///
///
///
/// Ein Werkzeug, keine Erkennung. Die Randstreifen sind eine
/// Vermutung: bei Outtakes im Abspann läuft das Bild vielleicht in der
/// Mitte und die Ränder bleiben schwarz. Ob das trägt, sieht man nur an
/// echten Dateien — und dafür braucht es die Zahlen, bevor eine Schwelle
/// gesetzt wird.
///
///
/// Drei Durchläufe, nicht einer. Zwei blackframe in einer
/// Kette schrieben denselben Metadatenschlüssel und überschrieben
/// einander; mit split ginge es, kostete aber drei Ausgabewege
/// und eine Zusammenführung. Für etwas, das jemand einmal je Film von
/// Hand anstösst, sind drei Läufe über Schlüsselbilder das günstigere
/// Geschäft.
///
///
public async Task> KurveAsync(
string pfad, double ab, double dauer, CancellationToken ct = default)
{
async Task> Lauf(string? zuschnitt) => Lesen(
await LaufenAsync(pfad, ab, dauer, nurSchluesselbilder: true,
TimeSpan.FromMinutes(10), ct, zuschnitt),
ab);
var gesamt = await Lauf(null);
var links = await Lauf("crop=iw*0.2:ih:0:0");
var rechts = await Lauf("crop=iw*0.2:ih:iw*0.8:0");
// Nach Zeit zusammenführen: die drei Läufe sehen dieselben
// Schlüsselbilder, aber darauf zu bauen wäre eine Annahme.
double BeiZeit(IReadOnlyList reihe, double sekunde) =>
reihe.Count == 0 ? double.NaN
: reihe.MinBy(p => Math.Abs(p.Sekunde - sekunde)).Dunkelanteil;
return gesamt
.Select(p => new Kurvenpunkt(
p.Sekunde, p.Dunkelanteil, BeiZeit(links, p.Sekunde), BeiZeit(rechts, p.Sekunde)))
.ToList();
}
private async Task LaufenAsync(
string pfad, double ab, double dauer, bool nurSchluesselbilder,
TimeSpan frist, CancellationToken ct, string? zuschnitt = null)
{
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(
(zuschnitt is null ? "" : zuschnitt + ",")
+ $"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 "";
}
}
/// Liest die Ausgabe des metadata-Filters.
///
///
/// Sie besteht aus Paaren: eine Zeile mit pts_time, darunter eine
/// mit dem Wert. Der Zeitstempel zählt ab dem Suchpunkt, nicht ab dem
/// Dateianfang — deshalb der Aufschlag.
///
public static IReadOnlyList Lesen(string ausgabe, double versatz)
{
var proben = new List();
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();
}