Ein Bild, wo bisher nur eine Zahl stand
Wer zur Zeitleiste greift, sieht jetzt das Standbild der Stelle — im Web beim Überfahren, in der App beim Ziehen. **Erzeugt wird beim Abspielen, nicht auf Vorrat.** Ein Lauf über 453 Filme und 4213 Folgen wäre ein Abend ffmpeg und mehrere Gigabyte für Titel, die niemand anfasst. Startet jemand einen Titel, stellt sich die Datei hinten an; bis zum ersten Griff zur Leiste ist der Lauf durch. Gemessen an einer erzeugten Datei: 191 ms für 250 Sekunden Film, ein Zweistünder liegt damit bei etwa fünf Sekunden. **Ein ffmpeg-Aufruf, nicht siebenhundert.** `fps=1/10,scale=320:-2, tile=10x10` macht aus einem Durchlauf fertige Blätter zu hundert Kacheln. Bezahlbar ist das durch `-skip_frame nokey` — dieselbe Abkürzung wie beim Abtasten der Abspannhelligkeit: nur Keyframes dekodieren. Dass die Vorschau dadurch um zwei Sekunden danebenliegen kann, sieht niemand. **Der Spieler lädt ein Blatt und verschiebt danach nur den Ausschnitt.** Beim Fahren über die Leiste wird kein einziger Abruf mehr fällig. Die Rechnung Sekunde → Kachel steht an drei Stellen — Server, Web, App — und deshalb auch dreimal dieselbe; weicht eine ab, hält man die Vorschau für ein unscharfes Bild statt für einen Fehler. Neun Prüfungen decken die Ränder ab: Sekunde null, letzte Kachel eines Blattes, erste des nächsten, und hinter dem Ende. **Die Kachelmasse werden gemessen, nicht gerechnet.** `scale=320:-2` hält das Seitenverhältnis, ein Breitbildfilm ergibt also flachere Kacheln. Geraten wäre jede Kachel um ein paar Bildpunkte verschoben. Aufgeräumt wird mit dem Transcode-Cache: der Namensanfang trägt Kennung und Änderungszeit der Mediendatei, verwaiste Blätter sind daran ohne Tabelle erkennbar. Der Ordner taucht unter „Speicher" auf. **Und die Pfeiltasten spulen wieder.** Hatte die Zeitleiste den Fokus, überliess der Spieler die Pfeile dem Browser — der verschiebt `value` und löst `input` aus, aber `change` kommt bei Tastatureingabe nicht verlässlich, also wurde nie gesprungen. Die Ausnahme fällt weg; Pfeil heisst überall ±10 Sekunden. Nativ wären es ohnehin 100 Millisekunden gewesen, weil `step` auf 0,1 steht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ed13d5fa43
commit
68abd71074
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
/// Der Regler unter dem Film — mit dem, was schon geladen ist.
|
/// 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.
|
/// Bei jeder Berührung — damit die Bedienung stehen bleibt.
|
||||||
let onActivity: () -> Void
|
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
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
private static let trackHeight: CGFloat = 5
|
private static let trackHeight: CGFloat = 5
|
||||||
@@ -71,6 +77,22 @@ struct PlaybackScrubber: View {
|
|||||||
.offset(x: gespielt - Self.thumbSize / 2)
|
.offset(x: gespielt - Self.thumbSize / 2)
|
||||||
.animation(.easeOut(duration: 0.15), value: scrubbing == nil)
|
.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)
|
.frame(height: Self.hitHeight)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
.gesture(
|
.gesture(
|
||||||
@@ -117,3 +139,75 @@ struct PlaybackScrubber: View {
|
|||||||
return min(max(Double(x / width), 0), 1) * duration.seconds
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ final class PlayerModel {
|
|||||||
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
|
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
|
||||||
/// Überspringen-Knopf.
|
/// Überspringen-Knopf.
|
||||||
private(set) var activeMarker: MediaMarker?
|
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,
|
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
|
||||||
/// damit sie am Ende ohne Wartezeit bereitsteht.
|
/// damit sie am Ende ohne Wartezeit bereitsteht.
|
||||||
private(set) var nextEpisode: MediaItem?
|
private(set) var nextEpisode: MediaItem?
|
||||||
@@ -598,6 +606,7 @@ final class PlayerModel {
|
|||||||
|
|
||||||
await load(source, decision: decision)
|
await load(source, decision: decision)
|
||||||
preloadNextEpisode()
|
preloadNextEpisode()
|
||||||
|
vorschauHolen()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stellt auf eine andere Qualitätsstufe um.
|
/// 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
|
/// Scheitert es — kein Server, kein Netz —, bleibt es, wie es war: die
|
||||||
/// Datei läuft, es gibt bloss keine Marken und keine Nachbarfolgen.
|
/// 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() {
|
private func metadatenNachladen() {
|
||||||
guard item == nil, let backend else { return }
|
guard item == nil, let backend else { return }
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
@@ -771,6 +810,7 @@ final class PlayerModel {
|
|||||||
self.item = geholt
|
self.item = geholt
|
||||||
self.updateActiveMarker()
|
self.updateActiveMarker()
|
||||||
self.preloadNextEpisode()
|
self.preloadNextEpisode()
|
||||||
|
self.vorschauHolen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -730,7 +730,9 @@ struct PlayerView: View {
|
|||||||
// springen lässt das Bild flackern und hält bei großen
|
// springen lässt das Bild flackern und hält bei großen
|
||||||
// Dateien den Puffer dauerhaft leer.
|
// Dateien den Puffer dauerhaft leer.
|
||||||
onCommit: { model.seek(to: $0) },
|
onCommit: { model.seek(to: $0) },
|
||||||
onActivity: { scheduleControlsHide() }
|
onActivity: { scheduleControlsHide() },
|
||||||
|
vorschau: model.vorschau,
|
||||||
|
blatt: { model.vorschaublatt($0) }
|
||||||
)
|
)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
@@ -308,6 +308,32 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
/// liefert, und rechnet nicht um. Ein Plakat wiegt achtzig Kilobyte; ein
|
/// liefert, und rechnet nicht um. Ein Plakat wiegt achtzig Kilobyte; ein
|
||||||
/// Server, der jede Kachel einzeln skaliert, spart Leitung und kostet
|
/// Server, der jede Kachel einzeln skaliert, spart Leitung und kostet
|
||||||
/// Rechenzeit, die auf einem NAS knapper ist.
|
/// 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? {
|
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
||||||
rawImageURL(path: path)
|
rawImageURL(path: path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,3 +261,16 @@ enum Wire {
|
|||||||
struct FavouriteRequest: Encodable, Sendable { let favourite: Bool }
|
struct FavouriteRequest: Encodable, Sendable { let favourite: Bool }
|
||||||
struct RatingRequest: Encodable, Sendable { let rating: Int? }
|
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.
|
/// deshalb nicht unbaubar werden.
|
||||||
func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
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
|
// MARK: - Bilder
|
||||||
|
|
||||||
/// Ein Bild in der gewünschten Größe.
|
/// Ein Bild in der gewünschten Größe.
|
||||||
@@ -240,6 +246,16 @@ public extension MediaBackend {
|
|||||||
/// Vorgabe: kein Weg zurück.
|
/// Vorgabe: kein Weg zurück.
|
||||||
func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? { nil }
|
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.
|
/// Vorgabe: der Server kann es nicht.
|
||||||
///
|
///
|
||||||
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm 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> Audio,
|
||||||
IReadOnlyList<Sprachangabe> Subtitles,
|
IReadOnlyList<Sprachangabe> Subtitles,
|
||||||
int Partial);
|
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 (
|
app.MapPost("/api/v1/files/{id:guid}/stream", async (
|
||||||
Guid id, StreamRequest anfrage, ClaimsPrincipal wer,
|
Guid id, StreamRequest anfrage, ClaimsPrincipal wer,
|
||||||
Streamkoordinator koordinator, FlaroContext db, TimeProvider uhr,
|
Streamkoordinator koordinator, FlaroContext db, TimeProvider uhr,
|
||||||
Untertitelvorrat vorrat) =>
|
Untertitelvorrat vorrat, Vorschauvorrat vorschau) =>
|
||||||
{
|
{
|
||||||
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||||
|
|
||||||
@@ -84,6 +84,13 @@ public static class StreamEndpoints
|
|||||||
// Antwort statt eines Fehlers.
|
// Antwort statt eines Fehlers.
|
||||||
vorrat.Anstellen(gewuenschte.Id, vordraengeln: true);
|
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 profil = Profil(anfrage);
|
||||||
var hoehe = anfrage.Height is { } gewuenscht
|
var hoehe = anfrage.Height is { } gewuenscht
|
||||||
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null;
|
&& 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
|
// 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
|
// im Spieler: bei „Fortsetzen ab 8:37" stand der Satz von Sekunde 0
|
||||||
// auf dem Bild.
|
// 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 (
|
app.MapGet("/api/v1/files/{id:guid}/subtitles/{spur:int}.vtt", async (
|
||||||
Guid id, int spur, FlaroContext db, Untertitelvorrat vorrat,
|
Guid id, int spur, FlaroContext db, Untertitelvorrat vorrat,
|
||||||
CancellationToken ct, double ab = 0) =>
|
CancellationToken ct, double ab = 0) =>
|
||||||
|
|||||||
@@ -184,6 +184,8 @@ builder.Services.AddSingleton<PackageCoordinator>();
|
|||||||
builder.Services.AddSingleton<Streamkoordinator>();
|
builder.Services.AddSingleton<Streamkoordinator>();
|
||||||
builder.Services.AddSingleton<UntertitelLeser>();
|
builder.Services.AddSingleton<UntertitelLeser>();
|
||||||
builder.Services.AddSingleton<Untertitelvorrat>();
|
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.
|
// Der Transcode-Cache räumt sich auf — kein Dienst, kein Takt, vier Anlässe.
|
||||||
builder.Services.AddScoped<Transcodeeinstellungen>();
|
builder.Services.AddScoped<Transcodeeinstellungen>();
|
||||||
builder.Services.AddSingleton<Transcodepflege>();
|
builder.Services.AddSingleton<Transcodepflege>();
|
||||||
|
|||||||
@@ -65,11 +65,14 @@ public sealed class Transcodepflege(
|
|||||||
var (paketfrei, paketzahl) = await PaketeAufraeumenAsync(jetzt, ct);
|
var (paketfrei, paketzahl) = await PaketeAufraeumenAsync(jetzt, ct);
|
||||||
var (textfrei, textzahl) = await UntertitelAufraeumenAsync(ct);
|
var (textfrei, textzahl) = await UntertitelAufraeumenAsync(ct);
|
||||||
|
|
||||||
|
var (vorschaufrei, vorschauzahl) = await VorschauAufraeumenAsync(ct);
|
||||||
|
|
||||||
var teile = new List<Teilbericht>
|
var teile = new List<Teilbericht>
|
||||||
{
|
{
|
||||||
new("stroeme", stromfrei, 0),
|
new("stroeme", stromfrei, 0),
|
||||||
new("packages", paketfrei, paketzahl),
|
new("packages", paketfrei, paketzahl),
|
||||||
new("untertitel", textfrei, textzahl),
|
new("untertitel", textfrei, textzahl),
|
||||||
|
new("vorschau", vorschaufrei, vorschauzahl),
|
||||||
};
|
};
|
||||||
|
|
||||||
var bericht = new Aufraeumbericht(
|
var bericht = new Aufraeumbericht(
|
||||||
@@ -159,6 +162,66 @@ public sealed class Transcodepflege(
|
|||||||
/// lässt den Server bei jedem Start eine vierzig Gigabyte grosse Datei
|
/// lässt den Server bei jedem Start eine vierzig Gigabyte grosse Datei
|
||||||
/// neu durchlesen, um wieder nichts zu finden.
|
/// neu durchlesen, um wieder nichts zu finden.
|
||||||
/// </remarks>
|
/// </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)
|
private async Task<(long Frei, int Dateien)> UntertitelAufraeumenAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var ordner = Path.Combine(pfade.Transcode, "untertitel");
|
var ordner = Path.Combine(pfade.Transcode, "untertitel");
|
||||||
@@ -240,6 +303,7 @@ public sealed class Transcodepflege(
|
|||||||
("stroeme", stroeme.Wurzel),
|
("stroeme", stroeme.Wurzel),
|
||||||
("packages", pakete.Ordner),
|
("packages", pakete.Ordner),
|
||||||
("untertitel", Path.Combine(pfade.Transcode, "untertitel")),
|
("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)))
|
return ordner.Select(o => new Teilbericht(o.Item1, Streamkoordinator.Belegung(o.Item2), Zaehlen(o.Item2)))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
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]
|
[Test]
|
||||||
public async Task Die_Belegung_nennt_die_drei_Unterordner()
|
public async Task Die_Belegung_nennt_die_Unterordner()
|
||||||
{
|
{
|
||||||
using var server = new FlaroServer();
|
using var server = new FlaroServer();
|
||||||
var (client, kratz, dateiId) = await Aufgesetzt(server);
|
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, Is.Not.Null);
|
||||||
Assert.That(transcode.Teile!.Select(t => t.Name),
|
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");
|
var pakete = transcode.Teile!.First(t => t.Name == "packages");
|
||||||
Assert.That(pakete.Dateien, Is.EqualTo(1));
|
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");
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1252,6 +1252,27 @@ export const protokolltage = () => anfrage<Protokolltag[]>('/system/logs');
|
|||||||
*
|
*
|
||||||
* Mit `zeilen` kommt nur das Ende — das ist, was die Ansicht braucht. Ohne
|
* Mit `zeilen` kommt nur das Ende — das ist, was die Ansicht braucht. Ohne
|
||||||
* kommt alles, und das ist der Weg zum Herunterladen. */
|
* 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) =>
|
export const protokollAdresse = (tag: string, zeilen?: number) =>
|
||||||
`/api/v1/system/logs/${tag}` + (zeilen ? `?tail=${zeilen}` : '');
|
`/api/v1/system/logs/${tag}` + (zeilen ? `?tail=${zeilen}` : '');
|
||||||
|
|
||||||
|
|||||||
+77
-2
@@ -1,6 +1,7 @@
|
|||||||
import type HlsTyp from 'hls.js';
|
import type HlsTyp from 'hls.js';
|
||||||
import {
|
import {
|
||||||
stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, segmenteHolen,
|
stromStarten, stromBeenden, untertitelHolen, fortschritt, nachbarfolge, segmenteHolen,
|
||||||
|
vorschauauskunft, vorschaublattAdresse, type Vorschauauskunft,
|
||||||
type Stromauskunft, type Spurauskunft, type Folge, type Segment,
|
type Stromauskunft, type Spurauskunft, type Folge, type Segment,
|
||||||
} from './api';
|
} from './api';
|
||||||
import { dauer, knopf, meldung, hinweisZeigen, folgenname } from './util';
|
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,
|
// gibt, ist keiner. Jetzt liegt er als eigene Schicht über dem Bild,
|
||||||
// rechts unten, und erscheint von selbst — bis man die Bedienung einmal
|
// rechts unten, und erscheint von selbst — bis man die Bedienung einmal
|
||||||
// eingeblendet hat, danach kommt und geht er mit ihr.
|
// 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
|
// **Beide Nachbarn gleich zu Beginn holen.** Zwei kleine Anfragen, einmal
|
||||||
// je Wiedergabe. Erst beim Antippen zu fragen hiesse, einen Knopf zu
|
// 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.
|
// Regler stehen, bis jemand ihn wieder anfasst.
|
||||||
zeitleiste.onpointercancel = () => { amRegler = false; };
|
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. */
|
/** Die Gesamtlänge des Films — nicht die des Stroms. */
|
||||||
const gesamt = anfrage.laenge ?? 0;
|
const gesamt = anfrage.laenge ?? 0;
|
||||||
|
|
||||||
@@ -1309,7 +1377,14 @@ export function spieler(anfrage: Spieleranfrage): void {
|
|||||||
/* ---------- Tastatur ---------- */
|
/* ---------- Tastatur ---------- */
|
||||||
|
|
||||||
function tastendruck(e: KeyboardEvent): void {
|
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) {
|
switch (e.key) {
|
||||||
case ' ': case 'k': e.preventDefault(); umschalten(); break;
|
case ' ': case 'k': e.preventDefault(); umschalten(); break;
|
||||||
|
|||||||
@@ -987,6 +987,35 @@ textarea.feld {
|
|||||||
height: 20px; background: transparent; cursor: pointer; margin: 0;
|
height: 20px; background: transparent; cursor: pointer; margin: 0;
|
||||||
}
|
}
|
||||||
.pl-zeit { width: 100%; }
|
.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-laut { width: 84px; flex: 0 0 auto; }
|
||||||
|
|
||||||
.pl-zeit::-webkit-slider-runnable-track, .pl-laut::-webkit-slider-runnable-track {
|
.pl-zeit::-webkit-slider-runnable-track, .pl-laut::-webkit-slider-runnable-track {
|
||||||
|
|||||||
Reference in New Issue
Block a user