namespace Flaro.Core.Media;
/// Ein Messpunkt aus dem Bild: wann, und wie schwarz.
///
///
///
/// Der Anteil, nicht der Mittelwert — und das ist der Unterschied
/// zwischen „funktioniert manchmal" und „funktioniert". Ein Mittelwert
/// über das ganze Bild steigt mit jeder Textzeile: 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 jedem Namen.
///
///
/// Der Anteil dunkler Pixel tut das nicht: Schrift belegt ein bis zehn
/// Prozent der Fläche, der Rest bleibt schwarz. Gemessen an denselben
/// zwei Abspännen — 84 % und 87 % gegen 0 % in der Handlung.
///
///
///
/// Die Stelle im Film.
/// Wie viel Prozent des Bildes dunkel sind, 0 bis 100.
public readonly record struct Bildprobe(double Sekunde, double Dunkelanteil);
/// Ein Stück Abspann: von wann bis wann.
///
///
/// Mehrere, weil ein Abspann unterbrochen sein kann. Liegt eine
/// Post-Credit-Szene dazwischen, sind es zwei Stücke mit der Szene als
/// Lücke — und der Überspringen-Knopf bringt den Zuschauer genau dorthin
/// statt an ihr vorbei.
///
public readonly record struct Abspannstück(double Von, double Bis)
{
public double Länge => Math.Max(0, Bis - Von);
}
/// Die Schwellen, nach denen eine dunkle Strecke ein Abspann ist.
///
///
/// Alle an einer Stelle, damit man sie gemeinsam verschieben kann.
/// Jede einzelne ist eine Setzung; verstreut über drei Dateien liesse sich
/// nicht mehr sagen, warum ein Fund herausfiel.
///
public readonly record struct Abspannregeln
{
/// Wie viel Prozent des Bildes dunkel sein müssen (0–100).
///
///
///
/// Sechzig, und der Abstand ist nach beiden Seiten gross.
/// Gemessen an erzeugtem 1080p-Material mit zwei Spalten Namen, durch
/// dieselbe ffmpeg-Kette wie im Betrieb:
///
///
/// - Abspann, schriftreich: 84 %
/// - Abspann, schlank: 87 %
/// - dunkle Nachtszene: 37 %
/// - Handlung: 0 %
///
///
/// Und damit entfällt die relative Schwelle. 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
/// Anteil dunkler Pixel eicht sich selbst — ein heller Film und ein
/// dunkler ergeben in der Handlung beide nahezu null.
///
///
public double Mindestanteil { get; init; }
/// Wie viele Sekunden hell eine dunkle Strecke nicht brechen.
///
///
///
/// Eine Schrifttafel blitzt hell auf, ein Logo steht zwei Sekunden im
/// Bild. Ohne Nachsicht zerfiele ein Abspann in fünf kurze Strecken,
/// von denen keine lang genug wäre.
///
///
/// In Sekunden, nicht in Proben — dieselbe Lehre wie bei
/// , hier eine Zeile höher übersehen. Als
/// Probenzahl hing die Nachsicht daran, wie dicht die Datei ihre
/// Schlüsselbilder setzt: gemessen an „(T)Raumschiff Surprise" kommen
/// 1318 Proben auf zwanzig Minuten, also eine alle 0,9 Sekunden —
/// zwei Proben waren dort 1,8 Sekunden Nachsicht. Bei einer Datei mit
/// einem Schlüsselbild alle zehn Sekunden waren es zwanzig. Dasselbe
/// Zugeständnis, zehnfach verschieden.
///
///
public double Nachsicht { get; init; }
/// Wie viele Sekunden hell hinter dem Abspann erlaubt sind.
///
///
/// In Sekunden, nicht in Proben. Eine Logotafel dauert, was sie
/// dauert; wie viele Messpunkte darauf fallen, hängt allein daran, wie
/// dicht die Datei ihre Schlüsselbilder setzt. In Proben gezählt wäre
/// dieselbe Tafel bei der einen Datei verziehen und bei der nächsten
/// ein Abbruch.
///
public double Schlusstafel { get; init; }
/// Wie kurz ein einzelnes Stück sein darf, um noch zu zählen.
///
///
/// Ein Abspann kann aus zwei Stücken bestehen, wenn eine Post-Credit-
/// Szene dazwischenliegt. Zehn Sekunden Schwarz sind aber kein Stück
/// Abspann, sondern eine Blende — die Mindestlänge gilt für die Summe,
/// diese Schranke für das einzelne Stück.
///
public double Mindeststück { get; init; }
/// Wie lang eine Post-Credit-Szene höchstens sein darf.
///
///
/// Die Lücke, über die hinweg zusammengefasst wird. Eine
/// Post-Credit-Szene im MCU dauert typischerweise eine Minute; die
/// längste gemessene (Thunderbolts*) 2:54. Vier Minuten sind der
/// längste bekannte Wert plus Reserve — und zugleich die Schranke
/// dagegen, dass die Suche rückwärts durch den halben Film läuft.
///
public double Lückentoleranz { get; init; }
/// Die Summe aller Stücke, nicht die Länge eines einzelnen.
public double Mindestlänge { get; init; }
public double Höchstlänge { get; init; }
public static Abspannregeln Vorgabe => new()
{
Mindestanteil = 60,
Nachsicht = 10,
Schlusstafel = 20,
Mindeststück = 15,
Lückentoleranz = 4 * 60,
Mindestlänge = 45,
Höchstlänge = 12 * 60,
};
}
/// Findet, wo der Abspann eines Films anfängt.
///
///
///
/// Warum das Bild und nicht der Ton. Bei einer Serie erkennt man
/// einen Abspann daran, dass er in jeder Folge derselbe ist. Ein Film hat
/// nichts, womit er sich vergleichen liesse — sein Abspann kommt genau
/// einmal vor. Was bleibt, ist, dass er dunkel ist.
///
///
/// Gesucht wird nicht „ein dunkles Bild", sondern die Kante. Dunkle
/// Einzelbilder gibt es in jedem Film. Ein Abspann ist eine dunkle Strecke,
/// die bis zum Ende durchhält — und genau das Durchhalten ist das Merkmal,
/// nicht die Dunkelheit.
///
///
/// Reine Rechnung, ohne Datei. Das Abtasten braucht ffmpeg; das
/// Beurteilen braucht nichts. Getrennt lässt sich das Schwierige prüfen,
/// ohne einen Film vorzuhalten — dieselbe Naht wie bei
/// .
///
///
public static class Abspannkante
{
/// Die Stücke Abspann am Ende — oder eine leere Liste.
///
///
/// Messpunkte über das Ende des Films, aufsteigend nach Zeit. Sie dürfen
/// ungleichmässig liegen: Schlüsselbilder tun das.
///
public static IReadOnlyList Suchen(
IReadOnlyList proben, Abspannregeln regeln) =>
Suchen(proben, regeln, out _);
///
/// Woran es lag — für das Protokoll. „Kein Abspann gefunden" ist keine
/// Auskunft: ob das Ende zu hell war, die Strecke zu kurz oder zu lang,
/// sind drei verschiedene Ursachen mit drei verschiedenen Antworten,
/// und ohne diese Zeile beginnt jede Nachfrage wieder beim Raten.
///
public static IReadOnlyList Suchen(
IReadOnlyList proben, Abspannregeln regeln, out string grund)
{
grund = "gefunden";
if (proben.Count < 2)
{
grund = "zu wenige Proben";
return [];
}
var läufe = Läufe(proben, regeln);
if (läufe.Count == 0)
{
grund = "keine dunkle Strecke im Suchfenster";
return [];
}
// **Das letzte Stück muss das Ende erreichen.** Ein Abspann endet am
// Dateiende; was davor dunkel war und wieder hell wurde, war eine
// Schwarzblende. Eine helle Logotafel dahinter ist erlaubt — Sender,
// Studio, Förderer stehen bei vielen Produktionen noch hinter dem
// Abspann.
var ende = proben[^1].Sekunde;
if (ende - läufe[^1].Bis >= regeln.Schlusstafel)
{
grund = "das Ende ist hell";
return [];
}
// **Rückwärts zusammenfassen.** Jede Lücke unter der Toleranz ist
// eine Post-Credit-Szene und kein Abbruch; die erste grössere Lücke
// beendet die Suche.
var stücke = new List { läufe[^1] };
for (var i = läufe.Count - 2; i >= 0; i--)
{
if (stücke[^1].Von - läufe[i].Bis >= regeln.Lückentoleranz) break;
stücke.Add(läufe[i]);
}
stücke.Reverse();
// **Die Summe entscheidet, nicht das einzelne Stück.** Ein Abspann
// von vier Minuten, den eine Szene in der Mitte teilt, ist ein
// Abspann von vier Minuten — vorher zerfiel er in zwei Reste, von
// denen keiner lang genug war, und der Film bekam gar nichts.
var summe = stücke.Sum(t => t.Länge);
if (summe < regeln.Mindestlänge)
{
grund = $"die dunklen Strecken ergeben zusammen nur {summe:0} s";
return [];
}
if (summe > regeln.Höchstlänge)
{
grund = $"die dunklen Strecken ergeben zusammen {summe:0} s und damit zu viel";
return [];
}
// Das letzte Stück reicht bis ans Dateiende: eine Logotafel dahinter
// gehört zum Abspann, nicht zum Film.
stücke[^1] = stücke[^1] with { Bis = ende };
return stücke;
}
/// Zusammenhängende dunkle Strecken, kurze Helligkeiten verziehen.
///
///
/// Die Nachsicht zählt Sekunden, nicht Proben. Eine Schrifttafel
/// blitzt auf, ein Logo steht zwei Sekunden im Bild; wie viele
/// Messpunkte darauf fallen, hängt allein an der Schlüsselbilddichte
/// der Datei und schwankt um eine Zehnerpotenz.
///
private static List Läufe(
IReadOnlyList proben, Abspannregeln regeln)
{
var läufe = new List();
double? von = null;
double bis = 0;
foreach (var probe in proben)
{
if (probe.Dunkelanteil >= regeln.Mindestanteil)
{
von ??= probe.Sekunde;
bis = probe.Sekunde;
continue;
}
// Hell: der Lauf endet erst, wenn die Helligkeit lange genug
// anhält.
if (von is not null && probe.Sekunde - bis > regeln.Nachsicht)
{
läufe.Add(new Abspannstück(von.Value, bis));
von = null;
}
}
if (von is not null) läufe.Add(new Abspannstück(von.Value, bis));
// Zehn Sekunden Schwarz sind eine Blende, kein Stück Abspann.
läufe.RemoveAll(l => l.Länge < regeln.Mindeststück);
return läufe;
}
/// Die letzte helle Probe vor dem Fund — der Suchbereich fürs Nachmessen.
public static double? HellDavor(
IReadOnlyList proben, double dunkelAb, Abspannregeln regeln)
{
double? letzteHelle = null;
foreach (var p in proben)
{
if (p.Sekunde >= dunkelAb) break;
if (p.Dunkelanteil < regeln.Mindestanteil) letzteHelle = p.Sekunde;
}
return letzteHelle;
}
/// Sucht die Kante zwischen hell und dunkel genauer ein.
///
///
///
/// Binäre Suche mit kurzen Sonden, nicht noch ein Durchlauf. Die
/// grobe Reihe stammt aus Schlüsselbildern, und die liegen je nach
/// Aufnahme fünf bis zehn Sekunden auseinander. Den ganzen Bereich fein
/// abzutasten hiesse, ihn zu dekodieren; zehn Sonden von je einer
/// Sekunde kosten einen Bruchteil davon. Denselben Weg geht Jellyfins
/// Intro Skipper, und hört bei denselben vier Sekunden auf.
///
///
/// liefert die Helligkeit an einer Stelle oder
/// null, wenn dort nichts zu holen war. Eine Sonde ohne Antwort
/// beendet die Suche — dann gilt, was die grobe Reihe sagte.
///
///
public static async Task VerfeinernAsync(
double hell, double dunkel, double mindestanteil,
Func> messen, double genauigkeit = 4)
{
if (genauigkeit <= 0) genauigkeit = 1;
while (dunkel - hell > genauigkeit)
{
var mitte = hell + (dunkel - hell) / 2;
var wert = await messen(mitte);
if (wert is not { } anteil) return dunkel;
if (anteil >= mindestanteil) dunkel = mitte;
else hell = mitte;
}
// Die dunkle Seite gewinnt: lieber ein paar Sekunden Abspann zu viel
// überspringen als eine Sekunde Handlung.
return dunkel;
}
}
/// Ein Messpunkt des Werkzeugs: das ganze Bild und die Ränder.
///
///
/// Nur zum Nachsehen, nicht für die Erkennung. Ob die Ränder bei
/// Outtakes im Abspann dunkel bleiben, ist eine Vermutung — und eine, die
/// sich nur an echten Dateien beantworten lässt. Diese Reihe ist die
/// Grundlage dafür.
///
///
/// Die Stelle im Film.
/// Dunkelanteil des ganzen Bildes, 0 bis 100.
/// Dunkelanteil der linken fünftel Breite.
/// Dunkelanteil der rechten fünftel Breite.
public readonly record struct Kurvenpunkt(
double Sekunde, double Gesamt, double Links, double Rechts);