Ein Bild, wo bisher nur eine Zahl stand

Wer zur Zeitleiste greift, sieht jetzt das Standbild der Stelle — im Web
beim Überfahren, in der App beim Ziehen.

**Erzeugt wird beim Abspielen, nicht auf Vorrat.** Ein Lauf über 453
Filme und 4213 Folgen wäre ein Abend ffmpeg und mehrere Gigabyte für
Titel, die niemand anfasst. Startet jemand einen Titel, stellt sich die
Datei hinten an; bis zum ersten Griff zur Leiste ist der Lauf durch.
Gemessen an einer erzeugten Datei: 191 ms für 250 Sekunden Film, ein
Zweistünder liegt damit bei etwa fünf Sekunden.

**Ein ffmpeg-Aufruf, nicht siebenhundert.** `fps=1/10,scale=320:-2,
tile=10x10` macht aus einem Durchlauf fertige Blätter zu hundert
Kacheln. Bezahlbar ist das durch `-skip_frame nokey` — dieselbe
Abkürzung wie beim Abtasten der Abspannhelligkeit: nur Keyframes
dekodieren. Dass die Vorschau dadurch um zwei Sekunden danebenliegen
kann, sieht niemand.

**Der Spieler lädt ein Blatt und verschiebt danach nur den Ausschnitt.**
Beim Fahren über die Leiste wird kein einziger Abruf mehr fällig. Die
Rechnung Sekunde → Kachel steht an drei Stellen — Server, Web, App —
und deshalb auch dreimal dieselbe; weicht eine ab, hält man die Vorschau
für ein unscharfes Bild statt für einen Fehler. Neun Prüfungen decken
die Ränder ab: Sekunde null, letzte Kachel eines Blattes, erste des
nächsten, und hinter dem Ende.

**Die Kachelmasse werden gemessen, nicht gerechnet.** `scale=320:-2`
hält das Seitenverhältnis, ein Breitbildfilm ergibt also flachere
Kacheln. Geraten wäre jede Kachel um ein paar Bildpunkte verschoben.

Aufgeräumt wird mit dem Transcode-Cache: der Namensanfang trägt Kennung
und Änderungszeit der Mediendatei, verwaiste Blätter sind daran ohne
Tabelle erkennbar. Der Ordner taucht unter „Speicher" auf.

**Und die Pfeiltasten spulen wieder.** Hatte die Zeitleiste den Fokus,
überliess der Spieler die Pfeile dem Browser — der verschiebt `value`
und löst `input` aus, aber `change` kommt bei Tastatureingabe nicht
verlässlich, also wurde nie gesprungen. Die Ausnahme fällt weg; Pfeil
heisst überall ±10 Sekunden. Nativ wären es ohnehin 100 Millisekunden
gewesen, weil `step` auf 0,1 steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-16 17:05:55 +02:00
co-authored by Claude Opus 5
parent ed13d5fa43
commit 68abd71074
20 changed files with 1159 additions and 7 deletions
@@ -0,0 +1,109 @@
using System.Diagnostics;
using Flaro.Media;
using Microsoft.Extensions.Logging.Abstractions;
namespace Flaro.Media.Tests;
/// <summary>Die Blätter der Vorschau — Zahl, Masse, Namen.</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
/// Kacheln liegen da, wo der Spieler sie sucht" liegt alles. Eine um eine
/// Kachel verschobene Vorschau sieht niemand als Fehler — man hält sie
/// für ein ungenaues Bild.
/// </para>
/// <para>
/// Erzeugt wird ein Film von 250 Sekunden. Bei einem Bild alle zehn
/// Sekunden sind das 25 Kacheln, und die passen auf ein Blatt von zehn
/// mal zehn.
/// </para>
/// </remarks>
public class VorschaubilderTests
{
private string _ordner = "";
private string _datei = "";
[OneTimeSetUp]
public void Erzeugen()
{
_ordner = Directory.CreateDirectory(Path.Combine(
Path.GetTempPath(), "flaro-vorschau-" + Guid.CreateVersion7().ToString("N"))).FullName;
_datei = Path.Combine(_ordner, "quelle.mp4");
var start = new ProcessStartInfo("ffmpeg")
{
ArgumentList =
{
"-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", "testsrc2=size=640x360:rate=25:duration=250",
"-c:v", "libx264", "-preset", "ultrafast",
"-g", "50", "-pix_fmt", "yuv420p", _datei,
},
RedirectStandardError = true,
UseShellExecute = false,
};
try
{
using var prozess = Process.Start(start);
prozess!.WaitForExit(120_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) { }
}
[Test]
public async Task Ein_Lauf_ergibt_ein_Blatt_mit_richtigen_Massen()
{
var ziel = Path.Combine(_ordner, "blaetter");
var bilder = new Vorschaubilder(
FfmpegOptions.FromEnvironment(), NullLogger<Vorschaubilder>.Instance);
var plan = await bilder.ErzeugenAsync(_datei, ziel, "pruef-123");
Assert.That(plan, Is.Not.Null, "der Lauf muss Blätter ergeben");
Assert.Multiple(() =>
{
Assert.That(plan!.Value.Blaetter, Is.EqualTo(1),
"25 Kacheln passen auf ein Blatt von zehn mal zehn");
Assert.That(plan.Value.KachelBreite, Is.EqualTo(Vorschaubilder.Breite));
// 640×360 ist 16:9 — bei 320 Breite also 180 hoch.
Assert.That(plan.Value.KachelHoehe, Is.EqualTo(180).Within(2));
Assert.That(plan.Value.Intervall, Is.EqualTo(Vorschaubilder.Intervall));
});
Assert.Multiple(() =>
{
Assert.That(File.Exists(Path.Combine(ziel, "pruef-123-000.jpg")),
"die Blätter heissen nach dem Präfix und ihrer Nummer");
Assert.That(Directory.GetFiles(ziel, "*.teil-*"), Is.Empty,
"nichts Halbes bleibt liegen");
});
}
/// <summary>Eine Datei, die es nicht gibt, ergibt nichts — und wirft nicht.</summary>
[Test]
public async Task Ohne_Quelle_kein_Plan()
{
var bilder = new Vorschaubilder(
FfmpegOptions.FromEnvironment(), NullLogger<Vorschaubilder>.Instance);
var plan = await bilder.ErzeugenAsync(
Path.Combine(_ordner, "gibtsnicht.mkv"), Path.Combine(_ordner, "leer"), "x");
Assert.That(plan, Is.Null);
}
}