using Flaro.Core.Media; namespace Flaro.Core.Tests; /// Wo der Abspann eines Films anfängt. /// /// /// Mit erfundenen Messreihen statt mit Filmen. Geprüft wird das /// Beurteilen, nicht ob ffmpeg rechnen kann. Die Zahlen sind der Anteil /// dunkler Pixel je Bild und stammen aus einer Messung an erzeugtem /// 1080p-Material: Handlung 0 %, Abspann 84 bis 87 %, dunkle Nachtszene /// 37 %. /// public class AbspannkanteTests { private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe; /// Der Anfang des ersten Stücks — oder nichts. /// /// /// Die meisten Prüfungen hier fragen nur „wo fängt der Abspann an". /// Seit die Suche Stücke zurückgibt, steht das in [0].Von; ein /// Helfer hält die Erwartungen lesbar, statt überall zu indizieren. /// private static double? Anfang(IReadOnlyList stücke) => stücke.Count == 0 ? null : stücke[0].Von; /// Eine Reihe von Messpunkten im Abstand von zehn Sekunden. /// /// Ab wann dunkel. null heisst: nie. private static List Reihe(double von, double bis, double? abspannAb) { var proben = new List(); for (var t = von; t <= bis; t += 10) proben.Add(new Frameprobe(t, abspannAb is { } ab && t >= ab ? 85 : 0)); return proben; } [Test] public void Eine_dunkle_Strecke_bis_zum_Ende_ist_der_Abspann() { var proben = Reihe(0, 600, abspannAb: 420); Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.EqualTo(420)); } /// Ein Film, der hell endet, hat keinen erkennbaren Abspann. /// /// /// Namen über laufendem Bild — Marvel, Pixar, jede zweite Komödie. Kein /// Fund ist hier die richtige Antwort: ein geratener Knopf schnitte /// Handlung weg. /// [Test] public void Ohne_dunkles_Ende_kein_Fund() { Assert.That(Anfang(Abspannkante.Suchen(Reihe(0, 600, abspannAb: null), Regeln)), Is.Null); } /// Eine Schwarzblende mitten im Film zählt nicht. [Test] public void Eine_Schwarzblende_in_der_Mitte_zaehlt_nicht() { var proben = Reihe(0, 600, abspannAb: null); for (var i = 20; i < 30; i++) proben[i] = proben[i] with { Dunkelanteil = 92 }; Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.Null, "sie hält nicht bis zum Ende durch"); } /// Eine Szene nach dem Abspann nimmt den Fund weg. /// /// /// Lieber kein Segment als eine, die über die Szene hinwegspringt. /// [Test] public void Eine_Szene_nach_dem_Abspann_nimmt_den_Fund_weg() { var proben = Reihe(0, 600, abspannAb: 420); for (var i = proben.Count - 5; i < proben.Count; i++) proben[i] = proben[i] with { Dunkelanteil = 0 }; Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.Null); } /// Eine helle Schrifttafel mittendrin bricht die Strecke nicht. /// /// /// Eine Probe, also zehn Sekunden. Hier standen zwei, und das /// war unter der alten Zählweise gedacht: nachgesehen wurde, wie viele /// Proben am Stück hell sind, nicht wie lange. Zwanzig Sekunden /// helles Bild sind keine Schrifttafel mehr, sondern eine Szene — dass /// die Suche dort abbricht, ist richtig und steht in /// AbspannNachsichtTests. /// [Test] public void Eine_kurze_helle_Tafel_bricht_nichts() { var proben = Reihe(0, 600, abspannAb: 420); proben[50] = proben[50] with { Dunkelanteil = 0 }; Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.EqualTo(420)); } /// Zwanzig Sekunden Dunkelheit sind kein Abspann. [Test] public void Zu_kurz_zaehlt_nicht() { Assert.That(Anfang(Abspannkante.Suchen(Reihe(0, 600, abspannAb: 580), Regeln)), Is.Null); } /// Ein halber Film im Dunkeln ist ein Fehlfund. [Test] public void Zu_lang_zaehlt_auch_nicht() { Assert.That(Anfang(Abspannkante.Suchen(Reihe(0, 3600, abspannAb: 600), Regeln)), Is.Null); } [Test] public void Die_letzte_helle_Probe_begrenzt_die_Suche() { var proben = Reihe(0, 600, abspannAb: 420); Assert.That(Abspannkante.HellDavor(proben, 420, Regeln), Is.EqualTo(410)); } // ---------- Nachmessen ---------- /// Die binäre Suche findet die Kante auf vier Sekunden genau. [Test] public async Task Die_Kante_wird_eingegrenzt() { const double echteKante = 417.3; var sonden = 0; var kante = await Abspannkante.VerfeinernAsync(410, 420, Regeln.Mindestanteil, stelle => { sonden++; return Task.FromResult(stelle >= echteKante ? 85 : 0); }); Assert.Multiple(() => { Assert.That(kante, Is.EqualTo(echteKante).Within(4)); Assert.That(kante, Is.GreaterThanOrEqualTo(echteKante), "lieber ein paar Sekunden Abspann zu viel als eine Sekunde Handlung"); Assert.That(sonden, Is.LessThanOrEqualTo(4), "zehn Sekunden auf vier: zwei Halbierungen"); }); } /// Antwortet eine Sonde nicht, gilt der grobe Fund. [Test] public async Task Ohne_Antwort_bleibt_es_beim_groben_Fund() { var kante = await Abspannkante.VerfeinernAsync( 410, 420, Regeln.Mindestanteil, _ => Task.FromResult(null)); Assert.That(kante, Is.EqualTo(420)); } /// Ein schon enger Bereich braucht keine Sonde. [Test] public async Task Ein_enger_Bereich_wird_nicht_angefasst() { var sonden = 0; var kante = await Abspannkante.VerfeinernAsync(418, 420, Regeln.Mindestanteil, _ => { sonden++; return Task.FromResult(85); }); Assert.Multiple(() => { Assert.That(kante, Is.EqualTo(420)); Assert.That(sonden, Is.Zero); }); } } /// Der Anteil dunkler Pixel, an gemessenen Werten. /// /// /// /// Wofür diese Prüfungen stehen. Gemessen wurde lange der /// Mittelwert der Helligkeit, und daran scheiterte die Erkennung /// wiederholt: 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. Zweimal nachgezogen, zweimal wieder getroffen. /// /// /// Der Anteil dunkler Pixel kennt dieses Problem nicht. Schrift /// belegt ein bis zehn Prozent der Fläche; der Rest bleibt schwarz. /// Nachgemessen an erzeugtem 1080p-Material mit zwei Spalten Namen, /// durch dieselbe ffmpeg-Kette wie im Betrieb /// (format=gray,blackframe=amount=0:threshold=24): /// /// /// Handlung: 0 % /// Abspann, schriftreich: 84 % /// Abspann, schlank: 87 % /// dunkle Nachtszene: 37 % /// /// /// Sechzig Prozent trennen das sauber, und zwar nach beiden Seiten. /// /// public class AbspannAnteilTests { private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe; /// Der Anfang des ersten Stücks — oder nichts. /// /// /// Die meisten Prüfungen hier fragen nur „wo fängt der Abspann an". /// Seit die Suche Stücke zurückgibt, steht das in [0].Von; ein /// Helfer hält die Erwartungen lesbar, statt überall zu indizieren. /// private static double? Anfang(IReadOnlyList stücke) => stücke.Count == 0 ? null : stücke[0].Von; /// Ein Suchfenster, wie es im Betrieb entsteht. /// /// /// Zwanzig Minuten vor dem Ende einer Folge von fünfundvierzig, an den /// Keyframesn abgetastet — also alle fünf Sekunden eine Probe. /// private static List Fenster( double abspannAb, double imAbspann, double imInhalt = 0) { var proben = new List(); for (var t = 1500.0; t <= 2700; t += 5) proben.Add(new Frameprobe(t, t < abspannAb ? imInhalt : imAbspann)); return proben; } /// Der schriftreiche Abspann — der Fall, an dem es scheiterte. [Test] public void Ein_dichter_Abspann_bei_vierundachtzig_Prozent_wird_gefunden() { var proben = Fenster(abspannAb: 2340, imAbspann: 84); Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.EqualTo(2340.0).Within(5)); } [Test] public void Ein_schlanker_Abspann_bei_siebenundachtzig_Prozent_auch() { var proben = Fenster(abspannAb: 2340, imAbspann: 87); Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.EqualTo(2340.0).Within(5)); } /// **Eine dunkle Nachtszene ist kein Abspann.** /// /// /// Mit dem Mittelwert war das die heikelste Grenze: eine Nachtszene /// misst 28, ein schlanker Abspann 19 — zwischen diesen neun Punkten /// musste die Schwelle liegen. Nach Pixelanteilen sind es 37 gegen 87, /// und die Frage stellt sich nicht mehr. /// [Test] public void Eine_duestere_Schlussszene_ist_kein_Abspann() { var proben = Fenster(abspannAb: 2340, imAbspann: 37, imInhalt: 12); Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.Null); } /// Und ein heller Film bis zum Ende bleibt Handlung. [Test] public void Ohne_dunkles_Ende_kein_Fund() { var proben = Fenster(abspannAb: double.MaxValue, imAbspann: 99); Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.Null); } } /// Was hinter dem Abspann noch kommt. /// /// /// Der Fehler, für den diese Prüfungen stehen. Die Suche verlangte, /// dass die allerletzte Probe dunkel ist. Hinter dem Abspann steht bei /// vielen Produktionen aber noch eine Logotafel — Sender, Studio, /// Förderer —, oft hell. Ein einziges weisses Schlussbild machte damit den /// ganzen Abspann unsichtbar. /// public class AbspannSchlusstafelTests { private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe; /// Der Anfang des ersten Stücks — oder nichts. /// /// /// Die meisten Prüfungen hier fragen nur „wo fängt der Abspann an". /// Seit die Suche Stücke zurückgibt, steht das in [0].Von; ein /// Helfer hält die Erwartungen lesbar, statt überall zu indizieren. /// private static double? Anfang(IReadOnlyList stücke) => stücke.Count == 0 ? null : stücke[0].Von; private static List Fenster(double abspannAb, double tafelAb = double.MaxValue) { var proben = new List(); for (var t = 1500.0; t <= 2700; t += 5) { var dunkel = t >= tafelAb ? 0 : t < abspannAb ? 0 : 85; proben.Add(new Frameprobe(t, dunkel)); } return proben; } /// Eine helle Tafel am Schluss verdeckt den Abspann nicht. [Test] public void Eine_Logotafel_am_Ende_zaehlt_nicht_dagegen() { // Fünfzehn Sekunden weiss hinter dem Abspann. var proben = Fenster(abspannAb: 2340, tafelAb: 2690); Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.EqualTo(2340).Within(5)); } /// Eine lange helle Strecke am Ende schon. /// /// /// Die Nachsicht hat eine Grenze, sonst fände die Suche hinter jeder /// Schwarzblende einen Abspann und hinge die halbe Handlung daran. /// [Test] public void Eine_lange_helle_Strecke_am_Ende_bricht_ab() { var proben = Fenster(abspannAb: 2340, tafelAb: 2600); Assert.That(Anfang(Abspannkante.Suchen(proben, Regeln)), Is.Null); } } /// Dieselbe Szene, zweierlei Keyframedichte. /// /// /// /// Der Fehler, für den diese Prüfungen stehen. Die Nachsicht /// gegenüber hellen Stellen mitten im Abspann war in Proben /// angegeben, nicht in Sekunden. Was das bedeutet, hängt allein daran, /// wie dicht eine Datei ihre Keyframes setzt — und das schwankt um /// eine Zehnerpotenz. /// /// /// Gemessen am Bestand: „(T)Raumschiff Surprise" liefert 1318 Proben auf /// zwanzig Minuten, also eine alle 0,9 Sekunden. Zwei Proben Nachsicht /// waren dort 1,8 Sekunden; bei einer Datei mit einem Keyframe alle /// zehn Sekunden waren dieselben zwei Proben zwanzig Sekunden. Ein /// fünfsekündiges Logo mitten im Abspann zerriss die Strecke deshalb nur /// bei der dicht abgetasteten Datei — und genau die fiel durch. /// /// public class AbspannNachsichtTests { private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe; /// Der Anfang des ersten Stücks — oder nichts. /// /// /// Die meisten Prüfungen hier fragen nur „wo fängt der Abspann an". /// Seit die Suche Stücke zurückgibt, steht das in [0].Von; ein /// Helfer hält die Erwartungen lesbar, statt überall zu indizieren. /// private static double? Anfang(IReadOnlyList stücke) => stücke.Count == 0 ? null : stücke[0].Von; /// Abspann ab 2340 s, darin fünf Sekunden helles Logo. private static List Fenster(double takt) { var proben = new List(); for (var t = 1500.0; t <= 2700; t += takt) { var imLogo = t >= 2500 && t < 2505; proben.Add(new Frameprobe(t, t < 2340 ? 0 : imLogo ? 5 : 85)); } return proben; } /// Dicht abgetastet — der Fall, der im Bestand durchfiel. [Test] public void Ein_Logo_zerreisst_den_Abspann_nicht_bei_dichten_Proben() { Assert.That(Anfang(Abspannkante.Suchen(Fenster(takt: 0.9), Regeln)), Is.EqualTo(2340.0).Within(2)); } /// Dünn abgetastet — derselbe Film, dieselbe Antwort. [Test] public void Und_auch_nicht_bei_duennen_Proben() { Assert.That(Anfang(Abspannkante.Suchen(Fenster(takt: 10), Regeln)), Is.EqualTo(2340.0).Within(11)); } /// Eine Minute helles Bild mitten im Abspann ist eine Szene. /// /// /// Früher brach die Suche hier ab und meldete nur das Stück /// dahinter. Genau daran scheiterten Marvel-Filme: hinter dem Abspann /// eine Post-Credit-Szene, davor der eigentliche Abspann — und /// gefunden wurde nur der kurze Rest, der dann als zu kurz durchfiel. /// [Test] public void Eine_Minute_hell_mittendrin_teilt_den_Abspann_in_zwei_Stuecke() { var proben = new List(); for (var t = 1500.0; t <= 2700; t += 1) { // Dunkel ab 2340, aber von 2400 bis 2460 eine Minute hell. var hell = t < 2340 || (t >= 2400 && t < 2460); proben.Add(new Frameprobe(t, hell ? 0 : 85)); } var stücke = Abspannkante.Suchen(proben, Regeln); Assert.Multiple(() => { Assert.That(stücke, Has.Count.EqualTo(2)); Assert.That(stücke[0].Von, Is.EqualTo(2340.0).Within(2)); Assert.That(stücke[0].Bis, Is.EqualTo(2399.0).Within(2)); Assert.That(stücke[1].Von, Is.EqualTo(2460.0).Within(2)); Assert.That(stücke[1].Bis, Is.EqualTo(2700.0).Within(2), "das letzte Stück reicht bis ans Dateiende"); }); } /// Und der Grund steht für das Protokoll bereit. [Test] public void Der_Grund_wird_benannt() { var hell = new List(); for (var t = 1500.0; t <= 2700; t += 5) hell.Add(new Frameprobe(t, 0)); Abspannkante.Suchen(hell, Regeln, out var grund); Assert.That(grund, Does.Contain("keine dunkle Strecke")); var kurz = new List(); for (var t = 1500.0; t <= 2700; t += 5) kurz.Add(new Frameprobe(t, t < 2680 ? 0 : 85)); Abspannkante.Suchen(kurz, Regeln, out var zuKurz); Assert.That(zuKurz, Does.Contain("zusammen nur")); } } /// Ein Abspann darf aus Stücken bestehen. /// /// /// /// Wofür diese Prüfungen stehen. 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. /// /// /// Die Antwort ist dieselbe: Stücke über die Lücke hinweg zusammenfassen /// und die Summe über die Mindestlänge entscheiden lassen. /// /// public class AbspannstueckeTests { private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe; /// Eine Reihe im Sekundentakt, dunkel in den angegebenen Spannen. private static List Reihe(double bis, params (double Von, double Bis)[] dunkel) { var proben = new List(); for (var t = 0.0; t <= bis; t += 1) { var istDunkel = Array.Exists(dunkel, d => t >= d.Von && t < d.Bis); proben.Add(new Frameprobe(t, istDunkel ? 85 : 0)); } return proben; } /// Der Marvel-Fall: Abspann, Szene, Abspann. [Test] public void Zwei_Stuecke_mit_einer_Szene_dazwischen() { // 180 s Abspann, 90 s Szene, 60 s Abspann bis zum Ende. var stücke = Abspannkante.Suchen(Reihe(600, (330, 510), (540, 601)), Regeln); Assert.Multiple(() => { Assert.That(stücke, Has.Count.EqualTo(2)); Assert.That(stücke[0].Von, Is.EqualTo(330.0).Within(2)); Assert.That(stücke[1].Von, Is.EqualTo(540.0).Within(2)); Assert.That(stücke[1].Bis, Is.EqualTo(600.0).Within(2)); }); } /// **Die Summe entscheidet, nicht das einzelne Stück.** /// /// /// Genau hier scheiterte es vorher: 30 s plus 30 s sind zusammen 60 s /// und damit ein Abspann, einzeln aber zweimal zu wenig. /// [Test] public void Zwei_kurze_Stuecke_ergeben_zusammen_einen_Abspann() { var stücke = Abspannkante.Suchen(Reihe(600, (450, 480), (570, 601)), Regeln); Assert.Multiple(() => { Assert.That(stücke, Has.Count.EqualTo(2)); Assert.That(stücke.Sum(t => t.Länge), Is.GreaterThanOrEqualTo(Regeln.Mindestlänge)); }); } /// Eine zu lange Lücke trennt — dann zählt nur das letzte Stück. /// /// /// Sonst liefe die Suche rückwärts durch jede Nachtszene und hinge den /// halben Film an den Abspann. Hinter einer Lücke von fünf Minuten /// bleibt hier ein Stück von 30 s übrig, und das ist zu kurz. /// [Test] public void Eine_zu_lange_Luecke_trennt() { var stücke = Abspannkante.Suchen(Reihe(900, (200, 400), (870, 901)), Regeln); Assert.That(stücke, Is.Empty); } /// Ein zu kurzes Stück zwischen zwei gültigen zählt nicht mit. [Test] public void Ein_Stueck_von_zehn_Sekunden_faellt_heraus() { // Das mittlere Stück ist 10 s lang und damit unter `Mindeststück`. var stücke = Abspannkante.Suchen( Reihe(600, (300, 360), (450, 460), (540, 601)), Regeln); Assert.That(stücke, Has.Count.EqualTo(2), "das Zehnsekundenstück gehört nicht dazu"); } /// Und ein durchgehender Abspann bleibt ein Stück. /// /// /// Die wichtigste Prüfung dieser Gruppe: der Normalfall darf sich durch /// die Stückelung nicht verändern. /// [Test] public void Ein_durchgehender_Abspann_bleibt_ein_Stueck() { var stücke = Abspannkante.Suchen(Reihe(600, (420, 601)), Regeln); Assert.Multiple(() => { Assert.That(stücke, Has.Count.EqualTo(1)); Assert.That(stücke[0].Von, Is.EqualTo(420.0).Within(2)); Assert.That(stücke[0].Bis, Is.EqualTo(600.0).Within(2)); }); } }