diff --git a/server/src/Flaro.Api/Markenerkennung.cs b/server/src/Flaro.Api/Markenerkennung.cs index ac08109..cc0b75d 100644 --- a/server/src/Flaro.Api/Markenerkennung.cs +++ b/server/src/Flaro.Api/Markenerkennung.cs @@ -299,11 +299,25 @@ public sealed class Markenerkennung( var proben = await abtaster.UeberblickAsync(pfad, ab, laufzeit - ab, ct); var regeln = Abspannregeln.Vorgabe; - if (Abspannkante.Suchen(proben, regeln) is not { } stelle) return false; + + // **Ein Lauf ohne Fund soll sagen, warum.** „Kein Abspann erkannt" + // allein ist keine Auskunft: ob die Datei zu wenige Schlüsselbilder + // hat, ob der Abspann zu hell war oder ob am Schluss eine Tafel + // steht, sieht man nur an diesen drei Zahlen — und sonst gar nicht. + if (Abspannkante.Suchen(proben, regeln) is not { } stelle) + { + log.LogInformation( + "Kein Abspann in {Pfad}: {Proben} Proben, Schwelle {Schwelle:0.#}, " + + "letzte Probe {Letzte:0.#}.", + pfad, proben.Count, Abspannkante.Schwelle(proben, regeln), + proben.Count > 0 ? proben[^1].Helligkeit : double.NaN); + return false; + } var hell = Abspannkante.HellDavor(proben, stelle, regeln) ?? ab; var kante = await Abspannkante.VerfeinernAsync( - hell, stelle, regeln, wo => abtaster.HelligkeitAsync(pfad, wo, ct)); + hell, stelle, Abspannkante.Schwelle(proben, regeln), + wo => abtaster.HelligkeitAsync(pfad, wo, ct)); return await SchreibenAsync(db, titelId, istFilm, Markenart.Abspann, kante, laufzeit, ct); diff --git a/server/src/Flaro.Core/Media/Abspannkante.cs b/server/src/Flaro.Core/Media/Abspannkante.cs index 954f3b7..6e1f6f9 100644 --- a/server/src/Flaro.Core/Media/Abspannkante.cs +++ b/server/src/Flaro.Core/Media/Abspannkante.cs @@ -15,14 +15,37 @@ public readonly record struct Bildprobe(double Sekunde, double Helligkeit); /// public readonly record struct Abspannregeln { - /// Ab wann ein Bild als dunkel gilt (0–255). + /// Die unterste Schwelle, unter der immer „dunkel" gilt (0–255). /// /// - /// Gemessen an einem erzeugten Film: Handlung 125, Abspann 18. - /// Fünfunddreissig liegt weit von beiden. + /// Nur noch der Boden, nicht mehr die Schwelle. Sie stand lange + /// allein da, und das war falsch gemessen: das Prüfbild hatte 0,8 + /// Prozent weisse Fläche und kam auf 18. Ein echter Abspann hat mehr + /// Schrift. Nachgemessen an 1080p mit zwei Spalten Namen: bei + /// realistischer Schriftdichte 32, bei einem dichten Abspann 47. Die + /// Fünfunddreissig lag also mitten zwischen den Fällen und schnitt + /// sie auseinander. /// public double Dunkelschwelle { get; init; } + /// Wie dunkel eine Strecke gegenüber dem Film sein muss. + /// + /// + /// + /// Dunkel ist keine absolute Grösse. Entscheidend ist nicht, wie + /// dunkel der Abspann ist, sondern dass er viel dunkler ist als das, + /// was davor lief — und das misst sich am Film selbst. Ein heller Film + /// (Grundton 126) erkennt damit auch einen schriftreichen Abspann bei + /// 47; ein durchweg dunkler Film rutscht nicht in Fehlalarme, weil + /// die Schwelle nie unter fällt. + /// + /// + /// 0,55 heisst: knapp unter die Hälfte. Bei 126 ergibt das 69 — weit + /// über beiden gemessenen Abspannwerten und weit unter dem Inhalt. + /// + /// + public double Anteil { get; init; } + /// Wie viele helle Proben am Stück eine Strecke nicht brechen. /// /// @@ -32,13 +55,26 @@ public readonly record struct Abspannregeln /// public int Lücke { 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; } + public double Mindestlänge { get; init; } public double Höchstlänge { get; init; } public static Abspannregeln Vorgabe => new() { Dunkelschwelle = 35, + Anteil = 0.55, Lücke = 2, + Schlusstafel = 20, Mindestlänge = 45, Höchstlänge = 12 * 60, }; @@ -74,21 +110,62 @@ public static class Abspannkante /// Messpunkte über das Ende des Films, aufsteigend nach Zeit. Sie dürfen /// ungleichmässig liegen: Schlüsselbilder tun das. /// + /// Ab welcher Helligkeit eine Probe in diesem Film dunkel ist. + /// + /// + /// + /// Das obere Viertel als Grundton. Nicht der Mittelwert: der + /// zöge den Abspann selbst mit hinein. Nicht der Höchstwert: ein + /// einzelner Blitz verschöbe alles. Drei Viertel ist robust, solange + /// der Abspann nicht das ganze Fenster füllt — und das kann er nicht, + /// lässt ihm höchstens gut die + /// Hälfte davon. + /// + /// + /// Öffentlich, weil das Nachmessen dieselbe Schwelle braucht. Zweimal + /// gerechnet wären es zwei Schwellen, und die Kante läge woanders als + /// der Fund. + /// + /// + public static double Schwelle(IReadOnlyList proben, Abspannregeln regeln) + { + if (proben.Count == 0) return regeln.Dunkelschwelle; + + var sortiert = proben.Select(p => p.Helligkeit).OrderBy(h => h).ToArray(); + var grundton = sortiert[Math.Min(sortiert.Length - 1, (int)(sortiert.Length * 0.75))]; + + return Math.Max(regeln.Dunkelschwelle, grundton * regeln.Anteil); + } + public static double? Suchen(IReadOnlyList proben, Abspannregeln regeln) { if (proben.Count < 2) return null; + var schwelle = Schwelle(proben, regeln); // **Von hinten.** Der Abspann endet am Dateiende; alles, was davor // dunkel war und wieder hell wurde, war eine Schwarzblende. + // + // **Das allerletzte Bild muss aber nicht dunkel sein.** Hinter dem + // Abspann steht bei vielen Produktionen noch eine Logotafel — + // Sender, Studio, Förderer —, oft hell und ein paar Sekunden lang. + // Darauf zu bestehen, dass die letzte Probe dunkel ist, hiess: ein + // einziges weisses Schlussbild macht den ganzen Abspann unsichtbar. + // Dieselbe Nachsicht wie innerhalb der Strecke, also `Lücke`. var letzte = proben.Count - 1; - if (proben[letzte].Helligkeit >= regeln.Dunkelschwelle) return null; + var ende = proben[letzte].Sekunde; + while (letzte > 0 && proben[letzte].Helligkeit >= schwelle) + { + if (ende - proben[letzte].Sekunde >= regeln.Schlusstafel) return null; + letzte--; + } + if (proben[letzte].Helligkeit >= schwelle) return null; var anfang = letzte; var hellAmStück = 0; for (var i = letzte - 1; i >= 0; i--) { - if (proben[i].Helligkeit < regeln.Dunkelschwelle) + if (proben[i].Helligkeit < schwelle) { anfang = i; hellAmStück = 0; @@ -112,11 +189,12 @@ public static class Abspannkante public static double? HellDavor( IReadOnlyList proben, double dunkelAb, Abspannregeln regeln) { + var schwelle = Schwelle(proben, regeln); double? letzteHelle = null; foreach (var p in proben) { if (p.Sekunde >= dunkelAb) break; - if (p.Helligkeit >= regeln.Dunkelschwelle) letzteHelle = p.Sekunde; + if (p.Helligkeit >= schwelle) letzteHelle = p.Sekunde; } return letzteHelle; } @@ -139,7 +217,7 @@ public static class Abspannkante /// /// public static async Task VerfeinernAsync( - double hell, double dunkel, Abspannregeln regeln, + double hell, double dunkel, double schwelle, Func> messen, double genauigkeit = 4) { if (genauigkeit <= 0) genauigkeit = 1; @@ -150,7 +228,7 @@ public static class Abspannkante var wert = await messen(mitte); if (wert is not { } helligkeit) return dunkel; - if (helligkeit < regeln.Dunkelschwelle) dunkel = mitte; + if (helligkeit < schwelle) dunkel = mitte; else hell = mitte; } diff --git a/server/tests/Flaro.Core.Tests/AbspannkanteTests.cs b/server/tests/Flaro.Core.Tests/AbspannkanteTests.cs index 0bbc4e8..21407a5 100644 --- a/server/tests/Flaro.Core.Tests/AbspannkanteTests.cs +++ b/server/tests/Flaro.Core.Tests/AbspannkanteTests.cs @@ -114,7 +114,7 @@ public class AbspannkanteTests const double echteKante = 417.3; var sonden = 0; - var kante = await Abspannkante.VerfeinernAsync(410, 420, Regeln, stelle => + var kante = await Abspannkante.VerfeinernAsync(410, 420, Regeln.Dunkelschwelle, stelle => { sonden++; return Task.FromResult(stelle >= echteKante ? 18 : 125); @@ -134,7 +134,7 @@ public class AbspannkanteTests public async Task Ohne_Antwort_bleibt_es_beim_groben_Fund() { var kante = await Abspannkante.VerfeinernAsync( - 410, 420, Regeln, _ => Task.FromResult(null)); + 410, 420, Regeln.Dunkelschwelle, _ => Task.FromResult(null)); Assert.That(kante, Is.EqualTo(420)); } @@ -144,7 +144,7 @@ public class AbspannkanteTests public async Task Ein_enger_Bereich_wird_nicht_angefasst() { var sonden = 0; - var kante = await Abspannkante.VerfeinernAsync(418, 420, Regeln, _ => + var kante = await Abspannkante.VerfeinernAsync(418, 420, Regeln.Dunkelschwelle, _ => { sonden++; return Task.FromResult(18); @@ -157,3 +157,156 @@ public class AbspannkanteTests }); } } + +/// Die Schwelle an gemessenen Abspannwerten, nicht an erfundenen. +/// +/// +/// +/// Der Fehler, für den diese Prüfungen stehen. Die Erkennung lief +/// gegen eine feste Schwelle von 35, und die stammte aus einem Prüfbild +/// mit 0,8 Prozent weisser Fläche — Helligkeit 18. Ein echter Abspann hat +/// mehr Schrift. +/// +/// +/// Nachgemessen an erzeugtem 1080p-Material mit zwei Spalten Namen, +/// durch dieselbe ffmpeg-Kette wie im Betrieb +/// (scale=160:-2,signalstats): +/// +/// +/// Handlung: 126 +/// Abspann, realistische Schriftdichte (1,3 % Weiss): 32 +/// Abspann, dicht gesetzt (9,5 % Weiss): 47 +/// +/// +/// Die Fünfunddreissig lag also mitten zwischen den beiden Abspannfällen. +/// Seitdem ist sie nur noch der Boden; gemessen wird gegen den Grundton +/// des Films. +/// +/// +public class AbspannSchwellenTests +{ + private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe; + + /// Ein Suchfenster, wie es im Betrieb entsteht. + /// + /// + /// Zwanzig Minuten vor dem Ende einer Folge von fünfundvierzig, an den + /// Schlüsselbildern abgetastet — also alle fünf Sekunden eine Probe. + /// + private static List Fenster(double abspannAb, double imAbspann, double imInhalt = 126) + { + var proben = new List(); + for (var t = 1500.0; t <= 2700; t += 5) + proben.Add(new Bildprobe(t, t < abspannAb ? imInhalt : imAbspann)); + return proben; + } + + /// Der Fall, an dem es scheiterte: ein schriftreicher Abspann. + /// + /// + /// Mit der alten festen Schwelle von 35 ist diese Prüfung rot: 47 gilt + /// dort als hell, und die Folge bekam keine Marke. + /// + [Test] + public void Ein_dichter_Abspann_bei_siebenundvierzig_wird_gefunden() + { + var proben = Fenster(abspannAb: 2340, imAbspann: 47); + + Assert.That(Abspannkante.Suchen(proben, Regeln), Is.EqualTo(2340).Within(5)); + } + + [Test] + public void Ein_schlanker_Abspann_bei_zweiunddreissig_wird_gefunden() + { + var proben = Fenster(abspannAb: 2340, imAbspann: 32); + + Assert.That(Abspannkante.Suchen(proben, Regeln), Is.EqualTo(2340).Within(5)); + } + + /// Und die Schwelle liegt zwischen beidem, nicht daneben. + [Test] + public void Die_Schwelle_liegt_zwischen_Handlung_und_Abspann() + { + var schwelle = Abspannkante.Schwelle(Fenster(2340, 47), Regeln); + + Assert.Multiple(() => + { + Assert.That(schwelle, Is.GreaterThan(47), "ein dichter Abspann muss darunter liegen"); + Assert.That(schwelle, Is.LessThan(126), "die Handlung muss darüber liegen"); + }); + } + + /// **Ein dunkler Film wird dadurch nicht zum Dauerabspann.** + /// + /// + /// Eine relative Schwelle kann nach unten entgleisen: bei einem Film, + /// der durchweg dämmrig ist, wäre „etwas mehr als halb so hell" bald + /// jede Nachtszene. Deshalb bleibt Dunkelschwelle als Boden — + /// hier sorgt sie dafür, dass eine Schlussszene bei 40 nichts auslöst. + /// + [Test] + public void Eine_duestere_Schlussszene_ist_kein_Abspann() + { + var proben = Fenster(abspannAb: 2340, imAbspann: 40, imInhalt: 55); + + Assert.That(Abspannkante.Suchen(proben, Regeln), Is.Null); + } + + /// Helle Handlung bis zum Ende bleibt Handlung. + [Test] + public void Ohne_dunkles_Ende_kein_Fund() + { + var proben = Fenster(abspannAb: double.MaxValue, imAbspann: 0); + + Assert.That(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; + + private static List Fenster(double abspannAb, double tafelAb = double.MaxValue) + { + var proben = new List(); + for (var t = 1500.0; t <= 2700; t += 5) + { + var hell = t >= tafelAb ? 210 : t < abspannAb ? 126 : 32; + proben.Add(new Bildprobe(t, hell)); + } + 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(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(Abspannkante.Suchen(proben, Regeln), Is.Null); + } +} diff --git a/server/tests/Flaro.Media.Tests/BildabtasterTests.cs b/server/tests/Flaro.Media.Tests/BildabtasterTests.cs index 6605000..de6c2ec 100644 --- a/server/tests/Flaro.Media.Tests/BildabtasterTests.cs +++ b/server/tests/Flaro.Media.Tests/BildabtasterTests.cs @@ -82,7 +82,13 @@ public class BildabtasterLaufTests "-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=640x360:rate=24:duration=90", "-f", "lavfi", "-i", "color=c=black:s=640x360:r=24:d=60", - "-f", "lavfi", "-i", "color=c=white:s=180x14:r=24:d=60", + // **So viel weisse Fläche hat ein echter Abspann.** + // Nachgemessen an 1080p mit zwei Spalten Namen: rund ein + // Zehntel der Fläche, und damit YAVG um 45 statt um 18. + // Mit dem schmalen Balken von vorher war dieses Prüfbild + // dunkler als jeder wirkliche Abspann — und genau deshalb + // blieb die zu enge Schwelle so lange unbemerkt. + "-f", "lavfi", "-i", "color=c=white:s=400x76:r=24:d=60", "-filter_complex", "[1][2]overlay=x=230:y='H-mod(t*45,H+h)'[cr];[0][cr]concat=n=2:v=1:a=0[v]", "-map", "[v]", "-c:v", "libx264", "-preset", "ultrafast", @@ -127,7 +133,7 @@ public class BildabtasterLaufTests var hell = Abspannkante.HellDavor(proben, grob!.Value, Abspannregeln.Vorgabe) ?? 0; var abtaster = Abtaster(); var kante = await Abspannkante.VerfeinernAsync( - hell, grob.Value, Abspannregeln.Vorgabe, + hell, grob.Value, Abspannkante.Schwelle(proben, Abspannregeln.Vorgabe), stelle => abtaster.HelligkeitAsync(_datei, stelle)); Assert.That(kante, Is.EqualTo(90).Within(4), @@ -143,17 +149,38 @@ public class BildabtasterLaufTests Assert.That(Abspannkante.Suchen(proben, Abspannregeln.Vorgabe), Is.Null); } - /// Eine einzelne Sekunde lässt sich messen. + /// Eine einzelne Sekunde lässt sich messen — und liegt richtig. + /// + /// + /// + /// Hier stand der Fehler, und hier wurde er sichtbar. Diese + /// Prüfung verlangte früher, dass der Abspann unter der festen + /// Schwelle von 35 liegt — mit dem schmalen Balken von damals lag er + /// bei 18, und alles schien zu stimmen. Mit realistischer + /// Schriftdichte misst dieselbe Stelle 40. Die feste Schwelle + /// hätte diesen Abspann also für Handlung gehalten, und genau das + /// geschah im Bestand. + /// + /// + /// Gemessen wird deshalb gegen die Schwelle, die der Film selbst + /// vorgibt — und die liegt hier zwischen 40 und der Handlung. + /// + /// [Test] - public async Task Eine_Sekunde_im_Abspann_ist_dunkel() + public async Task Eine_Sekunde_im_Abspann_liegt_unter_der_Schwelle() { + var proben = await Abtaster().UeberblickAsync(_datei, ab: 30, dauer: 120); + var schwelle = Abspannkante.Schwelle(proben, Abspannregeln.Vorgabe); + var dunkel = await Abtaster().HelligkeitAsync(_datei, bei: 120); var hell = await Abtaster().HelligkeitAsync(_datei, bei: 40); Assert.Multiple(() => { - Assert.That(dunkel, Is.Not.Null.And.LessThan(Abspannregeln.Vorgabe.Dunkelschwelle)); - Assert.That(hell, Is.Not.Null.And.GreaterThan(Abspannregeln.Vorgabe.Dunkelschwelle)); + Assert.That(dunkel, Is.Not.Null.And.LessThan(schwelle)); + Assert.That(hell, Is.Not.Null.And.GreaterThan(schwelle)); + Assert.That(dunkel, Is.GreaterThan(Abspannregeln.Vorgabe.Dunkelschwelle), + "dieser Abspann ist heller als der alte feste Wert — darum ging es"); }); } }