`log.LogInformation("… {Folgen} … {Folgen}.", a, b, c, d, e)` — sechs
Platzhalter, fünf Argumente. Vorlagen sind positionsbezogen: auch ein
wiederholter Name verbraucht ein weiteres Argument. Der Logger warf eine
Ausnahme, die riss `StaffelMeldenAsync` mit, und die wiederum den ganzen
Serienlauf. Gefunden war dann alles umsonst — „A Knight of the Seven
Kingdoms" und „Alice in Borderland" haben ihre Marken deshalb verloren,
nachdem sie längst berechnet waren.
**CA2017 hatte das gemeldet.** Als Warnung, unter hundert anderen Zeilen,
und ich habe beim Bauen nach „warning CS" gegriffen. Eine neue
`Directory.Build.props` macht genau diese eine Regel zum Fehler — nicht
alle Warnungen, das wäre eine andere Entscheidung. Gegengeprüft: mit der
kaputten Zeile bricht der Bau jetzt ab.
**Und der zweite Fund, aus „Alice im Wunderland".** Die Zeile meldete
„Schwelle 247,6", und das kann bei 0–255 nicht entstehen: die Schwelle
ist Grundton mal 0,55, das wären 450 gewesen. `signalstats` rechnet in
der Skala des Bildformats — bei acht Bit 0–255, bei zehn Bit 0–1023.
Gemessen am selben erzeugten Bild: YAVG 127 gegen YAVG 500. Alle
Schwellen hier sind an acht Bit gemessen; bei einer 10-Bit-Datei bedeutete
dieselbe Zahl ein Viertel.
`format=gray` vor `signalstats` bringt jede Datei auf dieselbe Skala. Es
rechnet dabei auch den Fernsehbereich 16–235 auf 0–255 um — linear, und
die dunklen Werte trifft das am stärksten: der gemessene Abspann liegt
jetzt bei 28 statt bei 40. Der Boden wandert entsprechend von 35 auf 22.
Dazu: die Schlusszeile je Serie trennt Vorspann und Abspann, statt eine
Zahl aus beidem zu machen — „14 Marken" beantwortet nicht, ob die Serie
ihre Vorspänne bekommen hat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
266 lines
9.5 KiB
C#
266 lines
9.5 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.signalstats.YAVG=125.065
|
||
frame:1 pts:61440 pts_time:5
|
||
lavfi.signalstats.YAVG=18.4
|
||
|
||
""";
|
||
|
||
var proben = Bildabtaster.Lesen(ausgabe, versatz: 60);
|
||
|
||
Assert.That(proben, Is.EqualTo(new[]
|
||
{
|
||
new Bildprobe(62.5, 125.065),
|
||
new Bildprobe(65, 18.4),
|
||
}));
|
||
}
|
||
|
||
/// <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.signalstats.YAVG=18.4\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 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, Abspannkante.Schwelle(proben, Abspannregeln.Vorgabe),
|
||
stelle => abtaster.HelligkeitAsync(_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.Null);
|
||
}
|
||
|
||
/// <summary>Eine einzelne Sekunde lässt sich messen — und liegt richtig.</summary>
|
||
///
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Hier stand der Fehler, und hier wurde er sichtbar.</b> 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 <b>40</b>. Die feste Schwelle
|
||
/// hätte diesen Abspann also für Handlung gehalten, und genau das
|
||
/// geschah im Bestand.
|
||
/// </para>
|
||
/// <para>
|
||
/// Gemessen wird deshalb gegen die Schwelle, die der Film selbst
|
||
/// vorgibt — und die liegt hier zwischen 40 und der Handlung.
|
||
/// </para>
|
||
/// </remarks>
|
||
[Test]
|
||
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(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");
|
||
});
|
||
}
|
||
}
|
||
|
||
/// <summary>Dieselbe Helligkeit, 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.HelligkeitAsync(Erzeugen("yuv420p"), bei: 2);
|
||
var zehn = await abtaster.HelligkeitAsync(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");
|
||
});
|
||
}
|
||
}
|