Files
Flaro/server/tests/Flaro.Media.Tests/SchluesselbilderTests.cs
T
ScarriffleandClaude Opus 5 7673c73757 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>
2026-10-13 06:42:16 +02:00

154 lines
5.4 KiB
C#

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