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