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>
349 lines
14 KiB
C#
349 lines
14 KiB
C#
namespace Flaro.Core.Media;
|
||
|
||
/// <summary>Ein Messpunkt aus dem Bild: wann, und wie schwarz.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Der Anteil, nicht der Mittelwert</b> — 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.
|
||
/// </para>
|
||
/// <para>
|
||
/// 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.
|
||
/// </para>
|
||
/// </remarks>
|
||
///
|
||
/// <param name="Sekunde">Die Stelle im Film.</param>
|
||
/// <param name="Dunkelanteil">Wie viel Prozent des Bildes dunkel sind, 0 bis 100.</param>
|
||
public readonly record struct Bildprobe(double Sekunde, double Dunkelanteil);
|
||
|
||
/// <summary>Ein Stück Abspann: von wann bis wann.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <b>Mehrere, weil ein Abspann unterbrochen sein kann.</b> 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.
|
||
/// </remarks>
|
||
public readonly record struct Abspannstück(double Von, double Bis)
|
||
{
|
||
public double Länge => Math.Max(0, Bis - Von);
|
||
}
|
||
|
||
/// <summary>Die Schwellen, nach denen eine dunkle Strecke ein Abspann ist.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <b>Alle an einer Stelle, damit man sie gemeinsam verschieben kann.</b>
|
||
/// Jede einzelne ist eine Setzung; verstreut über drei Dateien liesse sich
|
||
/// nicht mehr sagen, warum ein Fund herausfiel.
|
||
/// </remarks>
|
||
public readonly record struct Abspannregeln
|
||
{
|
||
/// <summary>Wie viel Prozent des Bildes dunkel sein müssen (0–100).</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Sechzig, und der Abstand ist nach beiden Seiten gross.</b>
|
||
/// Gemessen an erzeugtem 1080p-Material mit zwei Spalten Namen, durch
|
||
/// dieselbe ffmpeg-Kette wie im Betrieb:
|
||
/// </para>
|
||
/// <list type="table">
|
||
/// <item><description>Abspann, schriftreich: <b>84 %</b></description></item>
|
||
/// <item><description>Abspann, schlank: <b>87 %</b></description></item>
|
||
/// <item><description>dunkle Nachtszene: <b>37 %</b></description></item>
|
||
/// <item><description>Handlung: <b>0 %</b></description></item>
|
||
/// </list>
|
||
/// <para>
|
||
/// <b>Und damit entfällt die relative Schwelle.</b> 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.
|
||
/// </para>
|
||
/// </remarks>
|
||
public double Mindestanteil { get; init; }
|
||
|
||
/// <summary>Wie viele Sekunden hell eine dunkle Strecke nicht brechen.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <para>
|
||
/// 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.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>In Sekunden, nicht in Proben</b> — dieselbe Lehre wie bei
|
||
/// <see cref="Schlusstafel"/>, 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.
|
||
/// </para>
|
||
/// </remarks>
|
||
public double Nachsicht { get; init; }
|
||
|
||
/// <summary>Wie viele Sekunden hell hinter dem Abspann erlaubt sind.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <b>In Sekunden, nicht in Proben.</b> 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.
|
||
/// </remarks>
|
||
public double Schlusstafel { get; init; }
|
||
|
||
/// <summary>Wie kurz ein einzelnes Stück sein darf, um noch zu zählen.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// 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.
|
||
/// </remarks>
|
||
public double Mindeststück { get; init; }
|
||
|
||
/// <summary>Wie lang eine Post-Credit-Szene höchstens sein darf.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <b>Die Lücke, über die hinweg zusammengefasst wird.</b> 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.
|
||
/// </remarks>
|
||
public double Lückentoleranz { get; init; }
|
||
|
||
/// <summary>Die Summe aller Stücke, nicht die Länge eines einzelnen.</summary>
|
||
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,
|
||
};
|
||
}
|
||
|
||
/// <summary>Findet, wo der Abspann eines Films anfängt.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Warum das Bild und nicht der Ton.</b> 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.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Gesucht wird nicht „ein dunkles Bild", sondern die Kante.</b> 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.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Reine Rechnung, ohne Datei.</b> Das Abtasten braucht ffmpeg; das
|
||
/// Beurteilen braucht nichts. Getrennt lässt sich das Schwierige prüfen,
|
||
/// ohne einen Film vorzuhalten — dieselbe Naht wie bei
|
||
/// <see cref="Tonabgleich"/>.
|
||
/// </para>
|
||
/// </remarks>
|
||
public static class Abspannkante
|
||
{
|
||
/// <summary>Die Stücke Abspann am Ende — oder eine leere Liste.</summary>
|
||
///
|
||
/// <param name="proben">
|
||
/// Messpunkte über das Ende des Films, aufsteigend nach Zeit. Sie dürfen
|
||
/// ungleichmässig liegen: Schlüsselbilder tun das.
|
||
/// </param>
|
||
public static IReadOnlyList<Abspannstück> Suchen(
|
||
IReadOnlyList<Bildprobe> proben, Abspannregeln regeln) =>
|
||
Suchen(proben, regeln, out _);
|
||
|
||
/// <param name="grund">
|
||
/// 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.
|
||
/// </param>
|
||
public static IReadOnlyList<Abspannstück> Suchen(
|
||
IReadOnlyList<Bildprobe> 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<Abspannstück> { 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;
|
||
}
|
||
|
||
/// <summary>Zusammenhängende dunkle Strecken, kurze Helligkeiten verziehen.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <b>Die Nachsicht zählt Sekunden, nicht Proben.</b> 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.
|
||
/// </remarks>
|
||
private static List<Abspannstück> Läufe(
|
||
IReadOnlyList<Bildprobe> proben, Abspannregeln regeln)
|
||
{
|
||
var läufe = new List<Abspannstück>();
|
||
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;
|
||
}
|
||
|
||
/// <summary>Die letzte helle Probe vor dem Fund — der Suchbereich fürs Nachmessen.</summary>
|
||
public static double? HellDavor(
|
||
IReadOnlyList<Bildprobe> 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;
|
||
}
|
||
|
||
/// <summary>Sucht die Kante zwischen hell und dunkel genauer ein.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Binäre Suche mit kurzen Sonden, nicht noch ein Durchlauf.</b> 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.
|
||
/// </para>
|
||
/// <para>
|
||
/// <paramref name="messen"/> liefert die Helligkeit an einer Stelle oder
|
||
/// <c>null</c>, wenn dort nichts zu holen war. Eine Sonde ohne Antwort
|
||
/// beendet die Suche — dann gilt, was die grobe Reihe sagte.
|
||
/// </para>
|
||
/// </remarks>
|
||
public static async Task<double> VerfeinernAsync(
|
||
double hell, double dunkel, double mindestanteil,
|
||
Func<double, Task<double?>> 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;
|
||
}
|
||
}
|
||
|
||
/// <summary>Ein Messpunkt des Werkzeugs: das ganze Bild und die Ränder.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <b>Nur zum Nachsehen, nicht für die Erkennung.</b> 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.
|
||
/// </remarks>
|
||
///
|
||
/// <param name="Sekunde">Die Stelle im Film.</param>
|
||
/// <param name="Gesamt">Dunkelanteil des ganzen Bildes, 0 bis 100.</param>
|
||
/// <param name="Links">Dunkelanteil der linken fünftel Breite.</param>
|
||
/// <param name="Rechts">Dunkelanteil der rechten fünftel Breite.</param>
|
||
public readonly record struct Kurvenpunkt(
|
||
double Sekunde, double Gesamt, double Links, double Rechts);
|