From 68abd71074a0e3e6003bb74ea84e32c143a64efd Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 16 Oct 2026 17:05:55 +0200 Subject: [PATCH] Ein Bild, wo bisher nur eine Zahl stand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Flaro/Views/Player/PlaybackScrubber.swift | 94 +++++++++ Flaro/Views/Player/PlayerModel.swift | 40 ++++ Flaro/Views/Player/PlayerView.swift | 4 +- .../Sources/FlaroServerKit/FlaroBackend.swift | 26 +++ .../FlaroServerKit/FlaroContracts.swift | 13 ++ .../Sources/MediaKit/MediaBackend.swift | 16 ++ .../Sources/MediaKit/Vorschauplan.swift | 55 +++++ server/src/Flaro.Api/Endpoints/Contracts.cs | 18 ++ .../Flaro.Api/Endpoints/StreamEndpoints.cs | 71 ++++++- server/src/Flaro.Api/Program.cs | 2 + server/src/Flaro.Api/Transcodepflege.cs | 64 ++++++ server/src/Flaro.Api/Vorschauvorrat.cs | 149 +++++++++++++ server/src/Flaro.Core/Media/Vorschauplan.cs | 74 +++++++ server/src/Flaro.Media/Vorschaubilder.cs | 195 ++++++++++++++++++ .../tests/Flaro.Api.Tests/TranscodeTests.cs | 6 +- .../Flaro.Core.Tests/VorschauplanTests.cs | 101 +++++++++ .../Flaro.Media.Tests/VorschaubilderTests.cs | 109 ++++++++++ web/app/src/api.ts | 21 ++ web/app/src/spieler.ts | 79 ++++++- web/app/src/style.css | 29 +++ 20 files changed, 1159 insertions(+), 7 deletions(-) create mode 100644 Packages/FlaroKit/Sources/MediaKit/Vorschauplan.swift create mode 100644 server/src/Flaro.Api/Vorschauvorrat.cs create mode 100644 server/src/Flaro.Core/Media/Vorschauplan.cs create mode 100644 server/src/Flaro.Media/Vorschaubilder.cs create mode 100644 server/tests/Flaro.Core.Tests/VorschauplanTests.cs create mode 100644 server/tests/Flaro.Media.Tests/VorschaubilderTests.cs diff --git a/Flaro/Views/Player/PlaybackScrubber.swift b/Flaro/Views/Player/PlaybackScrubber.swift index 7ac17e1..ab0e5e7 100644 --- a/Flaro/Views/Player/PlaybackScrubber.swift +++ b/Flaro/Views/Player/PlaybackScrubber.swift @@ -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) + } +} diff --git a/Flaro/Views/Player/PlayerModel.swift b/Flaro/Views/Player/PlayerModel.swift index fea8749..ea51067 100644 --- a/Flaro/Views/Player/PlayerModel.swift +++ b/Flaro/Views/Player/PlayerModel.swift @@ -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() } } diff --git a/Flaro/Views/Player/PlayerView.swift b/Flaro/Views/Player/PlayerView.swift index d282469..a768f21 100644 --- a/Flaro/Views/Player/PlayerView.swift +++ b/Flaro/Views/Player/PlayerView.swift @@ -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 { diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift index 5110f97..e4e0436 100644 --- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift @@ -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) } diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift index 0cf2c87..e1ec446 100644 --- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift @@ -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 + } +} diff --git a/Packages/FlaroKit/Sources/MediaKit/MediaBackend.swift b/Packages/FlaroKit/Sources/MediaKit/MediaBackend.swift index 7043814..4ff143b 100644 --- a/Packages/FlaroKit/Sources/MediaKit/MediaBackend.swift +++ b/Packages/FlaroKit/Sources/MediaKit/MediaBackend.swift @@ -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 diff --git a/Packages/FlaroKit/Sources/MediaKit/Vorschauplan.swift b/Packages/FlaroKit/Sources/MediaKit/Vorschauplan.swift new file mode 100644 index 0000000..2aafa8a --- /dev/null +++ b/Packages/FlaroKit/Sources/MediaKit/Vorschauplan.swift @@ -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) + } +} diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index e36d76f..df2a7f5 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -1077,3 +1077,21 @@ public sealed record Sprachauswahl( IReadOnlyList Audio, IReadOnlyList Subtitles, int Partial); + +/// 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 — 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. +/// +public sealed record VorschauAuskunft( + bool Fertig, + double Intervall, + int Spalten, + int Zeilen, + int KachelBreite, + int KachelHoehe, + int Blaetter); diff --git a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs index cd81424..89437a8 100644 --- a/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/StreamEndpoints.cs @@ -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) => diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index 975e9b6..0578727 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -184,6 +184,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Der Transcode-Cache räumt sich auf — kein Dienst, kein Takt, vier Anlässe. builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/server/src/Flaro.Api/Transcodepflege.cs b/server/src/Flaro.Api/Transcodepflege.cs index cb2c6be..18a16dc 100644 --- a/server/src/Flaro.Api/Transcodepflege.cs +++ b/server/src/Flaro.Api/Transcodepflege.cs @@ -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 { 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. /// + /// Vorschaublätter, zu denen es keine Datei mehr gibt. + /// + /// + /// + /// Nur verwaist, wie bei den Untertiteln. 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. + /// + /// + /// Verwaist heisst: der Namensanfang passt zu nichts. 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. + /// + /// + 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(); + + 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))) diff --git a/server/src/Flaro.Api/Vorschauvorrat.cs b/server/src/Flaro.Api/Vorschauvorrat.cs new file mode 100644 index 0000000..751d310 --- /dev/null +++ b/server/src/Flaro.Api/Vorschauvorrat.cs @@ -0,0 +1,149 @@ +using Flaro.Core.Media; +using Flaro.Data; +using Flaro.Media; +using Microsoft.EntityFrameworkCore; + +namespace Flaro.Api; + +/// Erzeugt Vorschaubilder im Hintergrund, eine Datei nach der anderen. +/// +/// +/// +/// Beim Abspielen, nicht auf Vorrat über den Bestand. 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. +/// +/// +/// Eine zur Zeit. 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. +/// +/// +public sealed class Vorschauvorrat( + IServiceScopeFactory scopes, + FlaroPaths pfade, + Vorschaubilder bilder, + ILogger log) +{ + private readonly LinkedList _reihe = new(); + private readonly Lock _schloss = new(); + private bool _arbeitet; + + /// Welche Datei gerade dran ist — für die Auskunft. + public Guid? Laufende { get; private set; } + + private string Ordner => Path.Combine(pfade.Transcode, "vorschau"); + + /// Der gemeinsame Namensanfang der Blätter einer Datei. + /// + /// + /// Mit der Änderungszeit, und das ist der Trick. 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. + /// + public static string Praefix(Guid dateiId, DateTimeOffset geaendert) => + $"{dateiId:N}-{geaendert.ToUnixTimeMilliseconds()}"; + + /// Was schon dasteht — oder nichts. + 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); + } + + /// Stellt eine Datei an, wenn ihre Bilder noch fehlen. + 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(); + + 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); + } + + /// Der Pfad eines Blattes — oder null, wenn es das nicht gibt. + 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; + } +} diff --git a/server/src/Flaro.Core/Media/Vorschauplan.cs b/server/src/Flaro.Core/Media/Vorschauplan.cs new file mode 100644 index 0000000..e9c2b2c --- /dev/null +++ b/server/src/Flaro.Core/Media/Vorschauplan.cs @@ -0,0 +1,74 @@ +namespace Flaro.Core.Media; + +/// Welche Kachel eines Blattes eine Stelle zeigt. +/// +/// Das wievielte Blatt, von null an. +/// Spalte darin, von null an. +/// Zeile darin, von null an. +public readonly record struct Vorschaukachel(int Blatt, int Spalte, int Zeile); + +/// Wie die Vorschaubilder eines Titels zusammengesetzt sind. +/// +/// +/// +/// Gekachelt, nicht einzeln. 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. +/// +/// +/// Die Kachelmasse stehen nicht fest. 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. +/// +/// +/// Reine Rechnung, ohne Datei. Das Erzeugen braucht ffmpeg; das +/// Ausrechnen, welche Kachel eine Sekunde zeigt, braucht nichts — +/// dieselbe Naht wie zwischen und dem Abtaster. +/// Beide Spieler rechnen dasselbe, und was hier steht, ist die Vorlage. +/// +/// +/// +/// Abstand zweier Bilder in Sekunden. +/// Kacheln nebeneinander auf einem Blatt. +/// Kacheln übereinander auf einem Blatt. +/// Breite einer Kachel in Bildpunkten. +/// Höhe einer Kachel in Bildpunkten. +/// Wie viele Blätter es gibt. +public readonly record struct Vorschauplan( + double Intervall, + int Spalten, + int Zeilen, + int KachelBreite, + int KachelHoehe, + int Blaetter) +{ + /// Wie viele Kacheln auf ein Blatt passen. + public int ProBlatt => Spalten * Zeilen; + + /// Wie viele Bilder es insgesamt gibt. + public int Kacheln => ProBlatt * Blaetter; + + /// Welche Kachel die Stelle zeigt — oder nichts. + /// + /// + /// Hinter dem letzten Bild gilt das letzte Bild. 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. + /// + 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); + } +} diff --git a/server/src/Flaro.Media/Vorschaubilder.cs b/server/src/Flaro.Media/Vorschaubilder.cs new file mode 100644 index 0000000..1c8a22c --- /dev/null +++ b/server/src/Flaro.Media/Vorschaubilder.cs @@ -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; + +/// Erzeugt die Vorschaubilder eines Titels — in einem Lauf. +/// +/// +/// +/// Ein Aufruf, nicht siebenhundert. 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; +/// fps und tile machen daraus einen Durchlauf, der fertige +/// Blätter ausgibt. +/// +/// +/// -skip_frame nokey ist der Grund, warum das bezahlbar ist. +/// 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. +/// +/// +/// Erst .teil, dann umbenennen — wie beim Untertitelziehen. +/// Bricht der Lauf ab, bleibt kein halbes Blatt liegen, das später für +/// fertig gehalten würde. +/// +/// +public sealed class Vorschaubilder(FfmpegOptions optionen, ILogger log) +{ + /// Abstand zweier Bilder in Sekunden. + public const double Intervall = 10; + + /// Kacheln je Blatt — zehn mal zehn, wie bei Jellyfin und Plex. + public const int Spalten = 10; + public const int Zeilen = 10; + + /// Breite einer Kachel. Die Höhe folgt dem Seitenverhältnis. + public const int Breite = 320; + + /// Wie lange ein ganzer Film höchstens dauern darf. + /// + /// + /// 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. + /// + private static readonly TimeSpan Frist = TimeSpan.FromMinutes(20); + + /// Zieht alle Blätter und sagt, wie sie aussehen. + /// + /// + /// Der gemeinsame Namensanfang, etwa {dateiId:N}-{geaendertMs}. + /// Die Blätter heissen {praefix}-000.jpg und so fort — damit + /// ist ohne Datenbanktabelle entscheidbar, was zu einer Datei gehört + /// und was von einer älteren Fassung übrig ist. + /// + public async Task 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); + } + + /// Die Masse eines Blattes, von ffprobe. + 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; + } + } + + /// Halbe Blätter wegräumen — sonst gelten sie später als fertig. + 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); +} diff --git a/server/tests/Flaro.Api.Tests/TranscodeTests.cs b/server/tests/Flaro.Api.Tests/TranscodeTests.cs index 4a5ba60..1ce6301 100644 --- a/server/tests/Flaro.Api.Tests/TranscodeTests.cs +++ b/server/tests/Flaro.Api.Tests/TranscodeTests.cs @@ -234,9 +234,9 @@ public sealed class TranscodeTests Assert.That(File.Exists(weg), Is.False); } - /// Die Übersicht nennt die drei Unterordner mit Grösse und Anzahl. + /// Die Übersicht nennt jeden Unterordner mit Grösse und Anzahl. [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"); diff --git a/server/tests/Flaro.Core.Tests/VorschauplanTests.cs b/server/tests/Flaro.Core.Tests/VorschauplanTests.cs new file mode 100644 index 0000000..3a7e04f --- /dev/null +++ b/server/tests/Flaro.Core.Tests/VorschauplanTests.cs @@ -0,0 +1,101 @@ +using Flaro.Core.Media; + +namespace Flaro.Core.Tests; + +/// Welche Kachel eine Stelle zeigt. +/// +/// +/// Die Rechnung steht hier, weil beide Spieler sie brauchen. 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. +/// +public class VorschauplanTests +{ + /// Hundert Kacheln je Blatt, alle zehn Sekunden eine. + 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))); + } + + /// Innerhalb eines Intervalls gilt dieselbe Kachel. + [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))); + } + + /// Nach zehn Kacheln beginnt die zweite Zeile, nicht das zweite Blatt. + [Test] + public void Die_elfte_Kachel_steht_eine_Zeile_tiefer() + { + Assert.That(Plan.Kachel(100), Is.EqualTo(new Vorschaukachel(0, 0, 1))); + } + + /// Die Ränder eines Blattes — dort verrechnet man sich. + [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))); + }); + } + + /// Hinter dem letzten Bild bleibt das letzte Bild stehen. + /// + /// + /// Das Raster endet, wo ffmpeg aufgehört hat. Eine Sekunde darüber + /// hinaus — bei ungenauer Laufzeit durchaus möglich — soll die + /// Vorschau nicht verschwinden. + /// + [Test] + public void Hinter_dem_Ende_gilt_die_letzte_Kachel() + { + Assert.That(Plan.Kachel(99_999), Is.EqualTo(new Vorschaukachel(2, 9, 9))); + } + + /// Eine negative Stelle gibt es nicht, und sie stürzt auch nicht ab. + [Test] + public void Vor_dem_Anfang_gilt_die_erste_Kachel() + { + Assert.That(Plan.Kachel(-30), Is.EqualTo(new Vorschaukachel(0, 0, 0))); + } + + /// Ohne Blätter gibt es nichts zu zeigen. + [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)); + }); + } +} diff --git a/server/tests/Flaro.Media.Tests/VorschaubilderTests.cs b/server/tests/Flaro.Media.Tests/VorschaubilderTests.cs new file mode 100644 index 0000000..0f29d14 --- /dev/null +++ b/server/tests/Flaro.Media.Tests/VorschaubilderTests.cs @@ -0,0 +1,109 @@ +using System.Diagnostics; +using Flaro.Media; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Flaro.Media.Tests; + +/// Die Blätter der Vorschau — Zahl, Masse, Namen. +/// +/// +/// +/// Die einzige Prüfung hier, die ffmpeg braucht, 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. +/// +/// +/// 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. +/// +/// +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.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"); + }); + } + + /// Eine Datei, die es nicht gibt, ergibt nichts — und wirft nicht. + [Test] + public async Task Ohne_Quelle_kein_Plan() + { + var bilder = new Vorschaubilder( + FfmpegOptions.FromEnvironment(), NullLogger.Instance); + + var plan = await bilder.ErzeugenAsync( + Path.Combine(_ordner, "gibtsnicht.mkv"), Path.Combine(_ordner, "leer"), "x"); + + Assert.That(plan, Is.Null); + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index f282342..511d132 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -1252,6 +1252,27 @@ export const protokolltage = () => anfrage('/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(`/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}` : ''); diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts index a8198c6..9a2f7d5 100644 --- a/web/app/src/spieler.ts +++ b/web/app/src/spieler.ts @@ -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; diff --git a/web/app/src/style.css b/web/app/src/style.css index a14efec..dcaa156 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -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 {