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
+94
View File
@@ -1,5 +1,6 @@
import SwiftUI
import FlaroDesign
import MediaKit
/// Der Regler unter dem Film — mit dem, was schon geladen ist.
///
@@ -34,6 +35,11 @@ struct PlaybackScrubber: View {
/// Bei jeder Berührung — damit die Bedienung stehen bleibt.
let onActivity: () -> Void
/// Wie die Vorschaubilder liegen — `nil`, solange der Server keine hat.
var vorschau: Vorschauplan?
/// Woher ein Blatt kommt.
var blatt: (Int) -> URL? = { _ in nil }
@Environment(\.flaroAccent) private var accent
private static let trackHeight: CGFloat = 5
@@ -71,6 +77,22 @@ struct PlaybackScrubber: View {
.offset(x: gespielt - Self.thumbSize / 2)
.animation(.easeOut(duration: 0.15), value: scrubbing == nil)
}
// **Die Vorschau steht über dem Griff, nicht darunter.** Unter
// dem Finger verdeckte sie genau die Stelle, die man sucht.
.overlay(alignment: .bottomLeading) {
if let plan = vorschau,
let gezogen = scrubbing,
let stelle = plan.stelle(bei: gezogen) {
Vorschaukachel(plan: plan, stelle: stelle, blatt: blatt)
.offset(
x: min(
breite - plan.kachelBreite,
max(0, gespielt - plan.kachelBreite / 2)),
y: -Self.hitHeight / 2 - 8)
.allowsHitTesting(false)
.transition(.opacity)
}
}
.frame(height: Self.hitHeight)
.contentShape(.rect)
.gesture(
@@ -117,3 +139,75 @@ struct PlaybackScrubber: View {
return min(max(Double(x / width), 0), 1) * duration.seconds
}
}
/// Ein Standbild aus dem Film, ausgeschnitten aus einem Blatt.
///
/// **Ausgeschnitten, nicht einzeln geladen.** Ein Blatt trägt hundert
/// Bilder; beim Ziehen über die Leiste wird nur der Ausschnitt verschoben,
/// und nach dem ersten Blatt kommt kein Abruf mehr. Einzelne Bilder zu
/// holen hiesse, bei jeder Fingerbewegung ins Netz zu greifen.
///
/// **Geladen wird von Hand, nicht über `FlaroArtwork`.** Jenes rechnet mit
/// Seitenverhältnissen und schneidet selbst zu — hier wird ein Blatt
/// gebraucht, das genau so gross dasteht, wie es ist, damit der Versatz
/// auf den Bildpunkt stimmt.
struct Vorschaukachel: View {
let plan: Vorschauplan
let stelle: Vorschauplan.Stelle
/// Woher das Blatt kommt — der Spieler weiss, welche Datei läuft.
let blatt: (Int) -> URL?
@State private var bild: PlatformImage?
var body: some View {
Rechteck
.frame(width: plan.kachelBreite, height: plan.kachelHoehe)
.background(.black)
.clipShape(.rect(cornerRadius: FlaroRadius.card))
.overlay(
RoundedRectangle(cornerRadius: FlaroRadius.card)
.stroke(.white.opacity(0.18), lineWidth: 1))
.shadow(color: .black.opacity(0.6), radius: 10, y: 4)
.task(id: stelle.blatt) { await laden() }
}
@ViewBuilder
private var Rechteck: some View {
if let bild {
// `Image(platform:)` liegt in FlaroDesign und ist paketintern;
// dieselbe Fallunterscheidung, zwei Zeilen.
bildAnsicht(bild)
.resizable()
.frame(
width: plan.kachelBreite * Double(plan.spalten),
height: plan.kachelHoehe * Double(plan.zeilen))
// Der Ausschnitt entsteht durch Verschieben hinter einem
// Rahmen in Kachelgrösse — dieselbe Rechnung wie
// `background-position` im Web.
.offset(
x: -plan.kachelBreite * Double(stelle.spalte),
y: -plan.kachelHoehe * Double(stelle.zeile))
.frame(
width: plan.kachelBreite, height: plan.kachelHoehe,
alignment: .topLeading)
.clipped()
} else {
Color.black
}
}
private func bildAnsicht(_ bild: PlatformImage) -> Image {
#if canImport(UIKit)
Image(uiImage: bild)
#else
Image(nsImage: bild)
#endif
}
private func laden() async {
guard let adresse = blatt(stelle.blatt) else { return }
// Der Lader hält Blätter im Speicher: beim Ziehen über die Leiste
// wird dasselbe Blatt hundertmal gebraucht.
bild = await ArtworkLoader.shared.image(for: adresse)
}
}
+40
View File
@@ -61,6 +61,14 @@ final class PlayerModel {
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
/// Überspringen-Knopf.
private(set) var activeMarker: MediaMarker?
/// Wie die Vorschaubilder liegen — `nil`, solange der Server keine hat.
///
/// **Einmal gefragt, bei „noch nicht" einmal nachgefasst.** Die Blätter
/// entstehen beim Abspielen im Hintergrund; nach zehn Sekunden sind sie
/// bei den meisten Dateien da. Weiter zu fragen hiesse, für eine
/// Annehmlichkeit dauernd am Server zu ziehen.
private(set) var vorschau: Vorschauplan?
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
/// damit sie am Ende ohne Wartezeit bereitsteht.
private(set) var nextEpisode: MediaItem?
@@ -598,6 +606,7 @@ final class PlayerModel {
await load(source, decision: decision)
preloadNextEpisode()
vorschauHolen()
}
/// Stellt auf eine andere Qualitätsstufe um.
@@ -763,6 +772,36 @@ final class PlayerModel {
///
/// Scheitert es — kein Server, kein Netz —, bleibt es, wie es war: die
/// Datei läuft, es gibt bloss keine Marken und keine Nachbarfolgen.
/// Welche Datei läuft — die Vorschau hängt an ihr, nicht am Titel.
///
/// `MediaVersion.id` ist die Kennung der Mediendatei; eine Fassung
/// besteht aus genau einer.
private var vorschaudatei: String?
/// Holt den Vorschauplan — einmal sofort, einmal nach zehn Sekunden.
private func vorschauHolen() {
guard vorschau == nil, let backend, let item else { return }
guard let datei = version(of: item)?.id else { return }
vorschaudatei = datei
Task { [weak self] in
for warten in [Duration.zero, .seconds(10)] {
if warten > .zero { try? await Task.sleep(for: warten) }
guard let self, self.vorschau == nil else { return }
if let plan = await backend.vorschauplan(fileId: datei) {
self.vorschau = plan
return
}
}
}
}
/// Woher ein Vorschaublatt kommt.
func vorschaublatt(_ nummer: Int) -> URL? {
guard let backend, let datei = vorschaudatei else { return nil }
return backend.vorschaublatt(fileId: datei, blatt: nummer)
}
private func metadatenNachladen() {
guard item == nil, let backend else { return }
Task { [weak self] in
@@ -771,6 +810,7 @@ final class PlayerModel {
self.item = geholt
self.updateActiveMarker()
self.preloadNextEpisode()
self.vorschauHolen()
}
}
+3 -1
View File
@@ -730,7 +730,9 @@ struct PlayerView: View {
// springen lässt das Bild flackern und hält bei großen
// Dateien den Puffer dauerhaft leer.
onCommit: { model.seek(to: $0) },
onActivity: { scheduleControlsHide() }
onActivity: { scheduleControlsHide() },
vorschau: model.vorschau,
blatt: { model.vorschaublatt($0) }
)
HStack {
@@ -308,6 +308,32 @@ public actor FlaroBackend: MediaBackend {
/// liefert, und rechnet nicht um. Ein Plakat wiegt achtzig Kilobyte; ein
/// Server, der jede Kachel einzeln skaliert, spart Leitung und kostet
/// Rechenzeit, die auf einem NAS knapper ist.
/// Wie die Vorschaubilder einer Datei liegen.
///
/// **Der Server erzeugt sie beim Abspielen.** Steht noch nichts da,
/// antwortet er mit `fertig: false` — dann gibt es hier nichts, und
/// der Spieler fragt später noch einmal.
public func vorschauplan(fileId: String) async -> Vorschauplan? {
guard let auskunft = try? await client.send(
FlaroRequest(path: "/api/v1/files/\(fileId)/vorschau"),
as: Wire.Vorschau.self), auskunft.fertig
else { return nil }
return Vorschauplan(
intervall: auskunft.intervall,
spalten: auskunft.spalten,
zeilen: auskunft.zeilen,
kachelBreite: Double(auskunft.kachelBreite),
kachelHoehe: Double(auskunft.kachelHoehe),
blaetter: auskunft.blaetter)
}
/// **Ohne Kopfzeilen, wie bei den Plakaten.** Die Anmeldung hängt am
/// Cookie; der Bildlader holt die Adresse roh und kommt damit durch.
public nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL? {
client.url(path: "/api/v1/files/\(fileId)/vorschau/\(blatt).jpg")
}
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
rawImageURL(path: path)
}
@@ -261,3 +261,16 @@ enum Wire {
struct FavouriteRequest: Encodable, Sendable { let favourite: Bool }
struct RatingRequest: Encodable, Sendable { let rating: Int? }
}
extension Wire {
/// Was der Server über die Vorschaubilder sagt.
struct Vorschau: Decodable, Sendable {
let fertig: Bool
let intervall: Double
let spalten: Int
let zeilen: Int
let kachelBreite: Int
let kachelHoehe: Int
let blaetter: Int
}
}
@@ -96,6 +96,12 @@ public protocol MediaBackend: Sendable, Fortschrittsempfaenger {
/// deshalb nicht unbaubar werden.
func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem?
/// Wie die Vorschaubilder einer Datei liegen — `nil`, wenn es keine gibt.
func vorschauplan(fileId: String) async -> Vorschauplan?
/// Die Adresse eines Vorschaublattes.
nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL?
// MARK: - Bilder
/// Ein Bild in der gewünschten Größe.
@@ -240,6 +246,16 @@ public extension MediaBackend {
/// Vorgabe: kein Weg zurück.
func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? { nil }
/// Wie die Vorschaubilder einer Datei liegen — `nil`, wenn es keine gibt.
///
/// **Mit Vorgabe, damit nicht jeder Server es können muss.** Jellyfin
/// hat sein eigenes Trickplay mit anderem Zuschnitt; bis das angebunden
/// ist, sagt es hier schlicht nichts.
func vorschauplan(fileId: String) async -> Vorschauplan? { nil }
/// Die Adresse eines Vorschaublattes.
nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL? { nil }
/// Vorgabe: der Server kann es nicht.
///
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
@@ -0,0 +1,55 @@
import Foundation
/// Wie die Vorschaubilder eines Titels zusammengesetzt sind.
///
/// **Dieselbe Rechnung wie im Server und im Web.** Dort heisst der Typ
/// genauso; die Kachel zu einer Sekunde wird an drei Stellen ausgerechnet,
/// und wenn eine davon abweicht, sieht man es nicht als Fehler — man hält
/// die Vorschau für ein ungenaues Bild.
///
/// **Die Kachelmasse stehen nicht fest.** Skaliert wird auf eine feste
/// Breite, die Höhe folgt dem Seitenverhältnis des Films. Deshalb misst
/// der Server sie und teilt sie mit, statt dass sie hier stünden.
public struct Vorschauplan: Sendable, Equatable, Decodable {
public let intervall: Double
public let spalten: Int
public let zeilen: Int
public let kachelBreite: Double
public let kachelHoehe: Double
public let blaetter: Int
public init(
intervall: Double, spalten: Int, zeilen: Int,
kachelBreite: Double, kachelHoehe: Double, blaetter: Int
) {
self.intervall = intervall
self.spalten = spalten
self.zeilen = zeilen
self.kachelBreite = kachelBreite
self.kachelHoehe = kachelHoehe
self.blaetter = blaetter
}
/// Wie eine Kachel adressiert wird: welches Blatt, und wo darin.
public struct Stelle: Sendable, Equatable {
public let blatt: Int
public let spalte: Int
public let zeile: Int
}
public var proBlatt: Int { spalten * zeilen }
/// Welche Kachel die Stelle zeigt — oder nichts.
///
/// **Hinter dem letzten Bild bleibt das letzte stehen.** Das Raster
/// endet dort, wo ffmpeg aufgehört hat; eine Sekunde darüber hinaus —
/// bei ungenauer Laufzeit durchaus möglich — soll die Vorschau nicht
/// verschwinden.
public func stelle(bei sekunde: Double) -> Stelle? {
guard blaetter > 0, spalten > 0, zeilen > 0, intervall > 0 else { return nil }
let nummer = min(Int(max(0, sekunde) / intervall), proBlatt * blaetter - 1)
let rest = nummer % proBlatt
return Stelle(blatt: nummer / proBlatt, spalte: rest % spalten, zeile: rest / spalten)
}
}
@@ -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);
}
}
+21
View File
@@ -1252,6 +1252,27 @@ export const protokolltage = () => anfrage<Protokolltag[]>('/system/logs');
*
* Mit `zeilen` kommt nur das Ende — das ist, was die Ansicht braucht. Ohne
* kommt alles, und das ist der Weg zum Herunterladen. */
/** Wie die Vorschaubilder eines Titels zusammengesetzt sind.
*
* **Die Kachelmasse stehen nicht fest**: skaliert wird auf eine feste
* Breite, die Höhe folgt dem Seitenverhältnis. Der Server misst sie und
* sagt sie — geraten wäre jede Kachel ein paar Bildpunkte verschoben. */
export type Vorschauauskunft = {
fertig: boolean;
intervall: number;
spalten: number;
zeilen: number;
kachelBreite: number;
kachelHoehe: number;
blaetter: number;
};
export const vorschauauskunft = (dateiId: string) =>
anfrage<Vorschauauskunft>(`/files/${dateiId}/vorschau`);
export const vorschaublattAdresse = (dateiId: string, blatt: number) =>
`/api/v1/files/${dateiId}/vorschau/${blatt}.jpg`;
export const protokollAdresse = (tag: string, zeilen?: number) =>
`/api/v1/system/logs/${tag}` + (zeilen ? `?tail=${zeilen}` : '');
+77 -2
View File
@@ -1,6 +1,7 @@
import type HlsTyp from 'hls.js';
import {
stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, segmenteHolen,
vorschauauskunft, vorschaublattAdresse, type Vorschauauskunft,
type Stromauskunft, type Spurauskunft, type Folge, type Segment,
} from './api';
import { dauer, knopf, meldung, hinweisZeigen, folgenname } from './util';
@@ -661,7 +662,12 @@ export function spieler(anfrage: Spieleranfrage): void {
// gibt, ist keiner. Jetzt liegt er als eigene Schicht über dem Bild,
// rechts unten, und erscheint von selbst — bis man die Bedienung einmal
// eingeblendet hat, danach kommt und geht er mit ihr.
bedienung.append(zeitleiste, unten);
// **Die Vorschau hängt an der Bedienung, nicht am Bild.** Sie soll über
// der Leiste stehen und mit ihr verschwinden; als eigene Schicht über
// dem Film müsste ihre Lage zweimal gerechnet werden.
const vorschau = document.createElement('div');
vorschau.className = 'pl-vorschau hidden';
bedienung.append(zeitleiste, vorschau, unten);
// **Beide Nachbarn gleich zu Beginn holen.** Zwei kleine Anfragen, einmal
// je Wiedergabe. Erst beim Antippen zu fragen hiesse, einen Knopf zu
@@ -786,6 +792,68 @@ export function spieler(anfrage: Spieleranfrage): void {
// Regler stehen, bis jemand ihn wieder anfasst.
zeitleiste.onpointercancel = () => { amRegler = false; };
/** Was der Server über die Vorschaubilder sagt — `null`, solange nichts da ist. */
let bilder: Vorschauauskunft | null = null;
// **Einmal fragen, und bei „noch nicht" genau einmal nachfassen.** Die
// Blätter entstehen beim Abspielen im Hintergrund; nach zehn Sekunden
// sind sie bei den meisten Filmen da. Weiter zu pollen hiesse, für eine
// Annehmlichkeit dauernd zu fragen.
void (async () => {
for (const warten of [0, 10000]) {
if (warten) await new Promise(r => setTimeout(r, warten));
if (geschlossen) return;
try {
const auskunft = await vorschauauskunft(anfrage.dateiId);
if (auskunft.fertig) { bilder = auskunft; return; }
} catch { return; }
}
})();
/** Zeigt die Kachel zur Stelle unter dem Zeiger. */
function vorschauZeigen(x: number): void {
if (!bilder || gesamt <= 0) return;
const kasten = zeitleiste.getBoundingClientRect();
const anteil = Math.min(1, Math.max(0, (x - kasten.left) / kasten.width));
const sekunde = anteil * gesamt;
// Dieselbe Rechnung wie im Server und in der App — siehe `Vorschauplan`.
const proBlatt = bilder.spalten * bilder.zeilen;
const nummer = Math.min(
Math.floor(sekunde / bilder.intervall), proBlatt * bilder.blaetter - 1);
const rest = nummer % proBlatt;
vorschau.style.width = `${bilder.kachelBreite}px`;
vorschau.style.height = `${bilder.kachelHoehe}px`;
vorschau.style.backgroundImage =
`url(${vorschaublattAdresse(anfrage.dateiId, Math.floor(nummer / proBlatt))})`;
vorschau.style.backgroundPosition =
`-${(rest % bilder.spalten) * bilder.kachelBreite}px `
+ `-${Math.floor(rest / bilder.spalten) * bilder.kachelHoehe}px`;
// **Gerechnet wird in zwei Bezugssystemen, und das ist die Falle.**
// Die Stelle kommt aus der Leiste, das Feld hängt an der Bedienung —
// dazwischen liegt deren Innenabstand. Ohne die Umrechnung sässe die
// Vorschau achtzehn Bildpunkte daneben.
const bezug = bedienung.getBoundingClientRect();
const halb = bilder.kachelBreite / 2;
const links = Math.min(
kasten.right - bezug.left - halb,
Math.max(kasten.left - bezug.left + halb, x - bezug.left));
vorschau.style.left = `${links}px`;
vorschau.classList.remove('hidden');
zeit.textContent = dauer(sekunde);
}
const zeit = document.createElement('div');
zeit.className = 'pl-vorschauzeit';
vorschau.appendChild(zeit);
zeitleiste.addEventListener('pointermove', e => vorschauZeigen(e.clientX));
zeitleiste.addEventListener('pointerleave', () => vorschau.classList.add('hidden'));
/** Die Gesamtlänge des Films — nicht die des Stroms. */
const gesamt = anfrage.laenge ?? 0;
@@ -1309,7 +1377,14 @@ export function spieler(anfrage: Spieleranfrage): void {
/* ---------- Tastatur ---------- */
function tastendruck(e: KeyboardEvent): void {
if (e.target instanceof HTMLInputElement && e.target.type === 'range' && e.key.startsWith('Arrow')) return;
// **Pfeiltasten gehören dem Spieler, auch auf der Leiste.**
//
// Hier stand eine Ausnahme: hat die Zeitleiste den Fokus, überlasse
// die Pfeile dem Browser. Der verschiebt `value` und löst `input`
// aus — aber `change` kommt bei Tastatureingabe nicht verlässlich,
// und ohne `change` wird nie gesprungen. Der Regler bewegte sich
// also und der Film nicht. Abgesehen davon steht `step` auf 0,1:
// ein nativer Tastendruck wäre eine Zehntelsekunde.
switch (e.key) {
case ' ': case 'k': e.preventDefault(); umschalten(); break;
+29
View File
@@ -987,6 +987,35 @@ textarea.feld {
height: 20px; background: transparent; cursor: pointer; margin: 0;
}
.pl-zeit { width: 100%; }
/* **Die Vorschau steht über der Leiste, nicht unter dem Zeiger.**
Am Zeiger klebend verdeckte sie genau die Stelle, die man sucht; eine
feste Höhe darüber lässt die Leiste frei. `translateX(-50%)` setzt sie
mittig über die Stelle, `left` kommt aus dem Skript. */
.pl-vorschau {
position: absolute;
bottom: calc(100% + var(--s-snug));
transform: translateX(-50%);
border-radius: var(--r-klein);
overflow: hidden;
background-color: #000;
background-repeat: no-repeat;
box-shadow: 0 8px 24px rgb(0 0 0 / .6);
border: 1px solid rgb(255 255 255 / .18);
pointer-events: none;
z-index: 4;
}
.pl-vorschauzeit {
position: absolute;
inset: auto 0 0 0;
padding: 2px 0 3px;
text-align: center;
font-size: .72rem;
font-variant-numeric: tabular-nums;
color: #fff;
background: linear-gradient(to top, rgb(0 0 0 / .85), transparent);
}
.pl-laut { width: 84px; flex: 0 0 auto; }
.pl-zeit::-webkit-slider-runnable-track, .pl-laut::-webkit-slider-runnable-track {