„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>
154 lines
5.3 KiB
C#
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; }
|
|
}
|
|
}
|