Files
Flaro/server/tests/Flaro.Media.Tests/KeyframesTests.cs
T
ScarriffleandClaude Opus 5 ed13d5fa43 Kratzplatz, Markenerkennung, Tonabdruck — erfundene Wörter für Fachbegriffe
„Scratch disk" wörtlich zu übersetzen ist derselbe Fehler wie „Wachhund"
für Watchdog und „Keks" für Cookie. Und „Marke" ist kein Fachbegriff für
das, was gemeint ist: Jellyfin nennt diese Abschnitte Media Segments, und
das ist auch das Wort, in dem sich darüber reden lässt.

    Kratzplatz*       → Transcode*          (was es ist, steht in FLARO_TRANSCODE)
    Marke, Marken     → Segment, Segmente
    Markenerkennung   → Segmenterkennung
    Markenart/-suche  → Segmentart/-suche
    Tonabdruck        → Fingerprint         (Chromaprint nennt es so)
    Tonabgleich       → Fingerprintabgleich
    Schlüsselbild     → Keyframe
    Bildabtaster      → Frameabtaster

Gewöhnliches Deutsch bleibt gewöhnliches Deutsch: Wartung, Freigabe,
Meldung, Abspann, Vorspann. Das war nie das Problem.

**Was sich nicht ändert, und zwar mit Absicht.** Die drei
Einstellungsschlüssel `wartung.kratzplatz.paket.*` bleiben, wie sie sind —
sie stehen in der Datenbank, und ein neuer Name hiesse, dass die Werte
des Nutzers stillschweigend auf die Vorgabe zurückfallen. Dasselbe für
die Endpunktpfade `/marken`: sie sind nicht sichtbar, und ein Umbenennen
wäre Diff ohne Gewinn.

**Die Migration ist von Hand geschrieben.** `dotnet ef` hat ein Gerüst
erzeugt, das beide Tabellen löscht und neu anlegt — es sieht eine
verschwundene und eine neue Tabelle, nicht eine umbenannte. Damit wäre
jedes erkannte Segment und jeder Vermerk „hier wurde schon gesucht" weg
gewesen. `RenameTable` und `RenameIndex` tun dasselbe, ohne eine Zeile
anzufassen; nachgemessen an einer echten SQLite-Datei: Zeilen vorher 1
und 1, nachher 1 und 1, Inhalt unverändert.

Nachgezogen: in `bilder.ts` und `main.ts` heisst „marke" ein Häkchen und
das Logo — die blieben unberührt. Und „Segment" ist sächlich, was eine
Blindersetzung nicht weiss: „eine Segment" stand danach an vierzehn
Stellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 16:26:57 +02:00

154 lines
5.3 KiB
C#

using System.Diagnostics;
using System.Globalization;
using Flaro.Media;
using Microsoft.Extensions.Logging.Abstractions;
namespace Flaro.Media.Tests;
/// <summary>Sagt die Keyframesuche 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 KeyframesTests
{
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 Keyframes(
FfmpegOptions.FromEnvironment(), NullLogger<Keyframes>.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 Keyframes(
FfmpegOptions.FromEnvironment(), NullLogger<Keyframes>.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; }
}
}