updated scrubber
This commit is contained in:
parent
4150ffdb6b
commit
a2891a2cf5
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
||||
|
||||
@ -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
6
backend/package-lock.json
generated
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@ -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
|
||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@ -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"
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
Loading…
x
Reference in New Issue
Block a user