Vier Sekunden, die ffprobe zu früh aufhörte zu lesen

Die Untertitel kamen vier Sekunden zu spät. Nicht an ihnen lag es: der
Server meldete dem Spieler einen Anfang, der vor dem lag, an dem der
Strom wirklich begann, und genau diese Differenz landet eins zu eins im
Versatz.

Die Schlüsselbildsuche las mit `-read_intervals` bis eine halbe Sekunde
hinter den Wunsch. ffprobe bricht am Ende eines Fensters auf Paketebene
ab — das letzte Schlüsselbild darin kam regelmässig gar nicht mehr
heraus, und die Suche gab das davor zurück. Bei Bildgruppen von vier
Sekunden sind das vier Sekunden.

Nachgemessen gegen die Stelle, an der ffmpeg tatsächlich anfängt, an
einer Datei mit Schlüsselbildern alle vier Sekunden:

    Wunsch   Suche (0,5 s)   ffmpeg    Abweichung
      52,0            48      52,0         −4,0
      73,3            68      72,0         −4,0
     100,2            96     100,0         −4,0

Mit zehn Sekunden Nachlauf lag dieselbe Messung bei allen sieben Werten
auf 0,000. Was über den Wunsch hinausgelesen wird, verwirft die
Filterung ohnehin.

Die Prüfung vergleicht nicht gegen eine erwartete Zahl, sondern gegen
ffmpeg selbst — mit dem alten Wert fällt sie um.

Dazu der Filmteil der Abspannerkennung: `Abspannkante` als reine
Rechnung mit elf Prüfungen, `Bildabtaster` über ffmpeg, Marken auch an
Filmen, Knöpfe zum Suchen und Verwerfen auf der Filmseite. An einer
erzeugten Datei trifft die Kante auf vier Sekunden genau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-13 06:42:16 +02:00
co-authored by Claude Opus 5
parent 8112c15d4d
commit 7673c73757
22 changed files with 2825 additions and 32 deletions
@@ -0,0 +1,159 @@
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",
"-f", "lavfi", "-i", "color=c=white:s=180x14: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, 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.</summary>
[Test]
public async Task Eine_Sekunde_im_Abspann_ist_dunkel()
{
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));
});
}
}
@@ -0,0 +1,153 @@
using System.Diagnostics;
using System.Globalization;
using Flaro.Media;
using Microsoft.Extensions.Logging.Abstractions;
namespace Flaro.Media.Tests;
/// <summary>Sagt die Schlüsselbildsuche dasselbe wie ffmpeg?</summary>
///
/// <remarks>
/// <para>
/// <b>Der Fehler, für den diese Prüfung steht.</b> Die Suche las mit
/// <c>-read_intervals</c> bis eine halbe Sekunde hinter den Wunsch — und
/// ffprobe hörte damit regelmässig eine ganze Bildgruppe zu früh auf. Der
/// Server meldete dem Spieler dann einen Anfang, der vier Sekunden vor dem
/// lag, an dem der Strom wirklich begann; die Untertitel kamen vier
/// Sekunden zu spät.
/// </para>
/// <para>
/// <b>Gegen ffmpeg geprüft, nicht gegen eine Erwartung.</b> Was zählt, ist
/// nicht, ob die Zahl „schön" ist, sondern ob sie mit der Stelle
/// übereinstimmt, an der der Strom tatsächlich anfängt. Die holt derselbe
/// Aufruf wie im Betrieb, nur mit <c>-copyts</c>, damit die Zeit absolut
/// bleibt.
/// </para>
/// </remarks>
public class SchluesselbilderTests
{
private string _ordner = "";
private string _datei = "";
/// <summary>Vier Sekunden — so kodiert, was von Streamingdiensten kommt.</summary>
private const double Bildgruppe = 4;
[OneTimeSetUp]
public void Erzeugen()
{
_ordner = Directory.CreateDirectory(Path.Combine(
Path.GetTempPath(), "flaro-keyframe-" + Guid.CreateVersion7().ToString("N"))).FullName;
_datei = Path.Combine(_ordner, "quelle.mp4");
var gelungen = Starten("ffmpeg",
"-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", "testsrc2=size=320x180:rate=25:duration=120",
"-c:v", "libx264", "-preset", "ultrafast",
"-g", "100", "-pix_fmt", "yuv420p", _datei);
if (!gelungen || !File.Exists(_datei))
Assert.Ignore("Ohne ffmpeg lässt sich das nicht prüfen.");
}
[OneTimeTearDown]
public void Aufraeumen()
{
try { Directory.Delete(_ordner, recursive: true); } catch (IOException) { }
}
/// <summary>Die Stelle, an der ffmpeg beim Kopieren wirklich anfängt.</summary>
private double? EchterAnfang(double ab)
{
var ziel = Path.Combine(_ordner, "schnitt.mp4");
var gelungen = Starten("ffmpeg",
"-hide_banner", "-loglevel", "error", "-y",
"-noaccurate_seek", "-ss", ab.ToString("0.###", CultureInfo.InvariantCulture),
"-i", _datei, "-map", "0:0", "-c", "copy", "-copyts", "-f", "mp4", ziel);
if (!gelungen) return null;
var ausgabe = Lesen("ffprobe",
"-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=start_time", "-of", "csv=p=0", ziel);
return double.TryParse(ausgabe?.Trim(), NumberStyles.Float,
CultureInfo.InvariantCulture, out var wert) ? wert : null;
}
/// <summary>Jeder dieser Werte lag vorher um eine ganze Bildgruppe daneben.</summary>
[TestCase(50.0)]
[TestCase(50.5)]
[TestCase(52.0)]
[TestCase(52.1)]
[TestCase(56.0)]
[TestCase(73.3)]
[TestCase(100.2)]
public async Task Die_Suche_trifft_den_Anfang_des_Stroms(double wunsch)
{
var suche = new Schluesselbilder(
FfmpegOptions.FromEnvironment(), NullLogger<Schluesselbilder>.Instance);
var gemeldet = await suche.DavorAsync(_datei, wunsch);
var echt = EchterAnfang(wunsch);
if (echt is null) Assert.Ignore("ffmpeg lieferte keinen Anfang.");
var stelle = echt!.Value;
Assert.That(gemeldet, Is.EqualTo(stelle).Within(0.05),
$"gemeldet {gemeldet}, tatsächlich {stelle} — die Differenz landet "
+ "eins zu eins im Versatz der Untertitel");
}
/// <summary>Und der Fund ist nie später als der Wunsch.</summary>
[TestCase(50.5)]
[TestCase(73.3)]
public async Task Nie_hinter_dem_Wunsch(double wunsch)
{
var suche = new Schluesselbilder(
FfmpegOptions.FromEnvironment(), NullLogger<Schluesselbilder>.Instance);
Assert.That(await suche.DavorAsync(_datei, wunsch), Is.LessThanOrEqualTo(wunsch));
}
// ---------- Werkzeug ----------
private static bool Starten(string programm, params string[] argumente)
{
var start = new ProcessStartInfo(programm)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var a in argumente) start.ArgumentList.Add(a);
try
{
using var prozess = Process.Start(start);
if (prozess is null) return false;
prozess.WaitForExit(120_000);
return prozess.ExitCode == 0;
}
catch (Exception) { return false; }
}
private static string? Lesen(string programm, params string[] argumente)
{
var start = new ProcessStartInfo(programm)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var a in argumente) start.ArgumentList.Add(a);
try
{
using var prozess = Process.Start(start);
if (prozess is null) return null;
var text = prozess.StandardOutput.ReadToEnd();
prozess.WaitForExit(60_000);
return prozess.ExitCode == 0 ? text : null;
}
catch (Exception) { return null; }
}
}