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>
264 lines
9.3 KiB
C#
264 lines
9.3 KiB
C#
using System.Diagnostics;
|
||
using Flaro.Core.Media;
|
||
using Flaro.Media;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
|
||
namespace Flaro.Media.Tests;
|
||
|
||
/// <summary>Die Ausgabe des Abtasters lesen — ohne ffmpeg.</summary>
|
||
public class BildabtasterLesenTests
|
||
{
|
||
[Test]
|
||
public void Zeit_und_Wert_werden_zu_Proben()
|
||
{
|
||
var ausgabe = """
|
||
frame:0 pts:30720 pts_time:2.5
|
||
lavfi.blackframe.pblack=3
|
||
frame:1 pts:61440 pts_time:5
|
||
lavfi.blackframe.pblack=84
|
||
|
||
""";
|
||
|
||
var proben = Bildabtaster.Lesen(ausgabe, versatz: 60);
|
||
|
||
Assert.That(proben, Is.EqualTo(new[]
|
||
{
|
||
new Bildprobe(62.5, 3),
|
||
new Bildprobe(65, 84),
|
||
}));
|
||
}
|
||
|
||
/// <summary>Ohne Zeitstempel kein Messpunkt.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// Eine Wertzeile ohne vorangehende Zeitzeile gehört zu keinem
|
||
/// Zeitpunkt. Sie trotzdem zu nehmen hiesse, sie der vorigen Zeit
|
||
/// zuzuschlagen — und damit eine Messung zu erfinden.
|
||
/// </remarks>
|
||
[Test]
|
||
public void Ein_Wert_ohne_Zeit_faellt_heraus()
|
||
{
|
||
Assert.That(Bildabtaster.Lesen("lavfi.blackframe.pblack=84\n", 0), Is.Empty);
|
||
}
|
||
|
||
[Test]
|
||
public void Leere_Ausgabe_ergibt_nichts()
|
||
{
|
||
Assert.That(Bildabtaster.Lesen("", 0), Is.Empty);
|
||
}
|
||
}
|
||
|
||
/// <summary>Den ganzen Weg an einer erzeugten Datei.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Die einzige Prüfung hier, die ffmpeg braucht</b> — und sie ist es
|
||
/// wert: zwischen „der Filtergraph ist richtig geschrieben" und „die
|
||
/// Zahlen kommen an der richtigen Stelle heraus" liegt alles, was man
|
||
/// sonst erst am Bestand merkt.
|
||
/// </para>
|
||
/// <para>
|
||
/// Die Datei entsteht im Test: 90 Sekunden Bild, danach 60 Sekunden weisse
|
||
/// Schrift auf schwarzem Grund. Fehlt ffmpeg, wird übersprungen statt
|
||
/// rotgemeldet — ein fehlendes Werkzeug ist kein Fehler im Quelltext.
|
||
/// </para>
|
||
/// </remarks>
|
||
public class BildabtasterLaufTests
|
||
{
|
||
private string _ordner = "";
|
||
private string _datei = "";
|
||
|
||
[OneTimeSetUp]
|
||
public void Erzeugen()
|
||
{
|
||
_ordner = Directory.CreateDirectory(Path.Combine(
|
||
Path.GetTempPath(), "flaro-abspann-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||
_datei = Path.Combine(_ordner, "pruef.mp4");
|
||
|
||
var start = new ProcessStartInfo("ffmpeg")
|
||
{
|
||
ArgumentList =
|
||
{
|
||
"-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",
|
||
// **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",
|
||
"-g", "48", "-pix_fmt", "yuv420p", _datei,
|
||
},
|
||
RedirectStandardError = true,
|
||
UseShellExecute = false,
|
||
};
|
||
|
||
try
|
||
{
|
||
using var prozess = Process.Start(start);
|
||
prozess!.WaitForExit(60_000);
|
||
if (prozess.ExitCode != 0 || !File.Exists(_datei))
|
||
Assert.Ignore("ffmpeg konnte die Prüfdatei nicht erzeugen.");
|
||
}
|
||
catch (Exception)
|
||
{
|
||
Assert.Ignore("Ohne ffmpeg lässt sich das nicht prüfen.");
|
||
}
|
||
}
|
||
|
||
[OneTimeTearDown]
|
||
public void Aufraeumen()
|
||
{
|
||
try { Directory.Delete(_ordner, recursive: true); } catch (IOException) { }
|
||
}
|
||
|
||
private static Bildabtaster Abtaster() =>
|
||
new(FfmpegOptions.FromEnvironment(), NullLogger<Bildabtaster>.Instance);
|
||
|
||
/// <summary>Die Kante liegt da, wo sie erzeugt wurde: bei 90 Sekunden.</summary>
|
||
[Test]
|
||
public async Task Der_Abspann_wird_gefunden()
|
||
{
|
||
var proben = await Abtaster().UeberblickAsync(_datei, ab: 30, dauer: 120);
|
||
Assert.That(proben, Is.Not.Empty, "der Filtergraph muss Zahlen liefern");
|
||
|
||
var stücke = Abspannkante.Suchen(proben, Abspannregeln.Vorgabe);
|
||
Assert.That(stücke, Is.Not.Empty, "eine dunkle Strecke bis zum Ende muss auffallen");
|
||
|
||
var grob = stücke[0].Von;
|
||
var hell = Abspannkante.HellDavor(proben, grob, Abspannregeln.Vorgabe) ?? 0;
|
||
var abtaster = Abtaster();
|
||
var kante = await Abspannkante.VerfeinernAsync(
|
||
hell, grob, Abspannregeln.Vorgabe.Mindestanteil,
|
||
stelle => abtaster.DunkelanteilAsync(_datei, stelle));
|
||
|
||
Assert.That(kante, Is.EqualTo(90).Within(4),
|
||
"erzeugt wurde der Übergang bei genau 90 Sekunden");
|
||
}
|
||
|
||
/// <summary>Im hellen Teil gibt es nichts zu finden.</summary>
|
||
[Test]
|
||
public async Task Im_hellen_Teil_kein_Fund()
|
||
{
|
||
var proben = await Abtaster().UeberblickAsync(_datei, ab: 0, dauer: 80);
|
||
|
||
Assert.That(Abspannkante.Suchen(proben, Abspannregeln.Vorgabe), Is.Empty);
|
||
}
|
||
|
||
/// <summary>Eine einzelne Sekunde lässt sich messen — und liegt richtig.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Hier wurde der alte Fehler sichtbar.</b> Gemessen wurde lange
|
||
/// der Mittelwert der Helligkeit; diese Prüfung verlangte, dass der
|
||
/// Abspann unter einer festen Schwelle von 35 liegt. Mit dem schmalen
|
||
/// Balken von damals lag er bei 18, und alles schien zu stimmen — mit
|
||
/// realistischer Schriftdichte bei 40. Die feste Schwelle hielt
|
||
/// diesen Abspann also für Handlung, und genau das geschah im
|
||
/// Bestand.
|
||
/// </para>
|
||
/// <para>
|
||
/// Nach Pixelanteilen stellt sich die Frage nicht mehr: gemessen
|
||
/// werden hier rund 90 Prozent im Abspann gegen null in der Handlung.
|
||
/// </para>
|
||
/// </remarks>
|
||
[Test]
|
||
public async Task Eine_Sekunde_im_Abspann_ist_fast_ganz_dunkel()
|
||
{
|
||
var noetig = Abspannregeln.Vorgabe.Mindestanteil;
|
||
|
||
var dunkel = await Abtaster().DunkelanteilAsync(_datei, bei: 120);
|
||
var hell = await Abtaster().DunkelanteilAsync(_datei, bei: 40);
|
||
|
||
Assert.Multiple(() =>
|
||
{
|
||
Assert.That(dunkel, Is.Not.Null.And.GreaterThan(noetig));
|
||
Assert.That(hell, Is.Not.Null.And.LessThan(noetig));
|
||
});
|
||
}
|
||
}
|
||
|
||
/// <summary>Derselbe Dunkelanteil, gleich welche Bittiefe die Datei hat.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Der Fehler, für den diese Prüfung steht.</b> <c>signalstats</c>
|
||
/// rechnet in der Skala des Bildformats: bei acht Bit 0–255, bei zehn Bit
|
||
/// 0–1023. Alle Schwellen der Abspannsuche sind an acht Bit gemessen —
|
||
/// bei einer 10-Bit-Datei bedeutete dieselbe Zahl ein Viertel.
|
||
/// </para>
|
||
/// <para>
|
||
/// Aufgefallen ist es an einer Protokollzeile: „Schwelle 247,6". Die kann
|
||
/// bei 0–255 nicht entstehen, denn sie ist Grundton mal 0,55 — das wären
|
||
/// 450 gewesen.
|
||
/// </para>
|
||
/// </remarks>
|
||
public class BildabtasterBittiefeTests
|
||
{
|
||
private string _ordner = "";
|
||
|
||
[OneTimeSetUp]
|
||
public void Erzeugen() =>
|
||
_ordner = Directory.CreateDirectory(Path.Combine(
|
||
Path.GetTempPath(), "flaro-bittiefe-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||
|
||
[OneTimeTearDown]
|
||
public void Aufraeumen()
|
||
{
|
||
try { Directory.Delete(_ordner, recursive: true); } catch (IOException) { }
|
||
}
|
||
|
||
private string Erzeugen(string pixelformat)
|
||
{
|
||
var datei = Path.Combine(_ordner, $"{pixelformat}.mp4");
|
||
var start = new ProcessStartInfo("ffmpeg")
|
||
{
|
||
ArgumentList =
|
||
{
|
||
"-hide_banner", "-loglevel", "error", "-y",
|
||
"-f", "lavfi", "-i", "testsrc2=size=320x180:rate=24:duration=4",
|
||
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", pixelformat, datei,
|
||
},
|
||
RedirectStandardError = true,
|
||
UseShellExecute = false,
|
||
};
|
||
|
||
try
|
||
{
|
||
using var prozess = Process.Start(start);
|
||
prozess!.WaitForExit(60_000);
|
||
if (prozess.ExitCode != 0 || !File.Exists(datei))
|
||
Assert.Ignore($"ffmpeg kann {pixelformat} hier nicht erzeugen.");
|
||
}
|
||
catch (Exception)
|
||
{
|
||
Assert.Ignore("Ohne ffmpeg lässt sich das nicht prüfen.");
|
||
}
|
||
|
||
return datei;
|
||
}
|
||
|
||
[Test]
|
||
public async Task Acht_und_zehn_Bit_ergeben_dieselbe_Zahl()
|
||
{
|
||
var abtaster = new Bildabtaster(
|
||
FfmpegOptions.FromEnvironment(), NullLogger<Bildabtaster>.Instance);
|
||
|
||
var acht = await abtaster.DunkelanteilAsync(Erzeugen("yuv420p"), bei: 2);
|
||
var zehn = await abtaster.DunkelanteilAsync(Erzeugen("yuv420p10le"), bei: 2);
|
||
|
||
Assert.Multiple(() =>
|
||
{
|
||
Assert.That(acht, Is.Not.Null);
|
||
Assert.That(zehn, Is.Not.Null.And.EqualTo(acht!.Value).Within(3),
|
||
"ohne `format=gray` stünde hier das Vierfache");
|
||
Assert.That(zehn, Is.LessThan(256), "die Skala muss 0–255 sein");
|
||
});
|
||
}
|
||
}
|