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(); }