using System.Diagnostics;
using Flaro.Core.Media;
using Flaro.Media;
using Microsoft.Extensions.Logging.Abstractions;
namespace Flaro.Media.Tests;
/// Die Ausgabe des Abtasters lesen — ohne ffmpeg.
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),
}));
}
/// Ohne Zeitstempel kein Messpunkt.
///
///
/// 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.
///
[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);
}
}
/// Den ganzen Weg an einer erzeugten Datei.
///
///
///
/// Die einzige Prüfung hier, die ffmpeg braucht — 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.
///
///
/// 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.
///
///
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.Instance);
/// Die Kante liegt da, wo sie erzeugt wurde: bei 90 Sekunden.
[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 grob = Abspannkante.Suchen(proben, Abspannregeln.Vorgabe);
Assert.That(grob, Is.Not.Null, "eine dunkle Strecke bis zum Ende muss auffallen");
var hell = Abspannkante.HellDavor(proben, grob!.Value, Abspannregeln.Vorgabe) ?? 0;
var abtaster = Abtaster();
var kante = await Abspannkante.VerfeinernAsync(
hell, grob.Value, Abspannregeln.Vorgabe.Mindestanteil,
stelle => abtaster.DunkelanteilAsync(_datei, stelle));
Assert.That(kante, Is.EqualTo(90).Within(4),
"erzeugt wurde der Übergang bei genau 90 Sekunden");
}
/// Im hellen Teil gibt es nichts zu finden.
[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.Null);
}
/// Eine einzelne Sekunde lässt sich messen — und liegt richtig.
///
///
///
/// Hier wurde der alte Fehler sichtbar. 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.
///
///
/// Nach Pixelanteilen stellt sich die Frage nicht mehr: gemessen
/// werden hier rund 90 Prozent im Abspann gegen null in der Handlung.
///
///
[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));
});
}
}
/// Derselbe Dunkelanteil, gleich welche Bittiefe die Datei hat.
///
///
///
/// Der Fehler, für den diese Prüfung steht. signalstats
/// 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.
///
///
/// 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.
///
///
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.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");
});
}
}