Files
Flaro/server/src/Flaro.Media/Bildabtaster.cs
T
ScarriffleandClaude Opus 5 32f592b83e Ein Abspann darf aus Stücken bestehen
Zwei Fälle fielen durch, beide am Bestand gesehen. Bei einem Marvel-Film
liegt hinter dem Abspann eine Post-Credit-Szene: die Suche begann am
Dateiende, fand nur den kurzen Rest dahinter und verwarf ihn als zu kurz.
Und ein Abspann, den eine Szene in der Mitte teilt, zerfiel in zwei
Reste, von denen keiner lang genug war. Dieselbe Ursache: der Abspann
musste eine zusammenhängende dunkle Strecke bis zum Dateiende sein.

Jetzt bildet die Suche Läufe, fasst sie über Lücken bis vier Minuten
zusammen — so lang ist die längste bekannte Post-Credit-Szene plus
Reserve — und lässt die **Summe** über die Mindestlänge entscheiden statt
jedes einzelne Stück. Ein Lauf unter fünfzehn Sekunden ist eine Blende
und zählt nicht mit.

Dafür mussten die eindeutigen Indizes über (Titel, Art) weichen; sie
standen mit dem Vermerk „eine Art einmal je Titel" da, und genau das
stimmt nicht mehr. `SchreibenAsync` ersetzt jetzt die Menge, statt eine
Zeile umzuschreiben — das ist der einzige Weg, der in beide Richtungen
stimmt, wenn aus zwei Stücken wieder eines wird.

**Drei Stellen trugen das nicht.** Im Web hiess „Abspann überspringen"
bei einem Film immer Schluss — beim ersten von zwei Stücken bekäme
niemand die Szene je zu sehen; geschlossen wird jetzt nur, wenn nichts
mehr folgt. Auf iOS verwarf der Spieler die Abspannmarke eines Films
ganz; diese Regel gehört nicht dorthin, sondern auf den Server, und ist
ersatzlos weg. Und die Gesehen-Schwelle nahm irgendeine Abspannzeile —
ohne `OrderBy` entschied die Datenbank; gemeint ist immer die früheste.

Dazu der Schalter „Zusätzliche Szenen mit überspringen" (Vorgabe aus) bei
den Geräteeinstellungen, und ein Messwerkzeug: ein Knopf auf der
Filmseite lädt die Kurve je Schlüsselbild als Tabelle — Dunkelanteil des
ganzen Bildes und der beiden Randstreifen. Ob bei Outtakes im Abspann die
Ränder dunkel bleiben, ist eine Vermutung; bevor daraus eine Schwelle
wird, will ich die Zahlen sehen.

Fünf neue Prüfungen, darunter die wichtigste: ein durchgehender Abspann
bleibt ein Stück.

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

268 lines
11 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);
}
/// <param name="zuschnitt">
/// Ein <c>crop</c>-Ausdruck vor der Messung, oder <c>null</c> für das
/// ganze Bild. Nur das Messwerkzeug benutzt das.
/// </param>
/// <summary>Drei Kurven über denselben Abschnitt — zum Nachsehen.</summary>
///
/// <remarks>
/// <para>
/// <b>Ein Werkzeug, keine Erkennung.</b> 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.
/// </para>
/// <para>
/// <b>Drei Durchläufe, nicht einer.</b> Zwei <c>blackframe</c> in einer
/// Kette schrieben denselben Metadatenschlüssel und überschrieben
/// einander; mit <c>split</c> 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.
/// </para>
/// </remarks>
public async Task<IReadOnlyList<Kurvenpunkt>> KurveAsync(
string pfad, double ab, double dauer, CancellationToken ct = default)
{
async Task<IReadOnlyList<Bildprobe>> 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<Bildprobe> 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<string> 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 "";
}
}
/// <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();
}