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
@@ -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)
}
}