Compare commits

..

No commits in common. "a2891a2cf5d60ea2fd2a3d9eada8421b0713a1c7" and "e8bd9e9d680adeca04ab43a068cdc80e3c0eb387" have entirely different histories.

15 changed files with 96 additions and 613 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -41,19 +41,6 @@ func u64ToI64(x uint64) int64 {
return int64(x) return int64(x)
} }
func isFFmpegInputInvalidError(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
// typische ffmpeg/mp4 Fehler bei unvollständigen Dateien
return strings.Contains(s, "moov atom not found") ||
strings.Contains(s, "invalid data found when processing input") ||
strings.Contains(s, "error opening input file") ||
strings.Contains(s, "error opening input")
}
// ------------------------- // -------------------------
// Asset layout helpers // Asset layout helpers
// ------------------------- // -------------------------
@ -189,7 +176,6 @@ func ensureAssetsForVideoWithProgressCtx(ctx context.Context, videoPath string,
// Core: generiert thumbs/preview/sprite/meta und sagt zurück was passiert ist. // Core: generiert thumbs/preview/sprite/meta und sagt zurück was passiert ist.
func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceURL string, onRatio func(r float64)) (EnsureAssetsResult, error) { func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceURL string, onRatio func(r float64)) (EnsureAssetsResult, error) {
var out EnsureAssetsResult var out EnsureAssetsResult
var sourceInputInvalid bool
videoPath = strings.TrimSpace(videoPath) videoPath = strings.TrimSpace(videoPath)
if videoPath == "" { if videoPath == "" {
@ -201,12 +187,6 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
return out, nil return out, nil
} }
// 🔒 Schutz gegen Race: sehr frische Dateien sind evtl. noch nicht finalisiert/kopiert
// (typisch: moov atom fehlt noch)
if time.Since(fi.ModTime()) < 10*time.Second {
return out, nil
}
id := assetIDFromVideoPath(videoPath) id := assetIDFromVideoPath(videoPath)
if id == "" { if id == "" {
return out, nil return out, nil
@ -346,7 +326,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
progress(thumbsW + 0.05) progress(thumbsW + 0.05)
if err := generateTeaserClipsMP4WithProgress(genCtx, videoPath, previewPath, 0.75, 12, func(r float64) { if err := generateTeaserClipsMP4WithProgress(genCtx, videoPath, previewPath, 1.0, 18, func(r float64) {
if r < 0 { if r < 0 {
r = 0 r = 0
} }
@ -355,12 +335,6 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
} }
progress(thumbsW + r*previewW) progress(thumbsW + r*previewW)
}); err != nil { }); err != nil {
if isFFmpegInputInvalidError(err) {
sourceInputInvalid = true
fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath)
return
}
fmt.Println("⚠️ preview clips:", err) fmt.Println("⚠️ preview clips:", err)
return return
} }
@ -427,10 +401,6 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
} }
} else { } else {
func() { func() {
if sourceInputInvalid {
return
}
// nur sinnvoll wenn wir Dauer kennen // nur sinnvoll wenn wir Dauer kennen
if !(meta.durSec > 0) { if !(meta.durSec > 0) {
return return

View File

@ -90,15 +90,8 @@ func generatePreviewSpriteWebP(
return fmt.Errorf("mkdir sprite dir: %w", err) return fmt.Errorf("mkdir sprite dir: %w", err)
} }
// Temp-Datei im gleichen Verzeichnis für atomaren Replace. // Temp-Datei im gleichen Verzeichnis für atomaren Replace
// Wichtig: ffmpeg erkennt das Output-Format über die Endung. tmpPath := outPath + ".tmp"
// Deshalb muss .webp am Ende stehen (nicht "...webp.tmp").
ext := filepath.Ext(outPath)
if ext == "" {
ext = ".webp"
}
base := strings.TrimSuffix(outPath, ext)
tmpPath := base + ".tmp" + ext // z.B. preview-sprite.tmp.webp
// fps=1/stepSec nimmt alle stepSec Sekunden einen Frame // fps=1/stepSec nimmt alle stepSec Sekunden einen Frame
// scale+pad erzwingt feste Zellgröße (wichtig für korrektes background-positioning im Frontend) // scale+pad erzwingt feste Zellgröße (wichtig für korrektes background-positioning im Frontend)
@ -127,7 +120,6 @@ func generatePreviewSpriteWebP(
"-lossless", "0", "-lossless", "0",
"-compression_level", "6", "-compression_level", "6",
"-q:v", "80", "-q:v", "80",
"-f", "webp",
tmpPath, tmpPath,
) )

View File

@ -1,6 +0,0 @@
{
"name": "backend",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@ -1,5 +1,3 @@
// backend\preview_teaser.go
package main package main
import ( import (
@ -16,8 +14,7 @@ import (
) )
// Minimale Segmentdauer, damit ffmpeg nicht mit zu kurzen Schnipseln zickt. // Minimale Segmentdauer, damit ffmpeg nicht mit zu kurzen Schnipseln zickt.
const minSegmentDuration = 0.75 // Sekunden const minSegmentDuration = 0.50 // Sekunden
const defaultTeaserSegments = 12
type TeaserPreviewOptions struct { type TeaserPreviewOptions struct {
Segments int Segments int
@ -174,7 +171,7 @@ func computeTeaserStarts(dur float64, opts TeaserPreviewOptions) (starts []float
opts.SegmentDuration = 1 opts.SegmentDuration = 1
} }
if opts.Segments <= 0 { if opts.Segments <= 0 {
opts.Segments = defaultTeaserSegments opts.Segments = 18
} }
segDur = opts.SegmentDuration segDur = opts.SegmentDuration
if segDur < minSegmentDuration { if segDur < minSegmentDuration {
@ -230,7 +227,7 @@ func generateTeaserPreviewMP4WithProgress(
opts.SegmentDuration = 1 opts.SegmentDuration = 1
} }
if opts.Segments <= 0 { if opts.Segments <= 0 {
opts.Segments = defaultTeaserSegments opts.Segments = 18
} }
if opts.Width <= 0 { if opts.Width <= 0 {
opts.Width = 640 opts.Width = 640

File diff suppressed because one or more lines are too long

View File

@ -64,6 +64,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@ -1823,6 +1824,7 @@
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@ -1833,6 +1835,7 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@ -1892,6 +1895,7 @@
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0", "@typescript-eslint/types": "8.50.0",
@ -2200,6 +2204,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -2317,6 +2322,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -2573,6 +2579,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -3621,6 +3628,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -3713,6 +3721,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -3722,6 +3731,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@ -3949,6 +3959,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -4043,6 +4054,7 @@
"resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.4.tgz", "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.4.tgz",
"integrity": "sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ==", "integrity": "sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@videojs/http-streaming": "^3.17.2", "@videojs/http-streaming": "^3.17.2",
@ -4094,6 +4106,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -4215,6 +4228,7 @@
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@ -1023,16 +1023,7 @@ export default function FinishedDownloads({
}) })
}, []) }, [])
const clearRenamePair = useCallback((a: string, b: string) => {
if (!a && !b) return
setRenamedFiles((prev) => {
const next: Record<string, string> = { ...prev }
for (const [k, v] of Object.entries(next)) {
if (k === a || k === b || v === a || v === b) delete next[k]
}
return next
})
}, [])
const undoLastAction = useCallback(async () => { const undoLastAction = useCallback(async () => {
if (!lastAction || undoing) return if (!lastAction || undoing) return
@ -1215,7 +1206,7 @@ export default function FinishedDownloads({
queueRefill() queueRefill()
} catch (e: any) { } catch (e: any) {
// ❌ Rollback, weil Optimistik schon angewendet wurde // ❌ Rollback, weil Optimistik schon angewendet wurde
clearRenamePair(oldFile, optimisticNew) applyRename(optimisticNew, oldFile)
// und Undo-Action löschen (sonst zeigt Undo auf etwas, das nie passiert ist) // und Undo-Action löschen (sonst zeigt Undo auf etwas, das nie passiert ist)
setLastAction(null) setLastAction(null)
@ -1223,7 +1214,7 @@ export default function FinishedDownloads({
notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e)) notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e))
} }
}, },
[notify, applyRename, clearRenamePair, releasePlayingFile, onToggleHot, queueRefill, sortMode] [notify, applyRename, releasePlayingFile, onToggleHot, queueRefill, sortMode]
) )
const applyRenamedOutput = useCallback( const applyRenamedOutput = useCallback(

View File

@ -13,7 +13,6 @@ import {
} from '@heroicons/react/24/solid' } from '@heroicons/react/24/solid'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow' import TagOverflowRow from './TagOverflowRow'
import PreviewScrubber from './PreviewScrubber'
import { isHotName, stripHotPrefix } from './hotName' import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters' import { formatResolution } from './formatters'
@ -64,15 +63,7 @@ type Props = {
releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise<void> releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise<void>
modelsByKey: Record<string, { modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean | null; tags?: string }>
favorite?: boolean
liked?: boolean | null
watching?: boolean | null
tags?: string
// ✅ wie GalleryView: optionale Scrubber-Meta-Fallbacks
previewScrubberPath?: string
previewScrubberCount?: number
}>
activeTagSet: Set<string> activeTagSet: Set<string>
onToggleTagFilter: (tag: string) => void onToggleTagFilter: (tag: string) => void
@ -101,58 +92,6 @@ const parseTags = (raw?: string): string[] => {
return out return out
} }
function firstNonEmptyString(...values: unknown[]): string | undefined {
for (const v of values) {
if (typeof v === 'string') {
const s = v.trim()
if (s) return s
}
}
return undefined
}
function normalizeDurationSeconds(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
// ms -> s Heuristik wie in GalleryView / FinishedVideoPreview
return value > 24 * 60 * 60 ? value / 1000 : value
}
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n))
}
const DEFAULT_SPRITE_STEP_SECONDS = 5
function chooseSpriteGrid(count: number): [number, number] {
if (count <= 1) return [1, 1]
const targetRatio = 16 / 9
let bestCols = 1
let bestRows = count
let bestWaste = Number.POSITIVE_INFINITY
let bestRatioScore = Number.POSITIVE_INFINITY
for (let c = 1; c <= count; c++) {
const r = Math.max(1, Math.ceil(count / c))
const waste = c * r - count
const ratio = c / r
const ratioScore = Math.abs(ratio - targetRatio)
if (
waste < bestWaste ||
(waste === bestWaste && ratioScore < bestRatioScore) ||
(waste === bestWaste && ratioScore === bestRatioScore && r < bestRows)
) {
bestWaste = waste
bestRatioScore = ratioScore
bestCols = c
bestRows = r
}
}
return [bestCols, bestRows]
}
export default function FinishedDownloadsCardsView({ export default function FinishedDownloadsCardsView({
rows, rows,
isSmall, isSmall,
@ -161,7 +100,6 @@ export default function FinishedDownloadsCardsView({
teaserAudio, teaserAudio,
hoverTeaserKey, hoverTeaserKey,
blurPreviews, blurPreviews,
durations, // ✅ fehlte
teaserKey, teaserKey,
inlinePlay, inlinePlay,
deletingKeys, deletingKeys,
@ -185,7 +123,6 @@ export default function FinishedDownloadsCardsView({
startInline, startInline,
tryAutoplayInline, tryAutoplayInline,
registerTeaserHost, registerTeaserHost,
handleDuration,
deleteVideo, deleteVideo,
keepVideo, keepVideo,
@ -199,102 +136,19 @@ export default function FinishedDownloadsCardsView({
onToggleLike, onToggleLike,
onToggleWatch, onToggleWatch,
}: Props) { }: Props) {
// ✅ Auflösung als {w,h} aus meta.json bevorzugen
const parseMeta = React.useCallback((j: RecordJob): any | null => {
const raw: any = (j as any)?.meta
if (!raw) return null
if (typeof raw === 'string') {
try {
return JSON.parse(raw)
} catch {
return null
}
}
return raw
}, [])
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => { const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
const meta = parseMeta(j)
const w = const w =
(typeof meta?.videoWidth === 'number' && Number.isFinite(meta.videoWidth) ? meta.videoWidth : 0) || (typeof j.meta?.videoWidth === 'number' && Number.isFinite(j.meta.videoWidth) ? j.meta.videoWidth : 0) ||
(typeof j.videoWidth === 'number' && Number.isFinite(j.videoWidth) ? j.videoWidth : 0) (typeof j.videoWidth === 'number' && Number.isFinite(j.videoWidth) ? j.videoWidth : 0)
const h = const h =
(typeof meta?.videoHeight === 'number' && Number.isFinite(meta.videoHeight) ? meta.videoHeight : 0) || (typeof j.meta?.videoHeight === 'number' && Number.isFinite(j.meta.videoHeight) ? j.meta.videoHeight : 0) ||
(typeof j.videoHeight === 'number' && Number.isFinite(j.videoHeight) ? j.videoHeight : 0) (typeof j.videoHeight === 'number' && Number.isFinite(j.videoHeight) ? j.videoHeight : 0)
if (w > 0 && h > 0) return { w, h } if (w > 0 && h > 0) return { w, h }
return null return null
}, [parseMeta])
const previewScrubberInfoOf = React.useCallback(
(j: RecordJob): { count: number; stepSeconds: number } | null => {
const meta = parseMeta(j)
let ps: any =
meta?.previewSprite ??
meta?.preview?.sprite ??
meta?.sprite ??
(j as any)?.previewSprite ??
(j as any)?.preview?.sprite ??
null
if (typeof ps === 'string') {
try {
ps = JSON.parse(ps)
} catch {
ps = null
}
}
const countRaw = Number(ps?.count ?? ps?.frames ?? ps?.imageCount)
const stepRaw = Number(ps?.stepSeconds ?? ps?.step ?? ps?.intervalSeconds)
if (Number.isFinite(countRaw) && countRaw >= 2) {
return {
count: Math.max(2, Math.floor(countRaw)),
stepSeconds: Number.isFinite(stepRaw) && stepRaw > 0 ? stepRaw : 5,
}
}
// Fallback: aus Dauer ableiten
const k = keyFor(j)
const dur =
durations[k] ??
(typeof (j as any)?.durationSeconds === 'number' ? (j as any).durationSeconds : undefined)
if (typeof dur === 'number' && Number.isFinite(dur) && dur > 1) {
const stepSeconds = 5
const count = Math.max(2, Math.min(240, Math.floor(dur / stepSeconds) + 1))
return { count, stepSeconds }
}
return null
},
[parseMeta, keyFor, durations]
)
const [scrubActiveByKey, setScrubActiveByKey] = React.useState<Record<string, number | undefined>>({})
const setScrubActiveIndex = React.useCallback((key: string, index: number | undefined) => {
setScrubActiveByKey((prev) => {
if (index === undefined) {
if (!(key in prev)) return prev
const next = { ...prev }
delete next[key]
return next
}
if (prev[key] === index) return prev
return { ...prev, [key]: index }
})
}, []) }, [])
const clearScrubActiveIndex = React.useCallback((key: string) => {
setScrubActiveIndex(key, undefined)
}, [setScrubActiveIndex])
const renderCardItem = ( const renderCardItem = (
j: RecordJob, j: RecordJob,
opts?: { opts?: {
@ -337,129 +191,9 @@ export default function FinishedDownloadsCardsView({
const model = modelNameFromOutput(j.output) const model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '') const fileRaw = baseName(j.output || '')
const previewId = stripHotPrefix(fileRaw.replace(/\.[^.]+$/, '')).trim()
const modelKey = lower(model)
const flags = modelsByKey[modelKey]
const meta = parseMeta(j)
const spriteInfo = previewScrubberInfoOf(j)
const scrubActiveIndex = scrubActiveByKey[k]
// ✅ Sprite-Quelle wie in GalleryView (1 Request, danach nur CSS background-position)
const spritePathRaw = firstNonEmptyString(
meta?.previewSprite?.path,
(meta as any)?.previewSpritePath,
flags?.previewScrubberPath,
previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
)
const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined
const spriteStepSecondsRaw =
meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds
const spriteStepSeconds =
typeof spriteStepSecondsRaw === 'number' &&
Number.isFinite(spriteStepSecondsRaw) &&
spriteStepSecondsRaw > 0
? spriteStepSecondsRaw
: (spriteInfo?.stepSeconds && spriteInfo.stepSeconds > 0
? spriteInfo.stepSeconds
: DEFAULT_SPRITE_STEP_SECONDS)
// Dauer fallback (für count-Inferenz)
const durationForSprite =
normalizeDurationSeconds(meta?.durationSeconds) ??
normalizeDurationSeconds((j as any)?.durationSeconds) ??
normalizeDurationSeconds(durations[k])
const inferredSpriteCountFromDuration =
typeof durationForSprite === 'number' && durationForSprite > 0
? Math.max(1, Math.min(200, Math.floor(durationForSprite / spriteStepSeconds) + 1))
: undefined
const spriteCountRaw =
meta?.previewSprite?.count ??
(meta as any)?.previewSpriteCount ??
flags?.previewScrubberCount ??
inferredSpriteCountFromDuration
const spriteColsRaw = meta?.previewSprite?.cols ?? (meta as any)?.previewSpriteCols
const spriteRowsRaw = meta?.previewSprite?.rows ?? (meta as any)?.previewSpriteRows
const spriteCount =
typeof spriteCountRaw === 'number' && Number.isFinite(spriteCountRaw)
? Math.max(0, Math.floor(spriteCountRaw))
: 0
const [inferredCols, inferredRows] =
spriteCount > 1 ? chooseSpriteGrid(spriteCount) : [0, 0]
// ✅ explizite Werte robust lesen (auch wenn mal String kommt)
const explicitSpriteCols = Number(spriteColsRaw)
const explicitSpriteRows = Number(spriteRowsRaw)
// ✅ Nur übernehmen, wenn plausibel.
// Verhindert den Fall cols=1/rows=1 bei count>1 (zeigt sonst ganze Sprite-Map)
const explicitGridLooksValid =
Number.isFinite(explicitSpriteCols) &&
Number.isFinite(explicitSpriteRows) &&
explicitSpriteCols > 0 &&
explicitSpriteRows > 0 &&
!(spriteCount > 1 && explicitSpriteCols === 1 && explicitSpriteRows === 1) &&
(spriteCount <= 1 || explicitSpriteCols * explicitSpriteRows >= spriteCount)
const spriteCols = explicitGridLooksValid ? Math.floor(explicitSpriteCols) : inferredCols
const spriteRows = explicitGridLooksValid ? Math.floor(explicitSpriteRows) : inferredRows
const spriteVersion =
(typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix)
? meta.updatedAtUnix
: undefined) ??
(typeof (meta as any)?.fileModUnix === 'number' && Number.isFinite((meta as any).fileModUnix)
? (meta as any).fileModUnix
: undefined) ??
(assetNonce ?? 0)
const spriteUrl =
spritePath && spriteVersion
? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}`
: spritePath || undefined
const hasScrubberUi =
Boolean(spriteUrl) &&
spriteCount > 1
const hasSpriteScrubber =
hasScrubberUi &&
spriteCols > 0 &&
spriteRows > 0
const scrubberCount = hasScrubberUi ? spriteCount : 0
const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0
const spriteFrameStyle: React.CSSProperties | undefined =
hasSpriteScrubber && typeof scrubActiveIndex === 'number'
? (() => {
const idx = clamp(scrubActiveIndex, 0, Math.max(0, spriteCount - 1))
const col = idx % spriteCols
const row = Math.floor(idx / spriteCols)
const posX = spriteCols <= 1 ? 0 : (col / (spriteCols - 1)) * 100
const posY = spriteRows <= 1 ? 0 : (row / (spriteRows - 1)) * 100
return {
backgroundImage: `url("${spriteUrl}")`,
backgroundRepeat: 'no-repeat',
backgroundSize: `${spriteCols * 100}% ${spriteRows * 100}%`,
backgroundPosition: `${posX}% ${posY}%`,
}
})()
: undefined
const showScrubberSpriteInThumb = Boolean(spriteFrameStyle)
const hideTeaserUnderOverlay = showScrubberSpriteInThumb
const isHot = isHotName(fileRaw) const isHot = isHotName(fileRaw)
const flags = modelsByKey[lower(model)]
const isFav = Boolean(flags?.favorite) const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching) const isWatching = Boolean(flags?.watching)
@ -496,10 +230,7 @@ export default function FinishedDownloadsCardsView({
className={shellCls} className={shellCls}
onClick={isSmall ? undefined : () => openPlayer(j)} onClick={isSmall ? undefined : () => openPlayer(j)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
e.preventDefault()
onOpenPlayer(j)
}
}} }}
> >
{/* Card shell keeps backgrounds consistent */} {/* Card shell keeps backgrounds consistent */}
@ -516,10 +247,9 @@ export default function FinishedDownloadsCardsView({
onMouseEnter={ onMouseEnter={
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k) isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k)
} }
onMouseLeave={() => { onMouseLeave={
if (!isSmall && !opts?.disablePreviewHover) onHoverPreviewKeyChange?.(null) isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(null)
clearScrubActiveIndex(k) }
}}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
@ -528,13 +258,12 @@ export default function FinishedDownloadsCardsView({
}} }}
> >
{/* media */} {/* media */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg"> <div className="absolute inset-0">
{/* Meta-Overlay im Video nur anzeigen, wenn KEIN Inline-Player aktiv ist */} {/* Meta-Overlay im Video nur anzeigen, wenn KEIN Inline-Player aktiv ist */}
{!inlineActive ? ( {!inlineActive ? (
<div <div
className={ className={
'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' + 'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' +
'group-hover:opacity-0 group-focus-within:opacity-0 ' +
(inlineActive ? 'opacity-0' : 'opacity-100') (inlineActive ? 'opacity-0' : 'opacity-100')
} }
> >
@ -559,18 +288,13 @@ export default function FinishedDownloadsCardsView({
<FinishedVideoPreview <FinishedVideoPreview
job={j} job={j}
getFileName={(p) => stripHotPrefix(baseName(p))} getFileName={baseName}
className="h-full w-full" className="h-full w-full"
variant="fill"
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
onDuration={handleDuration}
showPopover={false} showPopover={false}
blur={inlineActive ? false : Boolean(blurPreviews)} blur={inlineActive ? false : Boolean(blurPreviews)}
animated={hideTeaserUnderOverlay ? false : allowTeaserAnimation} animated={allowTeaserAnimation}
animatedMode="teaser" animatedMode="teaser"
animatedTrigger="always" animatedTrigger="always"
clipSeconds={1}
thumbSamples={18}
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false} inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
inlineNonce={inlineNonce} inlineNonce={inlineNonce}
inlineControls={inlineActive} inlineControls={inlineActive}
@ -578,46 +302,13 @@ export default function FinishedDownloadsCardsView({
muted={previewMuted} muted={previewMuted}
popoverMuted={previewMuted} popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0} assetNonce={assetNonce ?? 0}
alwaysLoadStill={forceLoadStill} alwaysLoadStill={forceLoadStill || true}
teaserPreloadEnabled={opts?.mobileStackTopOnlyVideo ? true : !isSmall} teaserPreloadEnabled={
// ✅ im Mobile-Stack nur für Top-Card Teaser preloaden
opts?.mobileStackTopOnlyVideo ? true : !isSmall
}
teaserPreloadRootMargin={isSmall ? '900px 0px' : '700px 0px'} teaserPreloadRootMargin={isSmall ? '900px 0px' : '700px 0px'}
/> />
{/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */}
{hasSpriteScrubber && spriteUrl ? (
<img
src={spriteUrl}
alt=""
className="hidden"
loading="lazy"
decoding="async"
aria-hidden="true"
/>
) : null}
{/* ✅ Scrub-Frame Overlay via Sprite (kein Request pro Move) */}
{hasSpriteScrubber && spriteFrameStyle ? (
<div className="absolute inset-0 z-[5]" aria-hidden="true">
<div className="h-full w-full" style={spriteFrameStyle} />
</div>
) : null}
{/* ✅ stashapp-artiger Hover-Scrubber (wie GalleryView) */}
{!opts?.isDecorative && scrubberCount > 1 ? (
<div
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<PreviewScrubber
className="pointer-events-auto px-1"
imageCount={scrubberCount}
activeIndex={scrubActiveIndex}
onActiveIndexChange={(idx) => setScrubActiveIndex(k, idx)}
stepSeconds={scrubberStepSeconds}
/>
</div>
) : null}
</div> </div>
</div> </div>

View File

@ -13,7 +13,7 @@ import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow' import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName' import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters' import { formatResolution } from './formatters'
import PreviewScrubber from './PreviewScrubber' import GalleryPreviewScrubber from './GalleryPreviewScrubber'
type ModelFlags = { type ModelFlags = {
favorite?: boolean favorite?: boolean
@ -107,36 +107,6 @@ function clamp(n: number, min: number, max: number) {
const DEFAULT_SPRITE_STEP_SECONDS = 5 const DEFAULT_SPRITE_STEP_SECONDS = 5
function chooseSpriteGrid(count: number): [number, number] {
if (count <= 1) return [1, 1]
const targetRatio = 16 / 9
let bestCols = 1
let bestRows = count
let bestWaste = Number.POSITIVE_INFINITY
let bestRatioScore = Number.POSITIVE_INFINITY
for (let c = 1; c <= count; c++) {
const r = Math.max(1, Math.ceil(count / c))
const waste = c * r - count
const ratio = c / r
const ratioScore = Math.abs(ratio - targetRatio)
if (
waste < bestWaste ||
(waste === bestWaste && ratioScore < bestRatioScore) ||
(waste === bestWaste && ratioScore === bestRatioScore && r < bestRows)
) {
bestWaste = waste
bestRatioScore = ratioScore
bestCols = c
bestRows = r
}
}
return [bestCols, bestRows]
}
export default function FinishedDownloadsGalleryView({ export default function FinishedDownloadsGalleryView({
rows, rows,
isLoading, isLoading,
@ -307,14 +277,32 @@ export default function FinishedDownloadsGalleryView({
// ------------------------------------------------------------ // ------------------------------------------------------------
const spritePathRaw = firstNonEmptyString( const spritePathRaw = firstNonEmptyString(
meta?.previewSprite?.path, meta?.previewSprite?.path,
// optional weitere Fallback-Felder, falls du sie so speicherst:
(meta as any)?.previewSpritePath, (meta as any)?.previewSpritePath,
flags?.previewScrubberPath, // optional API-Fallback-Path (nur sinnvoll, wenn Backend existiert)
previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
) )
const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined
const spriteStepSecondsRaw = const spriteCountRaw = meta?.previewSprite?.count ?? (meta as any)?.previewSpriteCount
meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds const spriteColsRaw = meta?.previewSprite?.cols ?? (meta as any)?.previewSpriteCols
const spriteRowsRaw = meta?.previewSprite?.rows ?? (meta as any)?.previewSpriteRows
const spriteStepSecondsRaw = meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds
const spriteCount =
typeof spriteCountRaw === 'number' && Number.isFinite(spriteCountRaw)
? Math.max(0, Math.floor(spriteCountRaw))
: 0
const spriteCols =
typeof spriteColsRaw === 'number' && Number.isFinite(spriteColsRaw)
? Math.max(0, Math.floor(spriteColsRaw))
: 0
const spriteRows =
typeof spriteRowsRaw === 'number' && Number.isFinite(spriteRowsRaw)
? Math.max(0, Math.floor(spriteRowsRaw))
: 0
const spriteStepSeconds = const spriteStepSeconds =
typeof spriteStepSecondsRaw === 'number' && typeof spriteStepSecondsRaw === 'number' &&
@ -323,46 +311,6 @@ export default function FinishedDownloadsGalleryView({
? spriteStepSecondsRaw ? spriteStepSecondsRaw
: DEFAULT_SPRITE_STEP_SECONDS : DEFAULT_SPRITE_STEP_SECONDS
// ✅ Fallback-Dauer (meta -> job -> durations cache)
const durationForSprite =
normalizeDurationSeconds(meta?.durationSeconds) ??
normalizeDurationSeconds((j as any)?.durationSeconds) ??
normalizeDurationSeconds(durations[k])
// ✅ Count aus Meta/Flags ODER aus Dauer ableiten
const inferredSpriteCountFromDuration =
typeof durationForSprite === 'number' && durationForSprite > 0
? Math.max(1, Math.min(200, Math.floor(durationForSprite / spriteStepSeconds) + 1))
: undefined
const spriteCountRaw =
meta?.previewSprite?.count ??
(meta as any)?.previewSpriteCount ??
flags?.previewScrubberCount ??
inferredSpriteCountFromDuration
const spriteColsRaw = meta?.previewSprite?.cols ?? (meta as any)?.previewSpriteCols
const spriteRowsRaw = meta?.previewSprite?.rows ?? (meta as any)?.previewSpriteRows
const spriteCount =
typeof spriteCountRaw === 'number' && Number.isFinite(spriteCountRaw)
? Math.max(0, Math.floor(spriteCountRaw))
: 0
// ✅ Wenn cols/rows fehlen, aus Count ableiten (wie Backend)
const [inferredCols, inferredRows] =
spriteCount > 1 ? chooseSpriteGrid(spriteCount) : [0, 0]
const spriteCols =
typeof spriteColsRaw === 'number' && Number.isFinite(spriteColsRaw)
? Math.max(0, Math.floor(spriteColsRaw))
: inferredCols
const spriteRows =
typeof spriteRowsRaw === 'number' && Number.isFinite(spriteRowsRaw)
? Math.max(0, Math.floor(spriteRowsRaw))
: inferredRows
// Optionaler Cache-Buster (wenn du sowas in meta hast) // Optionaler Cache-Buster (wenn du sowas in meta hast)
const spriteVersion = const spriteVersion =
(typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix) (typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix)
@ -378,19 +326,16 @@ export default function FinishedDownloadsGalleryView({
? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}` ? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}`
: spritePath || undefined : spritePath || undefined
const hasScrubberUi =
Boolean(spriteUrl) &&
spriteCount > 1
const hasSpriteScrubber = const hasSpriteScrubber =
hasScrubberUi && Boolean(spriteUrl) &&
spriteCount > 1 &&
spriteCols > 0 && spriteCols > 0 &&
spriteRows > 0 spriteRows > 0
// Finales Scrubber-Setup // Finales Scrubber-Setup (NUR Sprite)
const scrubberCount = hasScrubberUi ? spriteCount : 0 const scrubberCount = hasSpriteScrubber ? spriteCount : 0
const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0 const scrubberStepSeconds = hasSpriteScrubber ? spriteStepSeconds : 0
const hasScrubber = hasScrubberUi const hasScrubber = hasSpriteScrubber
const activeScrubIndex = scrubIndexByKey[k] const activeScrubIndex = scrubIndexByKey[k]
@ -526,8 +471,8 @@ export default function FinishedDownloadsGalleryView({
{/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */} {/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */}
{hasScrubber ? ( {hasScrubber ? (
<div className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"> <div className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100">
<PreviewScrubber <GalleryPreviewScrubber
className="pointer-events-auto px-1" className="pointer-events-auto px-1"
imageCount={scrubberCount} imageCount={scrubberCount}
activeIndex={activeScrubIndex} activeIndex={activeScrubIndex}

View File

@ -9,7 +9,6 @@ import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview' import FinishedVideoPreview from './FinishedVideoPreview'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow' import TagOverflowRow from './TagOverflowRow'
import PreviewScrubber from './PreviewScrubber'
import { isHotName, stripHotPrefix } from './hotName' import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters' import { formatResolution } from './formatters'
@ -159,54 +158,6 @@ export default function FinishedDownloadsTableView({
return out return out
}, []) }, [])
const parseMeta = React.useCallback((j: RecordJob): any | null => {
const raw: any = (j as any)?.meta
if (!raw) return null
if (typeof raw === 'string') {
try {
return JSON.parse(raw)
} catch {
return null
}
}
return raw
}, [])
const previewSpriteInfoOf = React.useCallback(
(j: RecordJob): { count: number; stepSeconds: number } | null => {
const meta = parseMeta(j)
let ps: any = meta?.previewSprite ?? meta?.preview?.sprite ?? null
if (typeof ps === 'string') {
try {
ps = JSON.parse(ps)
} catch {
ps = null
}
}
const count = Number(ps?.count)
const stepSeconds = Number(ps?.stepSeconds)
if (!Number.isFinite(count) || count < 2) return null
return {
count: Math.max(2, Math.floor(count)),
stepSeconds: Number.isFinite(stepSeconds) && stepSeconds > 0 ? stepSeconds : 5,
}
},
[parseMeta]
)
const [scrubActiveByKey, setScrubActiveByKey] = React.useState<Record<string, number | undefined>>({})
const setScrubActiveIndex = React.useCallback((key: string, index: number | undefined) => {
setScrubActiveByKey((prev) => {
if (prev[key] === index) return prev
return { ...prev, [key]: index }
})
}, [])
const columns = React.useMemo<Column<RecordJob>[]>(() => { const columns = React.useMemo<Column<RecordJob>[]>(() => {
return [ return [
{ {
@ -217,26 +168,11 @@ export default function FinishedDownloadsTableView({
const k = keyFor(j) const k = keyFor(j)
const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k
const previewMuted = !allowSound const previewMuted = !allowSound
const spriteInfo = previewSpriteInfoOf(j)
const scrubActiveIndex = scrubActiveByKey[k]
const fileRaw = baseName(j.output || '')
const previewId = stripHotPrefix(fileRaw.replace(/\.[^.]+$/, '')).trim()
const scrubTimeSec =
spriteInfo && typeof scrubActiveIndex === 'number'
? Math.max(0, scrubActiveIndex * spriteInfo.stepSeconds)
: undefined
const scrubFrameSrc =
previewId && typeof scrubTimeSec === 'number'
? `/api/preview?id=${encodeURIComponent(previewId)}&t=${scrubTimeSec.toFixed(3)}&v=${assetNonce ?? 0}`
: ''
return ( return (
<div <div
ref={registerTeaserHost(k)} ref={registerTeaserHost(k)}
className="group relative py-1" className="py-1"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onMouseEnter={() => { onMouseEnter={() => {
@ -244,7 +180,6 @@ export default function FinishedDownloadsTableView({
}} }}
onMouseLeave={() => { onMouseLeave={() => {
if (canHover) setHoverTeaserKey(null) if (canHover) setHoverTeaserKey(null)
setScrubActiveIndex(k, undefined)
}} }}
> >
<FinishedVideoPreview <FinishedVideoPreview
@ -263,36 +198,6 @@ export default function FinishedDownloadsTableView({
animatedTrigger="always" animatedTrigger="always"
assetNonce={assetNonce} assetNonce={assetNonce}
/> />
{/* Scrub-Frame Overlay */}
{spriteInfo && typeof scrubActiveIndex === 'number' && scrubFrameSrc ? (
<img
src={scrubFrameSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute left-0 top-1 z-[15] h-16 w-28 rounded-md object-cover ring-1 ring-black/5 dark:ring-white/10"
draggable={false}
/>
) : null}
{/* Scrubber (Mobile sichtbar, Desktop nur Hover) */}
{spriteInfo ? (
<div
className="absolute inset-x-0 bottom-0 z-[20] px-0.5"
onPointerUp={() => setScrubActiveIndex(k, undefined)}
onPointerCancel={() => setScrubActiveIndex(k, undefined)}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<PreviewScrubber
imageCount={spriteInfo.count}
activeIndex={scrubActiveIndex}
onActiveIndexChange={(idx) => setScrubActiveIndex(k, idx)}
stepSeconds={spriteInfo.stepSeconds}
className="opacity-100 pointer-events-auto md:opacity-0 md:pointer-events-none md:group-hover:opacity-100 md:group-focus-within:opacity-100 md:group-hover:pointer-events-auto md:group-focus-within:pointer-events-auto"
/>
</div>
) : null}
</div> </div>
) )
}, },
@ -480,9 +385,6 @@ export default function FinishedDownloadsTableView({
deletingKeys, deletingKeys,
keepingKeys, keepingKeys,
removingKeys, removingKeys,
previewSpriteInfoOf,
scrubActiveByKey,
setScrubActiveIndex,
onToggleWatch, onToggleWatch,
onToggleFavorite, onToggleFavorite,
onToggleLike, onToggleLike,

View File

@ -23,7 +23,7 @@ export type FinishedVideoPreviewProps = {
onDuration?: (job: RecordJob, seconds: number) => void onDuration?: (job: RecordJob, seconds: number) => void
onResolution?: (job: RecordJob, w: number, h: number) => void onResolution?: (job: RecordJob, w: number, h: number) => void
/** animated="true": frames = wechselnde Bilder, clips = 0.75s-Teaser-Clips (z.B. 12), teaser = vorgerendertes MP4 */ /** animated="true": frames = wechselnde Bilder, clips = 1s-Teaser-Clips, teaser = vorgerendertes MP4 */
animated?: boolean animated?: boolean
animatedMode?: AnimatedMode animatedMode?: AnimatedMode
animatedTrigger?: AnimatedTrigger animatedTrigger?: AnimatedTrigger
@ -36,7 +36,6 @@ export type FinishedVideoPreviewProps = {
/** nur für clips */ /** nur für clips */
clipSeconds?: number clipSeconds?: number
clipCount?: number
/** neu: thumb = w-20 h-16, fill = w-full h-full */ /** neu: thumb = w-20 h-16, fill = w-full h-full */
variant?: Variant variant?: Variant
@ -94,8 +93,7 @@ export default function FinishedVideoPreview({
thumbSpread, thumbSpread,
thumbSamples, thumbSamples,
clipSeconds = 0.75, clipSeconds = 1,
clipCount = 12,
variant = 'thumb', variant = 'thumb',
className, className,
@ -353,7 +351,7 @@ export default function FinishedVideoPreview({
const readProgressStepped = ( const readProgressStepped = (
vv: HTMLVideoElement | null, vv: HTMLVideoElement | null,
totalSeconds: number | undefined, // bleibt drin (nur für clamp/teaser-end) totalSeconds: number | undefined, // bleibt drin (nur für clamp/teaser-end)
stepSec = clipSeconds, stepSec = 1,
forceTeaserMap = false forceTeaserMap = false
): { ratio: number; globalSec: number; vvDur: number } => { ): { ratio: number; globalSec: number; vvDur: number } => {
if (!vv) return { ratio: 0, globalSec: 0, vvDur: 0 } if (!vv) return { ratio: 0, globalSec: 0, vvDur: 0 }
@ -749,7 +747,7 @@ export default function FinishedVideoPreview({
const dur = effectiveDurationSec! const dur = effectiveDurationSec!
const clipLen = Math.max(0.25, clipSeconds) const clipLen = Math.max(0.25, clipSeconds)
const count = Math.max(8, Math.min(clipCount ?? thumbSamples ?? 12, Math.floor(dur))) const count = Math.max(8, Math.min(thumbSamples ?? 18, Math.floor(dur)))
const span = Math.max(0.1, dur - clipLen) const span = Math.max(0.1, dur - clipLen)
const base = Math.min(0.25, span * 0.02) const base = Math.min(0.25, span * 0.02)
@ -760,7 +758,7 @@ export default function FinishedVideoPreview({
times.push(Math.min(dur - 0.05, Math.max(0.05, t))) times.push(Math.min(dur - 0.05, Math.max(0.05, t)))
} }
return times return times
}, [animated, animatedMode, hasDuration, effectiveDurationSec, thumbSamples, clipSeconds, clipCount]) }, [animated, animatedMode, hasDuration, effectiveDurationSec, thumbSamples, clipSeconds])
const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes]) const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes])
@ -808,7 +806,7 @@ export default function FinishedVideoPreview({
if (!vv.isConnected) return if (!vv.isConnected) return
const forceMap = progressKind === 'teaser' && Array.isArray(previewClipMap) && previewClipMap.length > 0 const forceMap = progressKind === 'teaser' && Array.isArray(previewClipMap) && previewClipMap.length > 0
const p = readProgressStepped(vv, progressTotalSeconds, clipSeconds, forceMap) const p = readProgressStepped(vv, progressTotalSeconds, 1, forceMap)
setPlayRatio(p.ratio) setPlayRatio(p.ratio)
setPlayGlobalSec(p.globalSec) setPlayGlobalSec(p.globalSec)
@ -842,7 +840,7 @@ export default function FinishedVideoPreview({
applyInlineVideoPolicy(inlineRef.current, { muted }) applyInlineVideoPolicy(inlineRef.current, { muted })
}, [showingInlineVideo, muted]) }, [showingInlineVideo, muted])
// Legacy: "clips" spielt 0.75s Segmente aus dem Vollvideo per seek // Legacy: "clips" spielt 1s Segmente aus dem Vollvideo per seek
useEffect(() => { useEffect(() => {
const vv = clipsRef.current const vv = clipsRef.current
if (!vv) return if (!vv) return

View File

@ -1,4 +1,4 @@
// frontend\src\components\ui\PreviewScrubber.tsx // frontend\src\components\ui\GalleryPreviewScrubber.tsx
'use client' 'use client'
@ -24,7 +24,7 @@ function formatClock(totalSeconds: number) {
return `${m}:${String(sec).padStart(2, '0')}` return `${m}:${String(sec).padStart(2, '0')}`
} }
export default function PreviewScrubber({ export default function GalleryPreviewScrubber({
imageCount, imageCount,
activeIndex, activeIndex,
onActiveIndexChange, onActiveIndexChange,
@ -34,6 +34,7 @@ export default function PreviewScrubber({
}: Props) { }: Props) {
const rootRef = React.useRef<HTMLDivElement | null>(null) const rootRef = React.useRef<HTMLDivElement | null>(null)
// rAF-Throttle für Pointer-Move (reduziert Re-Renders)
const rafRef = React.useRef<number | null>(null) const rafRef = React.useRef<number | null>(null)
const pendingIndexRef = React.useRef<number | undefined>(undefined) const pendingIndexRef = React.useRef<number | undefined>(undefined)
@ -133,24 +134,12 @@ export default function PreviewScrubber({
const showLabel = typeof activeIndex === 'number' const showLabel = typeof activeIndex === 'number'
const labelLeftStyle =
typeof markerLeftPct === 'number'
? {
left: `clamp(24px, ${markerLeftPct}%, calc(100% - 24px))`,
transform: 'translateX(-50%)',
}
: undefined
return ( return (
<div <div
ref={rootRef} ref={rootRef}
className={[ className={[
// große Hit-Area, sichtbarer Balken ist unten drin
'relative h-7 w-full select-none touch-none cursor-col-resize', 'relative h-7 w-full select-none touch-none cursor-col-resize',
// standardmäßig versteckt, nur bei Hover/Focus auf dem Parent-Video sichtbar
'opacity-0 transition-opacity duration-150',
'pointer-events-none',
'group-hover:opacity-100 group-focus-within:opacity-100',
'group-hover:pointer-events-auto group-focus-within:pointer-events-auto',
className, className,
] ]
.filter(Boolean) .filter(Boolean)
@ -168,7 +157,9 @@ export default function PreviewScrubber({
aria-valuemax={imageCount} aria-valuemax={imageCount}
aria-valuenow={typeof activeIndex === 'number' ? activeIndex + 1 : undefined} aria-valuenow={typeof activeIndex === 'number' ? activeIndex + 1 : undefined}
> >
{/* sichtbarer Scrubbing-Bereich (durchgehend weiß) */}
<div className="pointer-events-none absolute inset-x-1 bottom-[3px] h-3 rounded-sm bg-white/35 ring-1 ring-white/40 backdrop-blur-[1px]"> <div className="pointer-events-none absolute inset-x-1 bottom-[3px] h-3 rounded-sm bg-white/35 ring-1 ring-white/40 backdrop-blur-[1px]">
{/* aktiver Marker */}
{typeof markerLeftPct === 'number' ? ( {typeof markerLeftPct === 'number' ? (
<div <div
className="absolute inset-y-0 w-[2px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.35)]" className="absolute inset-y-0 w-[2px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
@ -180,16 +171,15 @@ export default function PreviewScrubber({
) : null} ) : null}
</div> </div>
{/* Zeitlabel (unten rechts / dezent) */}
<div <div
className={[ className={[
'pointer-events-none absolute bottom-[17px] z-10', 'pointer-events-none absolute right-1 bottom-1',
'rounded bg-black/70 px-1.5 py-0.5', 'text-[11px] leading-none text-white',
'text-[11px] leading-none text-white whitespace-nowrap',
'transition-opacity duration-100', 'transition-opacity duration-100',
showLabel ? 'opacity-100' : 'opacity-0', showLabel ? 'opacity-100' : 'opacity-0',
'[text-shadow:_0_1px_2px_rgba(0,0,0,0.9)]', '[text-shadow:_0_1px_2px_rgba(0,0,0,0.9)]',
].join(' ')} ].join(' ')}
style={labelLeftStyle}
> >
{label} {label}
</div> </div>