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
@@ -1077,3 +1077,21 @@ public sealed record Sprachauswahl(
IReadOnlyList<Sprachangabe> Audio,
IReadOnlyList<Sprachangabe> Subtitles,
int Partial);
/// <summary>Wie die Vorschaubilder eines Titels zusammengesetzt sind.</summary>
///
/// <remarks>
/// <b>Die Kachelmasse stehen nicht fest.</b> Skaliert wird auf eine feste
/// Breite, die Höhe folgt dem Seitenverhältnis — ein Breitbildfilm ergibt
/// flachere Kacheln. Deshalb werden sie gemessen und mitgeteilt; geraten
/// wäre jede Kachel um ein paar Bildpunkte verschoben, und das sieht man
/// nicht als Fehler, sondern hält es für ein unscharfes Bild.
/// </remarks>
public sealed record VorschauAuskunft(
bool Fertig,
double Intervall,
int Spalten,
int Zeilen,
int KachelBreite,
int KachelHoehe,
int Blaetter);
@@ -62,7 +62,7 @@ public static class StreamEndpoints
app.MapPost("/api/v1/files/{id:guid}/stream", async (
Guid id, StreamRequest anfrage, ClaimsPrincipal wer,
Streamkoordinator koordinator, FlaroContext db, TimeProvider uhr,
Untertitelvorrat vorrat) =>
Untertitelvorrat vorrat, Vorschauvorrat vorschau) =>
{
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
@@ -84,6 +84,13 @@ public static class StreamEndpoints
// Antwort statt eines Fehlers.
vorrat.Anstellen(gewuenschte.Id, vordraengeln: true);
// **Und die Vorschaubilder gleich mit.** Denselben Grund: sie
// entstehen aus einem Durchlauf über die Datei, und bis jemand
// zur Zeitleiste greift, ist der längst durch. Auf Vorrat über
// den ganzen Bestand wären es Gigabyte für Titel, die niemand
// anfasst.
vorschau.Anstellen(gewuenschte.Id);
var profil = Profil(anfrage);
var hoehe = anfrage.Height is { } gewuenscht
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null;
@@ -400,6 +407,68 @@ public static class StreamEndpoints
// in dessen Zukunft, und zwar für den ganzen Rest des Films. Gemessen
// im Spieler: bei „Fortsetzen ab 8:37" stand der Satz von Sekunde 0
// auf dem Bild.
// **Die Vorschau beim Überfahren der Zeitleiste.**
//
// Zwei Endpunkte: die Auskunft sagt, wie die Blätter aussehen, das
// Blatt liefert die Bilder. Der Spieler holt die Auskunft einmal
// und danach nur noch Blätter — beim Fahren über die Leiste wird
// kein einziger Abruf mehr fällig, nur der Ausschnitt verschoben.
app.MapGet("/api/v1/files/{id:guid}/vorschau", async (
Guid id, FlaroContext db, Vorschauvorrat vorschau, TimeProvider uhr,
CancellationToken ct) =>
{
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id, ct);
if (datei is null || !File.Exists(datei.Path))
return Einwand.Fehlt("Diese Datei liegt nicht mehr da.");
// **Mit Freigabeprüfung, anders als beim Untertitel.** Ein
// Standbild zeigt den Inhalt — bei einem Titel, der erst
// nächste Woche gilt, wäre es genau das, was die Freigabe
// verhindern soll.
if (await DeliveryEndpoints.FreiAb(db, datei, uhr.GetUtcNow()) is { } nochNicht)
return DeliveryEndpoints.NochNicht(nochNicht);
// Wer fragt, will sie auch: anstellen, falls noch nichts da ist.
vorschau.Anstellen(id);
var plan = vorschau.Liegtvor(id, datei.ModifiedAt);
return Results.Ok(new VorschauAuskunft(
plan is not null,
plan?.Intervall ?? Vorschaubilder.Intervall,
plan?.Spalten ?? Vorschaubilder.Spalten,
plan?.Zeilen ?? Vorschaubilder.Zeilen,
plan?.KachelBreite ?? 0,
plan?.KachelHoehe ?? 0,
plan?.Blaetter ?? 0));
}).RequireAuthorization();
app.MapGet("/api/v1/files/{id:guid}/vorschau/{nummer:int}.jpg", async (
Guid id, int nummer, FlaroContext db, Vorschauvorrat vorschau, TimeProvider uhr,
CancellationToken ct) =>
{
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id, ct);
if (datei is null) return Einwand.Fehlt("Diese Datei liegt nicht mehr da.");
if (await DeliveryEndpoints.FreiAb(db, datei, uhr.GetUtcNow()) is { } nochNicht)
return DeliveryEndpoints.NochNicht(nochNicht);
var blatt = vorschau.Blatt(id, datei.ModifiedAt, nummer);
return blatt is null
? Einwand.Fehlt("Dieses Blatt gibt es nicht.")
: Results.File(blatt, "image/jpeg");
})
// **Für immer gültig.** Der Dateiname trägt die Änderungszeit der
// Mediendatei; wird sie ersetzt, ändert sich die Adresse. Dasselbe
// Versprechen wie bei den Bildern aus `ImageEndpoints`.
.AddEndpointFilter(async (zusammenhang, weiter) =>
{
var antwort = await weiter(zusammenhang);
zusammenhang.HttpContext.Response.Headers.CacheControl =
"private, max-age=31536000, immutable";
return antwort;
})
.RequireAuthorization();
app.MapGet("/api/v1/files/{id:guid}/subtitles/{spur:int}.vtt", async (
Guid id, int spur, FlaroContext db, Untertitelvorrat vorrat,
CancellationToken ct, double ab = 0) =>
+2
View File
@@ -184,6 +184,8 @@ builder.Services.AddSingleton<PackageCoordinator>();
builder.Services.AddSingleton<Streamkoordinator>();
builder.Services.AddSingleton<UntertitelLeser>();
builder.Services.AddSingleton<Untertitelvorrat>();
builder.Services.AddSingleton<Vorschaubilder>();
builder.Services.AddSingleton<Vorschauvorrat>();
// Der Transcode-Cache räumt sich auf — kein Dienst, kein Takt, vier Anlässe.
builder.Services.AddScoped<Transcodeeinstellungen>();
builder.Services.AddSingleton<Transcodepflege>();
+64
View File
@@ -65,11 +65,14 @@ public sealed class Transcodepflege(
var (paketfrei, paketzahl) = await PaketeAufraeumenAsync(jetzt, ct);
var (textfrei, textzahl) = await UntertitelAufraeumenAsync(ct);
var (vorschaufrei, vorschauzahl) = await VorschauAufraeumenAsync(ct);
var teile = new List<Teilbericht>
{
new("stroeme", stromfrei, 0),
new("packages", paketfrei, paketzahl),
new("untertitel", textfrei, textzahl),
new("vorschau", vorschaufrei, vorschauzahl),
};
var bericht = new Aufraeumbericht(
@@ -159,6 +162,66 @@ public sealed class Transcodepflege(
/// lässt den Server bei jedem Start eine vierzig Gigabyte grosse Datei
/// neu durchlesen, um wieder nichts zu finden.
/// </remarks>
/// <summary>Vorschaublätter, zu denen es keine Datei mehr gibt.</summary>
///
/// <remarks>
/// <para>
/// <b>Nur verwaist, wie bei den Untertiteln.</b> Weder Alter noch
/// Obergrenze: Blätter entstehen beim Abspielen, und wer einen Film
/// zweimal im Jahr ansieht, soll beim zweiten Mal nicht wieder warten.
/// Sie sind klein — ein ganzer Film liegt bei wenigen Megabyte.
/// </para>
/// <para>
/// <b>Verwaist heisst: der Namensanfang passt zu nichts.</b> Er trägt
/// Kennung und Änderungszeit der Mediendatei; wird sie ersetzt oder
/// verschwindet sie, gehören die Blätter zu niemandem mehr. Dafür
/// braucht es keine Tabelle.
/// </para>
/// </remarks>
private async Task<(long Frei, int Dateien)> VorschauAufraeumenAsync(CancellationToken ct)
{
var ordner = Path.Combine(pfade.Transcode, "vorschau");
if (!Directory.Exists(ordner)) return (0, 0);
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var gefunden = new List<(string Pfad, Guid Id, long Ms)>();
foreach (var pfad in Directory.EnumerateFiles(ordner))
{
var teile = Path.GetFileNameWithoutExtension(pfad).Split('-');
if (teile.Length < 2
|| !Guid.TryParseExact(teile[0], "N", out var id)
|| !long.TryParse(teile[1], out var ms))
{
// Was hier nicht hingehört, gehört weg.
gefunden.Add((pfad, Guid.Empty, -1));
continue;
}
gefunden.Add((pfad, id, ms));
}
if (gefunden.Count == 0) return (0, 0);
var kennungen = gefunden.Where(g => g.Ms >= 0).Select(g => g.Id).Distinct().ToList();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(f => kennungen.Contains(f.Id))
.Select(f => new { f.Id, f.ModifiedAt })
.ToDictionaryAsync(f => f.Id, f => f.ModifiedAt.ToUnixTimeMilliseconds(), ct);
long frei = 0;
var anzahl = 0;
foreach (var g in gefunden)
{
if (g.Ms >= 0 && dateien.TryGetValue(g.Id, out var ms) && ms == g.Ms) continue;
frei += Loeschen(g.Pfad);
anzahl++;
}
return (frei, anzahl);
}
private async Task<(long Frei, int Dateien)> UntertitelAufraeumenAsync(CancellationToken ct)
{
var ordner = Path.Combine(pfade.Transcode, "untertitel");
@@ -240,6 +303,7 @@ public sealed class Transcodepflege(
("stroeme", stroeme.Wurzel),
("packages", pakete.Ordner),
("untertitel", Path.Combine(pfade.Transcode, "untertitel")),
("vorschau", Path.Combine(pfade.Transcode, "vorschau")),
};
return ordner.Select(o => new Teilbericht(o.Item1, Streamkoordinator.Belegung(o.Item2), Zaehlen(o.Item2)))
+149
View File
@@ -0,0 +1,149 @@
using Flaro.Core.Media;
using Flaro.Data;
using Flaro.Media;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Erzeugt Vorschaubilder im Hintergrund, eine Datei nach der anderen.</summary>
///
/// <remarks>
/// <para>
/// <b>Beim Abspielen, nicht auf Vorrat über den Bestand.</b> Bei 453
/// Filmen und 4213 Folgen wäre ein Lauf über alles ein Abend ffmpeg und
/// mehrere Gigabyte für Titel, die niemand anfasst. Wer einen Titel
/// startet, braucht die Bilder aber selten in den ersten Sekunden —
/// bis zum ersten Griff zur Leiste ist der Lauf längst durch.
/// </para>
/// <para>
/// <b>Eine zur Zeit.</b> Jeder Lauf liest eine Mediendatei von vorn bis
/// hinten durch; zwei gleichzeitig hiesse zwei Leseköpfe auf derselben
/// Platte, und nebenher läuft vielleicht noch ein Umrechnen.
/// </para>
/// </remarks>
public sealed class Vorschauvorrat(
IServiceScopeFactory scopes,
FlaroPaths pfade,
Vorschaubilder bilder,
ILogger<Vorschauvorrat> log)
{
private readonly LinkedList<Guid> _reihe = new();
private readonly Lock _schloss = new();
private bool _arbeitet;
/// <summary>Welche Datei gerade dran ist — für die Auskunft.</summary>
public Guid? Laufende { get; private set; }
private string Ordner => Path.Combine(pfade.Transcode, "vorschau");
/// <summary>Der gemeinsame Namensanfang der Blätter einer Datei.</summary>
///
/// <remarks>
/// <b>Mit der Änderungszeit, und das ist der Trick.</b> Wird die Datei
/// ersetzt, ändert sich der Anfang — die alten Blätter gehören dann
/// zu niemandem mehr, und das Aufräumen erkennt sie daran, ohne dass
/// irgendwo eine Tabelle mitgeführt werden müsste. Dasselbe Schema
/// wie beim Untertitelvorrat.
/// </remarks>
public static string Praefix(Guid dateiId, DateTimeOffset geaendert) =>
$"{dateiId:N}-{geaendert.ToUnixTimeMilliseconds()}";
/// <summary>Was schon dasteht — oder nichts.</summary>
public Vorschauplan? Liegtvor(Guid dateiId, DateTimeOffset geaendert)
{
var praefix = Praefix(dateiId, geaendert);
if (!Directory.Exists(Ordner)) return null;
var blaetter = Directory.GetFiles(Ordner, $"{praefix}-*.jpg").Length;
if (blaetter == 0) return null;
// Die Masse stehen in der Begleitdatei: sie hängen am
// Seitenverhältnis des Films und lassen sich nicht herleiten.
var masse = Path.Combine(Ordner, $"{praefix}.masse");
if (!File.Exists(masse)) return null;
var teile = File.ReadAllText(masse).Split('x');
if (teile.Length != 2
|| !int.TryParse(teile[0], out var breite)
|| !int.TryParse(teile[1], out var hoehe))
{
return null;
}
return new Vorschauplan(
Vorschaubilder.Intervall, Vorschaubilder.Spalten, Vorschaubilder.Zeilen,
breite, hoehe, blaetter);
}
/// <summary>Stellt eine Datei an, wenn ihre Bilder noch fehlen.</summary>
public void Anstellen(Guid dateiId)
{
lock (_schloss)
{
if (Laufende == dateiId || _reihe.Contains(dateiId)) return;
_reihe.AddLast(dateiId);
if (_arbeitet) return;
_arbeitet = true;
}
_ = Task.Run(AbarbeitenAsync);
}
private async Task AbarbeitenAsync()
{
while (true)
{
Guid naechste;
lock (_schloss)
{
if (_reihe.First is null) { _arbeitet = false; Laufende = null; return; }
naechste = _reihe.First.Value;
_reihe.RemoveFirst();
Laufende = naechste;
}
try { await ZiehenAsync(naechste); }
catch (Exception fehler)
{
log.LogWarning(fehler, "Vorschau für {Datei} abgebrochen: {Grund}",
naechste, Fehlertext.FuerMenschen(fehler));
}
}
}
private async Task ZiehenAsync(Guid dateiId)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var datei = await db.MediaFiles.AsNoTracking()
.Where(f => f.Id == dateiId)
.Select(f => new { f.Path, f.ModifiedAt })
.FirstOrDefaultAsync();
if (datei is null || !File.Exists(datei.Path)) return;
var praefix = Praefix(dateiId, datei.ModifiedAt);
if (Liegtvor(dateiId, datei.ModifiedAt) is not null) return;
var plan = await bilder.ErzeugenAsync(datei.Path, Ordner, praefix);
if (plan is not { } fertig) return;
await File.WriteAllTextAsync(
Path.Combine(Ordner, $"{praefix}.masse"),
$"{fertig.KachelBreite}x{fertig.KachelHoehe}");
log.LogInformation(
"Vorschau für {Datei}: {Blaetter} Blatt/Blätter, Kachel {Breite}×{Hoehe}.",
Path.GetFileName(datei.Path), fertig.Blaetter, fertig.KachelBreite, fertig.KachelHoehe);
}
/// <summary>Der Pfad eines Blattes — oder <c>null</c>, wenn es das nicht gibt.</summary>
public string? Blatt(Guid dateiId, DateTimeOffset geaendert, int nummer)
{
if (nummer is < 0 or > 999) return null;
var pfad = Path.Combine(Ordner, $"{Praefix(dateiId, geaendert)}-{nummer:000}.jpg");
return File.Exists(pfad) ? pfad : null;
}
}
@@ -0,0 +1,74 @@
namespace Flaro.Core.Media;
/// <summary>Welche Kachel eines Blattes eine Stelle zeigt.</summary>
///
/// <param name="Blatt">Das wievielte Blatt, von null an.</param>
/// <param name="Spalte">Spalte darin, von null an.</param>
/// <param name="Zeile">Zeile darin, von null an.</param>
public readonly record struct Vorschaukachel(int Blatt, int Spalte, int Zeile);
/// <summary>Wie die Vorschaubilder eines Titels zusammengesetzt sind.</summary>
///
/// <remarks>
/// <para>
/// <b>Gekachelt, nicht einzeln.</b> Ein Zweistünder ergibt bei einem Bild
/// alle zehn Sekunden siebenhundert Stück. Einzeln wären das
/// siebenhundert Dateien und siebenhundert Abrufe; als Blätter zu
/// hundert sind es sieben. Der Spieler lädt das Blatt einmal und
/// verschiebt danach nur noch den Ausschnitt — deshalb fühlt sich das
/// Fahren über die Leiste flüssig an und nicht wie ein Daumenkino aus
/// dem Netz.
/// </para>
/// <para>
/// <b>Die Kachelmasse stehen nicht fest.</b> Skaliert wird auf eine feste
/// Breite, die Höhe folgt dem Seitenverhältnis — ein Breitbildfilm
/// ergibt flachere Kacheln als eine Fernsehserie. Deshalb werden sie
/// gemessen und mitgeteilt, statt gerechnet zu werden.
/// </para>
/// <para>
/// <b>Reine Rechnung, ohne Datei.</b> Das Erzeugen braucht ffmpeg; das
/// Ausrechnen, welche Kachel eine Sekunde zeigt, braucht nichts —
/// dieselbe Naht wie zwischen <see cref="Frameprobe"/> und dem Abtaster.
/// Beide Spieler rechnen dasselbe, und was hier steht, ist die Vorlage.
/// </para>
/// </remarks>
///
/// <param name="Intervall">Abstand zweier Bilder in Sekunden.</param>
/// <param name="Spalten">Kacheln nebeneinander auf einem Blatt.</param>
/// <param name="Zeilen">Kacheln übereinander auf einem Blatt.</param>
/// <param name="KachelBreite">Breite einer Kachel in Bildpunkten.</param>
/// <param name="KachelHoehe">Höhe einer Kachel in Bildpunkten.</param>
/// <param name="Blaetter">Wie viele Blätter es gibt.</param>
public readonly record struct Vorschauplan(
double Intervall,
int Spalten,
int Zeilen,
int KachelBreite,
int KachelHoehe,
int Blaetter)
{
/// <summary>Wie viele Kacheln auf ein Blatt passen.</summary>
public int ProBlatt => Spalten * Zeilen;
/// <summary>Wie viele Bilder es insgesamt gibt.</summary>
public int Kacheln => ProBlatt * Blaetter;
/// <summary>Welche Kachel die Stelle zeigt — oder nichts.</summary>
///
/// <remarks>
/// <b>Hinter dem letzten Bild gilt das letzte Bild.</b> Das Raster
/// endet dort, wo ffmpeg aufgehört hat; eine Sekunde danach — bei
/// ungenauer Laufzeit durchaus möglich — soll die Vorschau nicht
/// verschwinden, sondern stehen bleiben.
/// </remarks>
public Vorschaukachel? Kachel(double sekunde)
{
if (Blaetter <= 0 || Spalten <= 0 || Zeilen <= 0 || Intervall <= 0) return null;
var nummer = (int)Math.Floor(Math.Max(0, sekunde) / Intervall);
nummer = Math.Min(nummer, Kacheln - 1);
var rest = nummer % ProBlatt;
return new Vorschaukachel(nummer / ProBlatt, rest % Spalten, rest / Spalten);
}
}
+195
View File
@@ -0,0 +1,195 @@
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using Flaro.Core.Media;
using Microsoft.Extensions.Logging;
namespace Flaro.Media;
/// <summary>Erzeugt die Vorschaubilder eines Titels — in einem Lauf.</summary>
///
/// <remarks>
/// <para>
/// <b>Ein Aufruf, nicht siebenhundert.</b> Ein Zweistünder ergibt bei
/// einem Bild alle zehn Sekunden siebenhundert Stück. Sie einzeln zu
/// ziehen hiesse, die Datei siebenhundertmal zu öffnen und zu suchen;
/// <c>fps</c> und <c>tile</c> machen daraus einen Durchlauf, der fertige
/// Blätter ausgibt.
/// </para>
/// <para>
/// <b><c>-skip_frame nokey</c> ist der Grund, warum das bezahlbar ist.</b>
/// Dieselbe Abkürzung wie beim Abtasten der Abspannhelligkeit: nur
/// Keyframes dekodieren. Gemessen dort 0,24 s je 180 Sekunden 1080p — ein
/// ganzer Film kostet damit Sekunden statt Minuten. Keyframes liegen alle
/// zwei bis zehn Sekunden, es ist also stets eines nahe genug an der
/// Marke; dass die Vorschau um zwei Sekunden danebenliegen kann, sieht
/// niemand.
/// </para>
/// <para>
/// <b>Erst <c>.teil</c>, dann umbenennen</b> — wie beim Untertitelziehen.
/// Bricht der Lauf ab, bleibt kein halbes Blatt liegen, das später für
/// fertig gehalten würde.
/// </para>
/// </remarks>
public sealed class Vorschaubilder(FfmpegOptions optionen, ILogger<Vorschaubilder> log)
{
/// <summary>Abstand zweier Bilder in Sekunden.</summary>
public const double Intervall = 10;
/// <summary>Kacheln je Blatt — zehn mal zehn, wie bei Jellyfin und Plex.</summary>
public const int Spalten = 10;
public const int Zeilen = 10;
/// <summary>Breite einer Kachel. Die Höhe folgt dem Seitenverhältnis.</summary>
public const int Breite = 320;
/// <summary>Wie lange ein ganzer Film höchstens dauern darf.</summary>
///
/// <remarks>
/// Grosszügig: ein Dreistünder auf langsamer Platte darf sich Zeit
/// lassen. Ohne Frist bliebe ein hängendes ffmpeg für immer stehen
/// und belegte den einen Platz der Warteschlange.
/// </remarks>
private static readonly TimeSpan Frist = TimeSpan.FromMinutes(20);
/// <summary>Zieht alle Blätter und sagt, wie sie aussehen.</summary>
///
/// <param name="praefix">
/// Der gemeinsame Namensanfang, etwa <c>{dateiId:N}-{geaendertMs}</c>.
/// Die Blätter heissen <c>{praefix}-000.jpg</c> und so fort — damit
/// ist ohne Datenbanktabelle entscheidbar, was zu einer Datei gehört
/// und was von einer älteren Fassung übrig ist.
/// </param>
public async Task<Vorschauplan?> ErzeugenAsync(
string pfad, string ordner, string praefix, CancellationToken ct = default)
{
Directory.CreateDirectory(ordner);
var roh = Path.Combine(ordner, $"{praefix}.teil-%03d.jpg");
var start = new ProcessStartInfo(optionen.Ffmpeg)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var a in new[]
{
// Kein Lesen von der Tastatur: ohne das greift ffmpeg nach der
// Standardeingabe des Dienstes.
"-nostdin", "-v", "error",
"-skip_frame", "nokey",
"-i", pfad,
"-an", "-sn",
"-vf", $"fps=1/{Zahl(Intervall)},scale={Breite}:-2,tile={Spalten}x{Zeilen}",
// Sechs ist die Stelle, an der ein Vorschaubild noch gut
// aussieht und ein Blatt unter einem halben Megabyte bleibt.
"-qscale:v", "6",
"-y", roh,
})
{
start.ArgumentList.Add(a);
}
using var frist = CancellationTokenSource.CreateLinkedTokenSource(ct);
frist.CancelAfter(Frist);
try
{
using var prozess = Process.Start(start);
if (prozess is null) return null;
var fehlerLesen = prozess.StandardError.ReadToEndAsync(frist.Token);
await prozess.WaitForExitAsync(frist.Token);
var fehlertext = await fehlerLesen;
if (prozess.ExitCode != 0)
{
log.LogWarning("ffmpeg gab {Code} für die Vorschau von {Pfad} zurück: {Fehler}",
prozess.ExitCode, pfad, fehlertext.Trim());
Aufraeumen(ordner, praefix);
return null;
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
Aufraeumen(ordner, praefix);
throw;
}
catch (Exception fehler)
{
log.LogWarning(fehler, "Vorschau für {Pfad} misslungen.", pfad);
Aufraeumen(ordner, praefix);
return null;
}
var teile = Directory.GetFiles(ordner, $"{praefix}.teil-*.jpg").OrderBy(p => p).ToList();
if (teile.Count == 0) return null;
// **Die Kachelmasse messen, nicht rechnen.** `scale=320:-2` rundet
// auf gerade Zahlen, und das Seitenverhältnis hängt am Film. Eine
// falsche Höhe verschiebt jede Kachel — und zwar unauffällig.
var masse = await MasseAsync(teile[0], frist.Token);
if (masse is not ({ } breite, { } hoehe))
{
Aufraeumen(ordner, praefix);
return null;
}
for (var i = 0; i < teile.Count; i++)
{
File.Move(teile[i], Path.Combine(ordner, $"{praefix}-{i:000}.jpg"), overwrite: true);
}
return new Vorschauplan(
Intervall, Spalten, Zeilen,
breite / Spalten, hoehe / Zeilen, teile.Count);
}
/// <summary>Die Masse eines Blattes, von ffprobe.</summary>
private async Task<(int Breite, int Hoehe)?> MasseAsync(string pfad, CancellationToken ct)
{
var start = new ProcessStartInfo(optionen.Ffprobe)
{
ArgumentList =
{
"-v", "quiet", "-print_format", "json",
"-show_entries", "stream=width,height", "-i", pfad,
},
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
try
{
using var prozess = Process.Start(start);
if (prozess is null) return null;
var lesen = prozess.StandardOutput.ReadToEndAsync(ct);
await prozess.WaitForExitAsync(ct);
if (prozess.ExitCode != 0) return null;
using var papier = JsonDocument.Parse(await lesen);
var strom = papier.RootElement.GetProperty("streams")[0];
return (strom.GetProperty("width").GetInt32(), strom.GetProperty("height").GetInt32());
}
catch (Exception fehler) when (fehler is not OperationCanceledException)
{
log.LogWarning(fehler, "Masse von {Pfad} liessen sich nicht lesen.", pfad);
return null;
}
}
/// <summary>Halbe Blätter wegräumen — sonst gelten sie später als fertig.</summary>
private static void Aufraeumen(string ordner, string praefix)
{
foreach (var datei in Directory.GetFiles(ordner, $"{praefix}.teil-*.jpg"))
{
try { File.Delete(datei); } catch (IOException) { }
}
}
private static string Zahl(double wert) => wert.ToString("0.###", CultureInfo.InvariantCulture);
}
@@ -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);
}
}