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