updated scrubber

This commit is contained in:
Chris 2026-02-23 20:01:24 +01:00
parent 4150ffdb6b
commit a2891a2cf5
14 changed files with 612 additions and 96 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

6
backend/package-lock.json generated Normal file
View File

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

View File

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

View File

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

View File

@ -1023,7 +1023,16 @@ 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 () => {
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(

View File

@ -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<void>
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean | null; tags?: string }>
modelsByKey: Record<string, {
favorite?: boolean
liked?: boolean | null
watching?: boolean | null
tags?: string
// ✅ wie GalleryView: optionale Scrubber-Meta-Fallbacks
previewScrubberPath?: string
previewScrubberCount?: number
}>
activeTagSet: Set<string>
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<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 = (
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 */}
<div className="absolute inset-0">
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
{/* Meta-Overlay im Video nur anzeigen, wenn KEIN Inline-Player aktiv ist */}
{!inlineActive ? (
<div
className={
'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')
}
>
@ -288,13 +559,18 @@ export default function FinishedDownloadsCardsView({
<FinishedVideoPreview
job={j}
getFileName={baseName}
getFileName={(p) => 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 ? (
<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>

View File

@ -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 ? (
<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">
<GalleryPreviewScrubber
<div className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150">
<PreviewScrubber
className="pointer-events-auto px-1"
imageCount={scrubberCount}
activeIndex={activeScrubIndex}

View File

@ -9,6 +9,7 @@ import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview'
import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import PreviewScrubber from './PreviewScrubber'
import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters'
@ -158,6 +159,54 @@ export default function FinishedDownloadsTableView({
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>[]>(() => {
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 (
<div
ref={registerTeaserHost(k)}
className="py-1"
className="group relative py-1"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onMouseEnter={() => {
@ -180,6 +244,7 @@ export default function FinishedDownloadsTableView({
}}
onMouseLeave={() => {
if (canHover) setHoverTeaserKey(null)
setScrubActiveIndex(k, undefined)
}}
>
<FinishedVideoPreview
@ -198,6 +263,36 @@ export default function FinishedDownloadsTableView({
animatedTrigger="always"
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>
)
},
@ -385,6 +480,9 @@ export default function FinishedDownloadsTableView({
deletingKeys,
keepingKeys,
removingKeys,
previewSpriteInfoOf,
scrubActiveByKey,
setScrubActiveIndex,
onToggleWatch,
onToggleFavorite,
onToggleLike,

View File

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

View File

@ -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<HTMLDivElement | null>(null)
// rAF-Throttle für Pointer-Move (reduziert Re-Renders)
const rafRef = React.useRef<number | null>(null)
const pendingIndexRef = React.useRef<number | undefined>(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 (
<div
ref={rootRef}
className={[
// große Hit-Area, sichtbarer Balken ist unten drin
'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,
]
]
.filter(Boolean)
.join(' ')}
onPointerMove={handlePointerMove}
@ -157,29 +168,28 @@ export default function GalleryPreviewScrubber({
aria-valuemax={imageCount}
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]">
{/* aktiver Marker */}
<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]">
{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)]"
style={{
left: `${markerLeftPct}%`,
transform: 'translateX(-50%)',
left: `${markerLeftPct}%`,
transform: 'translateX(-50%)',
}}
/>
/>
) : null}
</div>
{/* Zeitlabel (unten rechts / dezent) */}
</div>
<div
className={[
'pointer-events-none absolute right-1 bottom-1',
'text-[11px] leading-none text-white',
'pointer-events-none absolute bottom-[17px] z-10',
'rounded bg-black/70 px-1.5 py-0.5',
'text-[11px] leading-none text-white whitespace-nowrap',
'transition-opacity duration-100',
showLabel ? 'opacity-100' : 'opacity-0',
'[text-shadow:_0_1px_2px_rgba(0,0,0,0.9)]',
].join(' ')}
style={labelLeftStyle}
>
{label}
</div>