diff --git a/backend/data/models_store.db b/backend/data/models_store.db index eebc7ab..f303744 100644 Binary files a/backend/data/models_store.db and b/backend/data/models_store.db differ diff --git a/backend/data/models_store.db-shm b/backend/data/models_store.db-shm index 8c5479a..b326c33 100644 Binary files a/backend/data/models_store.db-shm and b/backend/data/models_store.db-shm differ diff --git a/backend/data/models_store.db-wal b/backend/data/models_store.db-wal index 3ed9d27..3543c76 100644 Binary files a/backend/data/models_store.db-wal and b/backend/data/models_store.db-wal differ diff --git a/backend/generate.go b/backend/generate.go index 755ddc0..0dbb270 100644 --- a/backend/generate.go +++ b/backend/generate.go @@ -41,6 +41,19 @@ func u64ToI64(x uint64) int64 { 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 // ------------------------- @@ -176,6 +189,7 @@ func ensureAssetsForVideoWithProgressCtx(ctx context.Context, videoPath string, // 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) { var out EnsureAssetsResult + var sourceInputInvalid bool videoPath = strings.TrimSpace(videoPath) if videoPath == "" { @@ -187,6 +201,12 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU 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) if id == "" { return out, nil @@ -326,7 +346,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU progress(thumbsW + 0.05) - if err := generateTeaserClipsMP4WithProgress(genCtx, videoPath, previewPath, 1.0, 18, func(r float64) { + if err := generateTeaserClipsMP4WithProgress(genCtx, videoPath, previewPath, 0.75, 12, func(r float64) { if r < 0 { r = 0 } @@ -335,6 +355,12 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU } progress(thumbsW + r*previewW) }); err != nil { + if isFFmpegInputInvalidError(err) { + sourceInputInvalid = true + fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath) + return + } + fmt.Println("⚠️ preview clips:", err) return } @@ -401,6 +427,10 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU } } else { func() { + if sourceInputInvalid { + return + } + // nur sinnvoll wenn wir Dauer kennen if !(meta.durSec > 0) { return diff --git a/backend/generate_sprite.go b/backend/generate_sprite.go index 999ee6c..2586ac2 100644 --- a/backend/generate_sprite.go +++ b/backend/generate_sprite.go @@ -90,8 +90,15 @@ func generatePreviewSpriteWebP( return fmt.Errorf("mkdir sprite dir: %w", err) } - // Temp-Datei im gleichen Verzeichnis für atomaren Replace - tmpPath := outPath + ".tmp" + // Temp-Datei im gleichen Verzeichnis für atomaren Replace. + // Wichtig: ffmpeg erkennt das Output-Format über die Endung. + // 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 // scale+pad erzwingt feste Zellgröße (wichtig für korrektes background-positioning im Frontend) @@ -120,6 +127,7 @@ func generatePreviewSpriteWebP( "-lossless", "0", "-compression_level", "6", "-q:v", "80", + "-f", "webp", tmpPath, ) diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..dfb18f1 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "backend", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/backend/teaser_preview_ffmpeg.go b/backend/preview_teaser.go similarity index 98% rename from backend/teaser_preview_ffmpeg.go rename to backend/preview_teaser.go index b7f8c79..7a76991 100644 --- a/backend/teaser_preview_ffmpeg.go +++ b/backend/preview_teaser.go @@ -1,3 +1,5 @@ +// backend\preview_teaser.go + package main import ( @@ -14,7 +16,8 @@ import ( ) // Minimale Segmentdauer, damit ffmpeg nicht mit zu kurzen Schnipseln zickt. -const minSegmentDuration = 0.50 // Sekunden +const minSegmentDuration = 0.75 // Sekunden +const defaultTeaserSegments = 12 type TeaserPreviewOptions struct { Segments int @@ -171,7 +174,7 @@ func computeTeaserStarts(dur float64, opts TeaserPreviewOptions) (starts []float opts.SegmentDuration = 1 } if opts.Segments <= 0 { - opts.Segments = 18 + opts.Segments = defaultTeaserSegments } segDur = opts.SegmentDuration if segDur < minSegmentDuration { @@ -227,7 +230,7 @@ func generateTeaserPreviewMP4WithProgress( opts.SegmentDuration = 1 } if opts.Segments <= 0 { - opts.Segments = 18 + opts.Segments = defaultTeaserSegments } if opts.Width <= 0 { opts.Width = 640 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ca4af0c..7d4bc3d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -64,7 +64,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1824,7 +1823,6 @@ "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1835,7 +1833,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1895,7 +1892,6 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -2204,7 +2200,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2322,7 +2317,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2579,7 +2573,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3628,7 +3621,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3721,7 +3713,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3731,7 +3722,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3959,7 +3949,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4054,7 +4043,6 @@ "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.4.tgz", "integrity": "sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.5", "@videojs/http-streaming": "^3.17.2", @@ -4106,7 +4094,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4228,7 +4215,6 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index b349929..c5439db 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -1023,7 +1023,16 @@ export default function FinishedDownloads({ }) }, []) - + const clearRenamePair = useCallback((a: string, b: string) => { + if (!a && !b) return + setRenamedFiles((prev) => { + const next: Record = { ...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 () => { if (!lastAction || undoing) return @@ -1206,7 +1215,7 @@ export default function FinishedDownloads({ queueRefill() } catch (e: any) { // ❌ Rollback, weil Optimistik schon angewendet wurde - applyRename(optimisticNew, oldFile) + clearRenamePair(oldFile, optimisticNew) // und Undo-Action löschen (sonst zeigt Undo auf etwas, das nie passiert ist) setLastAction(null) @@ -1214,7 +1223,7 @@ export default function FinishedDownloads({ notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e)) } }, - [notify, applyRename, releasePlayingFile, onToggleHot, queueRefill, sortMode] + [notify, applyRename, clearRenamePair, releasePlayingFile, onToggleHot, queueRefill, sortMode] ) const applyRenamedOutput = useCallback( diff --git a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx index 2a948c6..d6f774e 100644 --- a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -13,6 +13,7 @@ import { } from '@heroicons/react/24/solid' import RecordJobActions from './RecordJobActions' import TagOverflowRow from './TagOverflowRow' +import PreviewScrubber from './PreviewScrubber' import { isHotName, stripHotPrefix } from './hotName' import { formatResolution } from './formatters' @@ -63,7 +64,15 @@ type Props = { releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise - modelsByKey: Record + modelsByKey: Record activeTagSet: Set onToggleTagFilter: (tag: string) => void @@ -92,6 +101,58 @@ const parseTags = (raw?: string): string[] => { 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({ rows, isSmall, @@ -100,6 +161,7 @@ export default function FinishedDownloadsCardsView({ teaserAudio, hoverTeaserKey, blurPreviews, + durations, // ✅ fehlte teaserKey, inlinePlay, deletingKeys, @@ -123,6 +185,7 @@ export default function FinishedDownloadsCardsView({ startInline, tryAutoplayInline, registerTeaserHost, + handleDuration, deleteVideo, keepVideo, @@ -136,19 +199,102 @@ export default function FinishedDownloadsCardsView({ onToggleLike, onToggleWatch, }: 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 meta = parseMeta(j) + const w = - (typeof j.meta?.videoWidth === 'number' && Number.isFinite(j.meta.videoWidth) ? j.meta.videoWidth : 0) || + (typeof meta?.videoWidth === 'number' && Number.isFinite(meta.videoWidth) ? meta.videoWidth : 0) || (typeof j.videoWidth === 'number' && Number.isFinite(j.videoWidth) ? j.videoWidth : 0) + const h = - (typeof j.meta?.videoHeight === 'number' && Number.isFinite(j.meta.videoHeight) ? j.meta.videoHeight : 0) || + (typeof meta?.videoHeight === 'number' && Number.isFinite(meta.videoHeight) ? meta.videoHeight : 0) || (typeof j.videoHeight === 'number' && Number.isFinite(j.videoHeight) ? j.videoHeight : 0) if (w > 0 && h > 0) return { w, h } 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>({}) + + 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 = ( j: RecordJob, opts?: { @@ -191,9 +337,129 @@ export default function FinishedDownloadsCardsView({ const model = modelNameFromOutput(j.output) const fileRaw = baseName(j.output || '') - const isHot = isHotName(fileRaw) + const previewId = stripHotPrefix(fileRaw.replace(/\.[^.]+$/, '')).trim() + const modelKey = lower(model) + const flags = modelsByKey[modelKey] - const flags = modelsByKey[lower(model)] + 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 isFav = Boolean(flags?.favorite) const isLiked = flags?.liked === true const isWatching = Boolean(flags?.watching) @@ -230,7 +496,10 @@ export default function FinishedDownloadsCardsView({ className={shellCls} onClick={isSmall ? undefined : () => openPlayer(j)} onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onOpenPlayer(j) + } }} > {/* Card shell keeps backgrounds consistent */} @@ -247,9 +516,10 @@ export default function FinishedDownloadsCardsView({ onMouseEnter={ isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k) } - onMouseLeave={ - isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(null) - } + onMouseLeave={() => { + if (!isSmall && !opts?.disablePreviewHover) onHoverPreviewKeyChange?.(null) + clearScrubActiveIndex(k) + }} onClick={(e) => { e.preventDefault() e.stopPropagation() @@ -258,12 +528,13 @@ export default function FinishedDownloadsCardsView({ }} > {/* media */} -
+
{/* Meta-Overlay im Video nur anzeigen, wenn KEIN Inline-Player aktiv ist */} {!inlineActive ? (
@@ -288,13 +559,18 @@ export default function FinishedDownloadsCardsView({ stripHotPrefix(baseName(p))} className="h-full w-full" + variant="fill" + durationSeconds={durations[k] ?? (j as any)?.durationSeconds} + onDuration={handleDuration} showPopover={false} blur={inlineActive ? false : Boolean(blurPreviews)} - animated={allowTeaserAnimation} + animated={hideTeaserUnderOverlay ? false : allowTeaserAnimation} animatedMode="teaser" animatedTrigger="always" + clipSeconds={1} + thumbSamples={18} inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false} inlineNonce={inlineNonce} inlineControls={inlineActive} @@ -302,13 +578,46 @@ export default function FinishedDownloadsCardsView({ muted={previewMuted} popoverMuted={previewMuted} assetNonce={assetNonce ?? 0} - alwaysLoadStill={forceLoadStill || true} - teaserPreloadEnabled={ - // ✅ im Mobile-Stack nur für Top-Card Teaser preloaden - opts?.mobileStackTopOnlyVideo ? true : !isSmall - } + alwaysLoadStill={forceLoadStill} + teaserPreloadEnabled={opts?.mobileStackTopOnlyVideo ? true : !isSmall} teaserPreloadRootMargin={isSmall ? '900px 0px' : '700px 0px'} /> + + {/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */} + {hasSpriteScrubber && spriteUrl ? ( + + ) : null} + + {/* ✅ Scrub-Frame Overlay via Sprite (kein Request pro Move) */} + {hasSpriteScrubber && spriteFrameStyle ? ( +
diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx index 1d6ccd8..74a9520 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx @@ -13,7 +13,7 @@ import RecordJobActions from './RecordJobActions' import TagOverflowRow from './TagOverflowRow' import { isHotName, stripHotPrefix } from './hotName' import { formatResolution } from './formatters' -import GalleryPreviewScrubber from './GalleryPreviewScrubber' +import PreviewScrubber from './PreviewScrubber' type ModelFlags = { favorite?: boolean @@ -107,6 +107,36 @@ function clamp(n: number, min: number, max: number) { 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({ rows, isLoading, @@ -277,32 +307,14 @@ export default function FinishedDownloadsGalleryView({ // ------------------------------------------------------------ const spritePathRaw = firstNonEmptyString( meta?.previewSprite?.path, - // optional weitere Fallback-Felder, falls du sie so speicherst: (meta as any)?.previewSpritePath, - // optional API-Fallback-Path (nur sinnvoll, wenn Backend existiert) + flags?.previewScrubberPath, previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined ) const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined - const spriteCountRaw = meta?.previewSprite?.count ?? (meta as any)?.previewSpriteCount - 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 spriteStepSecondsRaw = + meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds const spriteStepSeconds = typeof spriteStepSecondsRaw === 'number' && @@ -311,6 +323,46 @@ export default function FinishedDownloadsGalleryView({ ? spriteStepSecondsRaw : 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) const spriteVersion = (typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix) @@ -326,16 +378,19 @@ export default function FinishedDownloadsGalleryView({ ? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}` : spritePath || undefined - const hasSpriteScrubber = + const hasScrubberUi = Boolean(spriteUrl) && - spriteCount > 1 && + spriteCount > 1 + + const hasSpriteScrubber = + hasScrubberUi && spriteCols > 0 && spriteRows > 0 - // Finales Scrubber-Setup (NUR Sprite) - const scrubberCount = hasSpriteScrubber ? spriteCount : 0 - const scrubberStepSeconds = hasSpriteScrubber ? spriteStepSeconds : 0 - const hasScrubber = hasSpriteScrubber + // Finales Scrubber-Setup + const scrubberCount = hasScrubberUi ? spriteCount : 0 + const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0 + const hasScrubber = hasScrubberUi const activeScrubIndex = scrubIndexByKey[k] @@ -471,8 +526,8 @@ export default function FinishedDownloadsGalleryView({ {/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */} {hasScrubber ? ( -
- + { + 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>({}) + + 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[]>(() => { return [ { @@ -168,11 +217,26 @@ export default function FinishedDownloadsTableView({ const k = keyFor(j) const allowSound = Boolean(teaserAudio) && hoverTeaserKey === k 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 (
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onMouseEnter={() => { @@ -180,6 +244,7 @@ export default function FinishedDownloadsTableView({ }} onMouseLeave={() => { if (canHover) setHoverTeaserKey(null) + setScrubActiveIndex(k, undefined) }} > + + {/* Scrub-Frame Overlay */} + {spriteInfo && typeof scrubActiveIndex === 'number' && scrubFrameSrc ? ( + + ) : null} + + {/* Scrubber (Mobile sichtbar, Desktop nur Hover) */} + {spriteInfo ? ( +
setScrubActiveIndex(k, undefined)} + onPointerCancel={() => setScrubActiveIndex(k, undefined)} + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + 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" + /> +
+ ) : null}
) }, @@ -385,6 +480,9 @@ export default function FinishedDownloadsTableView({ deletingKeys, keepingKeys, removingKeys, + previewSpriteInfoOf, + scrubActiveByKey, + setScrubActiveIndex, onToggleWatch, onToggleFavorite, onToggleLike, diff --git a/frontend/src/components/ui/FinishedVideoPreview.tsx b/frontend/src/components/ui/FinishedVideoPreview.tsx index 70ac070..04c2fbe 100644 --- a/frontend/src/components/ui/FinishedVideoPreview.tsx +++ b/frontend/src/components/ui/FinishedVideoPreview.tsx @@ -23,7 +23,7 @@ export type FinishedVideoPreviewProps = { onDuration?: (job: RecordJob, seconds: number) => void onResolution?: (job: RecordJob, w: number, h: number) => void - /** animated="true": frames = wechselnde Bilder, clips = 1s-Teaser-Clips, teaser = vorgerendertes MP4 */ + /** animated="true": frames = wechselnde Bilder, clips = 0.75s-Teaser-Clips (z.B. 12), teaser = vorgerendertes MP4 */ animated?: boolean animatedMode?: AnimatedMode animatedTrigger?: AnimatedTrigger @@ -36,6 +36,7 @@ export type FinishedVideoPreviewProps = { /** nur für clips */ clipSeconds?: number + clipCount?: number /** neu: thumb = w-20 h-16, fill = w-full h-full */ variant?: Variant @@ -93,7 +94,8 @@ export default function FinishedVideoPreview({ thumbSpread, thumbSamples, - clipSeconds = 1, + clipSeconds = 0.75, + clipCount = 12, variant = 'thumb', className, @@ -351,7 +353,7 @@ export default function FinishedVideoPreview({ const readProgressStepped = ( vv: HTMLVideoElement | null, totalSeconds: number | undefined, // bleibt drin (nur für clamp/teaser-end) - stepSec = 1, + stepSec = clipSeconds, forceTeaserMap = false ): { ratio: number; globalSec: number; vvDur: number } => { if (!vv) return { ratio: 0, globalSec: 0, vvDur: 0 } @@ -747,7 +749,7 @@ export default function FinishedVideoPreview({ const dur = effectiveDurationSec! const clipLen = Math.max(0.25, clipSeconds) - const count = Math.max(8, Math.min(thumbSamples ?? 18, Math.floor(dur))) + const count = Math.max(8, Math.min(clipCount ?? thumbSamples ?? 12, Math.floor(dur))) const span = Math.max(0.1, dur - clipLen) const base = Math.min(0.25, span * 0.02) @@ -758,7 +760,7 @@ export default function FinishedVideoPreview({ times.push(Math.min(dur - 0.05, Math.max(0.05, t))) } return times - }, [animated, animatedMode, hasDuration, effectiveDurationSec, thumbSamples, clipSeconds]) + }, [animated, animatedMode, hasDuration, effectiveDurationSec, thumbSamples, clipSeconds, clipCount]) const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes]) @@ -806,7 +808,7 @@ export default function FinishedVideoPreview({ if (!vv.isConnected) return const forceMap = progressKind === 'teaser' && Array.isArray(previewClipMap) && previewClipMap.length > 0 - const p = readProgressStepped(vv, progressTotalSeconds, 1, forceMap) + const p = readProgressStepped(vv, progressTotalSeconds, clipSeconds, forceMap) setPlayRatio(p.ratio) setPlayGlobalSec(p.globalSec) @@ -840,7 +842,7 @@ export default function FinishedVideoPreview({ applyInlineVideoPolicy(inlineRef.current, { muted }) }, [showingInlineVideo, muted]) - // Legacy: "clips" spielt 1s Segmente aus dem Vollvideo per seek + // Legacy: "clips" spielt 0.75s Segmente aus dem Vollvideo per seek useEffect(() => { const vv = clipsRef.current if (!vv) return diff --git a/frontend/src/components/ui/GalleryPreviewScrubber.tsx b/frontend/src/components/ui/PreviewScrubber.tsx similarity index 80% rename from frontend/src/components/ui/GalleryPreviewScrubber.tsx rename to frontend/src/components/ui/PreviewScrubber.tsx index 2914d57..9e985b1 100644 --- a/frontend/src/components/ui/GalleryPreviewScrubber.tsx +++ b/frontend/src/components/ui/PreviewScrubber.tsx @@ -1,4 +1,4 @@ -// frontend\src\components\ui\GalleryPreviewScrubber.tsx +// frontend\src\components\ui\PreviewScrubber.tsx 'use client' @@ -24,7 +24,7 @@ function formatClock(totalSeconds: number) { return `${m}:${String(sec).padStart(2, '0')}` } -export default function GalleryPreviewScrubber({ +export default function PreviewScrubber({ imageCount, activeIndex, onActiveIndexChange, @@ -34,7 +34,6 @@ export default function GalleryPreviewScrubber({ }: Props) { const rootRef = React.useRef(null) - // rAF-Throttle für Pointer-Move (reduziert Re-Renders) const rafRef = React.useRef(null) const pendingIndexRef = React.useRef(undefined) @@ -134,14 +133,26 @@ export default function GalleryPreviewScrubber({ const showLabel = typeof activeIndex === 'number' + const labelLeftStyle = + typeof markerLeftPct === 'number' + ? { + left: `clamp(24px, ${markerLeftPct}%, calc(100% - 24px))`, + transform: 'translateX(-50%)', + } + : undefined + return (
- {/* sichtbarer Scrubbing-Bereich (durchgehend weiß) */} -
- {/* aktiver Marker */} +
{typeof markerLeftPct === 'number' ? ( -
+ /> ) : null} -
- - {/* Zeitlabel (unten rechts / dezent) */} +
+
{label}