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)
}
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
// -------------------------
@ -189,7 +176,6 @@ 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 == "" {
@ -201,12 +187,6 @@ 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
@ -346,7 +326,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
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 {
r = 0
}
@ -355,12 +335,6 @@ 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
}
@ -427,10 +401,6 @@ 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,15 +90,8 @@ func generatePreviewSpriteWebP(
return fmt.Errorf("mkdir sprite dir: %w", err)
}
// 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
// Temp-Datei im gleichen Verzeichnis für atomaren Replace
tmpPath := outPath + ".tmp"
// fps=1/stepSec nimmt alle stepSec Sekunden einen Frame
// scale+pad erzwingt feste Zellgröße (wichtig für korrektes background-positioning im Frontend)
@ -127,7 +120,6 @@ func generatePreviewSpriteWebP(
"-lossless", "0",
"-compression_level", "6",
"-q:v", "80",
"-f", "webp",
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
import (
@ -16,8 +14,7 @@ import (
)
// Minimale Segmentdauer, damit ffmpeg nicht mit zu kurzen Schnipseln zickt.
const minSegmentDuration = 0.75 // Sekunden
const defaultTeaserSegments = 12
const minSegmentDuration = 0.50 // Sekunden
type TeaserPreviewOptions struct {
Segments int
@ -174,7 +171,7 @@ func computeTeaserStarts(dur float64, opts TeaserPreviewOptions) (starts []float
opts.SegmentDuration = 1
}
if opts.Segments <= 0 {
opts.Segments = defaultTeaserSegments
opts.Segments = 18
}
segDur = opts.SegmentDuration
if segDur < minSegmentDuration {
@ -230,7 +227,7 @@ func generateTeaserPreviewMP4WithProgress(
opts.SegmentDuration = 1
}
if opts.Segments <= 0 {
opts.Segments = defaultTeaserSegments
opts.Segments = 18
}
if opts.Width <= 0 {
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==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -1823,6 +1824,7 @@
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@ -1833,6 +1835,7 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -1892,6 +1895,7 @@
"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",
@ -2200,6 +2204,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -2317,6 +2322,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -2573,6 +2579,7 @@
"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",
@ -3621,6 +3628,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -3713,6 +3721,7 @@
"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"
}
@ -3722,6 +3731,7 @@
"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"
},
@ -3949,6 +3959,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -4043,6 +4054,7 @@
"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",
@ -4094,6 +4106,7 @@
"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",
@ -4215,6 +4228,7 @@
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"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 () => {
if (!lastAction || undoing) return
@ -1215,7 +1206,7 @@ export default function FinishedDownloads({
queueRefill()
} catch (e: any) {
// ❌ 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)
setLastAction(null)
@ -1223,7 +1214,7 @@ export default function FinishedDownloads({
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(

View File

@ -13,7 +13,6 @@ 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'
@ -64,15 +63,7 @@ type Props = {
releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise<void>
modelsByKey: Record<string, {
favorite?: boolean
liked?: boolean | null
watching?: boolean | null
tags?: string
// ✅ wie GalleryView: optionale Scrubber-Meta-Fallbacks
previewScrubberPath?: string
previewScrubberCount?: number
}>
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean | null; tags?: string }>
activeTagSet: Set<string>
onToggleTagFilter: (tag: string) => void
@ -101,58 +92,6 @@ 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,
@ -161,7 +100,6 @@ export default function FinishedDownloadsCardsView({
teaserAudio,
hoverTeaserKey,
blurPreviews,
durations, // ✅ fehlte
teaserKey,
inlinePlay,
deletingKeys,
@ -185,7 +123,6 @@ export default function FinishedDownloadsCardsView({
startInline,
tryAutoplayInline,
registerTeaserHost,
handleDuration,
deleteVideo,
keepVideo,
@ -199,102 +136,19 @@ export default function FinishedDownloadsCardsView({
onToggleLike,
onToggleWatch,
}: Props) {
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
}, [])
// ✅ Auflösung als {w,h} aus meta.json bevorzugen
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
const meta = parseMeta(j)
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)
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)
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?: {
@ -337,129 +191,9 @@ export default function FinishedDownloadsCardsView({
const model = modelNameFromOutput(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 flags = modelsByKey[lower(model)]
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
@ -496,10 +230,7 @@ export default function FinishedDownloadsCardsView({
className={shellCls}
onClick={isSmall ? undefined : () => openPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onOpenPlayer(j)
}
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
{/* Card shell keeps backgrounds consistent */}
@ -516,10 +247,9 @@ export default function FinishedDownloadsCardsView({
onMouseEnter={
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k)
}
onMouseLeave={() => {
if (!isSmall && !opts?.disablePreviewHover) onHoverPreviewKeyChange?.(null)
clearScrubActiveIndex(k)
}}
onMouseLeave={
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(null)
}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
@ -528,13 +258,12 @@ export default function FinishedDownloadsCardsView({
}}
>
{/* 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 */}
{!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')
}
>
@ -559,18 +288,13 @@ export default function FinishedDownloadsCardsView({
<FinishedVideoPreview
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
getFileName={baseName}
className="h-full w-full"
variant="fill"
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
onDuration={handleDuration}
showPopover={false}
blur={inlineActive ? false : Boolean(blurPreviews)}
animated={hideTeaserUnderOverlay ? false : allowTeaserAnimation}
animated={allowTeaserAnimation}
animatedMode="teaser"
animatedTrigger="always"
clipSeconds={1}
thumbSamples={18}
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
inlineNonce={inlineNonce}
inlineControls={inlineActive}
@ -578,46 +302,13 @@ export default function FinishedDownloadsCardsView({
muted={previewMuted}
popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0}
alwaysLoadStill={forceLoadStill}
teaserPreloadEnabled={opts?.mobileStackTopOnlyVideo ? true : !isSmall}
alwaysLoadStill={forceLoadStill || true}
teaserPreloadEnabled={
// ✅ im Mobile-Stack nur für Top-Card Teaser preloaden
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 PreviewScrubber from './PreviewScrubber'
import GalleryPreviewScrubber from './GalleryPreviewScrubber'
type ModelFlags = {
favorite?: boolean
@ -107,36 +107,6 @@ 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,
@ -307,14 +277,32 @@ export default function FinishedDownloadsGalleryView({
// ------------------------------------------------------------
const spritePathRaw = firstNonEmptyString(
meta?.previewSprite?.path,
// optional weitere Fallback-Felder, falls du sie so speicherst:
(meta as any)?.previewSpritePath,
flags?.previewScrubberPath,
// optional API-Fallback-Path (nur sinnvoll, wenn Backend existiert)
previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
)
const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined
const spriteStepSecondsRaw =
meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds
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 spriteStepSeconds =
typeof spriteStepSecondsRaw === 'number' &&
@ -323,46 +311,6 @@ 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)
@ -378,19 +326,16 @@ export default function FinishedDownloadsGalleryView({
? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}`
: spritePath || undefined
const hasScrubberUi =
Boolean(spriteUrl) &&
spriteCount > 1
const hasSpriteScrubber =
hasScrubberUi &&
Boolean(spriteUrl) &&
spriteCount > 1 &&
spriteCols > 0 &&
spriteRows > 0
// Finales Scrubber-Setup
const scrubberCount = hasScrubberUi ? spriteCount : 0
const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0
const hasScrubber = hasScrubberUi
// Finales Scrubber-Setup (NUR Sprite)
const scrubberCount = hasSpriteScrubber ? spriteCount : 0
const scrubberStepSeconds = hasSpriteScrubber ? spriteStepSeconds : 0
const hasScrubber = hasSpriteScrubber
const activeScrubIndex = scrubIndexByKey[k]
@ -526,8 +471,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-100 transition-opacity duration-150">
<PreviewScrubber
<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
className="pointer-events-auto px-1"
imageCount={scrubberCount}
activeIndex={activeScrubIndex}

View File

@ -9,7 +9,6 @@ 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'
@ -159,54 +158,6 @@ 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 [
{
@ -217,26 +168,11 @@ 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="group relative py-1"
className="py-1"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onMouseEnter={() => {
@ -244,7 +180,6 @@ export default function FinishedDownloadsTableView({
}}
onMouseLeave={() => {
if (canHover) setHoverTeaserKey(null)
setScrubActiveIndex(k, undefined)
}}
>
<FinishedVideoPreview
@ -263,36 +198,6 @@ 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>
)
},
@ -480,9 +385,6 @@ 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 = 0.75s-Teaser-Clips (z.B. 12), teaser = vorgerendertes MP4 */
/** animated="true": frames = wechselnde Bilder, clips = 1s-Teaser-Clips, teaser = vorgerendertes MP4 */
animated?: boolean
animatedMode?: AnimatedMode
animatedTrigger?: AnimatedTrigger
@ -36,7 +36,6 @@ export type FinishedVideoPreviewProps = {
/** nur für clips */
clipSeconds?: number
clipCount?: number
/** neu: thumb = w-20 h-16, fill = w-full h-full */
variant?: Variant
@ -94,8 +93,7 @@ export default function FinishedVideoPreview({
thumbSpread,
thumbSamples,
clipSeconds = 0.75,
clipCount = 12,
clipSeconds = 1,
variant = 'thumb',
className,
@ -353,7 +351,7 @@ export default function FinishedVideoPreview({
const readProgressStepped = (
vv: HTMLVideoElement | null,
totalSeconds: number | undefined, // bleibt drin (nur für clamp/teaser-end)
stepSec = clipSeconds,
stepSec = 1,
forceTeaserMap = false
): { ratio: number; globalSec: number; vvDur: number } => {
if (!vv) return { ratio: 0, globalSec: 0, vvDur: 0 }
@ -749,7 +747,7 @@ export default function FinishedVideoPreview({
const dur = effectiveDurationSec!
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 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)))
}
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])
@ -808,7 +806,7 @@ export default function FinishedVideoPreview({
if (!vv.isConnected) return
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)
setPlayGlobalSec(p.globalSec)
@ -842,7 +840,7 @@ export default function FinishedVideoPreview({
applyInlineVideoPolicy(inlineRef.current, { 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(() => {
const vv = clipsRef.current
if (!vv) return

View File

@ -1,4 +1,4 @@
// frontend\src\components\ui\PreviewScrubber.tsx
// frontend\src\components\ui\GalleryPreviewScrubber.tsx
'use client'
@ -24,7 +24,7 @@ function formatClock(totalSeconds: number) {
return `${m}:${String(sec).padStart(2, '0')}`
}
export default function PreviewScrubber({
export default function GalleryPreviewScrubber({
imageCount,
activeIndex,
onActiveIndexChange,
@ -34,6 +34,7 @@ export default function PreviewScrubber({
}: 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)
@ -133,26 +134,14 @@ export default function PreviewScrubber({
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}
@ -168,28 +157,29 @@ export default function PreviewScrubber({
aria-valuemax={imageCount}
aria-valuenow={typeof activeIndex === 'number' ? activeIndex + 1 : undefined}
>
<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]">
{/* 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 */}
{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>
</div>
{/* Zeitlabel (unten rechts / dezent) */}
<div
className={[
'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',
'pointer-events-none absolute right-1 bottom-1',
'text-[11px] leading-none text-white',
'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>