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