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:
co-authored by
Claude Opus 5
parent
ed13d5fa43
commit
68abd71074
@@ -234,9 +234,9 @@ public sealed class TranscodeTests
|
||||
Assert.That(File.Exists(weg), Is.False);
|
||||
}
|
||||
|
||||
/// <summary>Die Übersicht nennt die drei Unterordner mit Grösse und Anzahl.</summary>
|
||||
/// <summary>Die Übersicht nennt jeden Unterordner mit Grösse und Anzahl.</summary>
|
||||
[Test]
|
||||
public async Task Die_Belegung_nennt_die_drei_Unterordner()
|
||||
public async Task Die_Belegung_nennt_die_Unterordner()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var (client, kratz, dateiId) = await Aufgesetzt(server);
|
||||
@@ -250,7 +250,7 @@ public sealed class TranscodeTests
|
||||
{
|
||||
Assert.That(transcode.Teile, Is.Not.Null);
|
||||
Assert.That(transcode.Teile!.Select(t => t.Name),
|
||||
Is.EquivalentTo(new[] { "stroeme", "packages", "untertitel" }));
|
||||
Is.EquivalentTo(new[] { "stroeme", "packages", "untertitel", "vorschau" }));
|
||||
var pakete = transcode.Teile!.First(t => t.Name == "packages");
|
||||
Assert.That(pakete.Dateien, Is.EqualTo(1));
|
||||
Assert.That(pakete.Frei, Is.EqualTo(8 * 1024), "das Feld Frei trägt hier die Belegung des Ordners");
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using Flaro.Core.Media;
|
||||
|
||||
namespace Flaro.Core.Tests;
|
||||
|
||||
/// <summary>Welche Kachel eine Stelle zeigt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die Rechnung steht hier, weil beide Spieler sie brauchen.</b> Web und
|
||||
/// App verschieben denselben Ausschnitt; zweimal formuliert driftet er
|
||||
/// auseinander, und eine verschobene Vorschau sieht man nicht sofort — man
|
||||
/// hält sie für ein ungenaues Bild.
|
||||
/// </remarks>
|
||||
public class VorschauplanTests
|
||||
{
|
||||
/// <summary>Hundert Kacheln je Blatt, alle zehn Sekunden eine.</summary>
|
||||
private static readonly Vorschauplan Plan = new(
|
||||
Intervall: 10, Spalten: 10, Zeilen: 10,
|
||||
KachelBreite: 320, KachelHoehe: 180, Blaetter: 3);
|
||||
|
||||
[Test]
|
||||
public void Der_Anfang_ist_die_erste_Kachel()
|
||||
{
|
||||
Assert.That(Plan.Kachel(0), Is.EqualTo(new Vorschaukachel(0, 0, 0)));
|
||||
}
|
||||
|
||||
/// <summary>Innerhalb eines Intervalls gilt dieselbe Kachel.</summary>
|
||||
[Test]
|
||||
public void Neun_Sekunden_sind_noch_die_erste()
|
||||
{
|
||||
Assert.That(Plan.Kachel(9.9), Is.EqualTo(new Vorschaukachel(0, 0, 0)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Zehn_Sekunden_sind_die_zweite()
|
||||
{
|
||||
Assert.That(Plan.Kachel(10), Is.EqualTo(new Vorschaukachel(0, 1, 0)));
|
||||
}
|
||||
|
||||
/// <summary>Nach zehn Kacheln beginnt die zweite Zeile, nicht das zweite Blatt.</summary>
|
||||
[Test]
|
||||
public void Die_elfte_Kachel_steht_eine_Zeile_tiefer()
|
||||
{
|
||||
Assert.That(Plan.Kachel(100), Is.EqualTo(new Vorschaukachel(0, 0, 1)));
|
||||
}
|
||||
|
||||
/// <summary>Die Ränder eines Blattes — dort verrechnet man sich.</summary>
|
||||
[Test]
|
||||
public void Der_Blattwechsel_liegt_richtig()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
// Kachel 99: letzte Spalte, letzte Zeile, erstes Blatt.
|
||||
Assert.That(Plan.Kachel(990), Is.EqualTo(new Vorschaukachel(0, 9, 9)));
|
||||
// Kachel 100: erste Spalte, erste Zeile, zweites Blatt.
|
||||
Assert.That(Plan.Kachel(1000), Is.EqualTo(new Vorschaukachel(1, 0, 0)));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Hinter dem letzten Bild bleibt das letzte Bild stehen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Das Raster endet, wo ffmpeg aufgehört hat. Eine Sekunde darüber
|
||||
/// hinaus — bei ungenauer Laufzeit durchaus möglich — soll die
|
||||
/// Vorschau nicht verschwinden.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Hinter_dem_Ende_gilt_die_letzte_Kachel()
|
||||
{
|
||||
Assert.That(Plan.Kachel(99_999), Is.EqualTo(new Vorschaukachel(2, 9, 9)));
|
||||
}
|
||||
|
||||
/// <summary>Eine negative Stelle gibt es nicht, und sie stürzt auch nicht ab.</summary>
|
||||
[Test]
|
||||
public void Vor_dem_Anfang_gilt_die_erste_Kachel()
|
||||
{
|
||||
Assert.That(Plan.Kachel(-30), Is.EqualTo(new Vorschaukachel(0, 0, 0)));
|
||||
}
|
||||
|
||||
/// <summary>Ohne Blätter gibt es nichts zu zeigen.</summary>
|
||||
[Test]
|
||||
public void Ein_leerer_Plan_liefert_nichts()
|
||||
{
|
||||
var leer = Plan with { Blaetter = 0 };
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(leer.Kachel(42), Is.Null);
|
||||
Assert.That((Plan with { Intervall = 0 }).Kachel(42), Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Die_Zahlen_haengen_zusammen()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(Plan.ProBlatt, Is.EqualTo(100));
|
||||
Assert.That(Plan.Kacheln, Is.EqualTo(300));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user