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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
32f592b83e
commit
ed13d5fa43
@@ -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 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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user