This commit is contained in:
Linrador 2026-02-25 17:46:15 +01:00
parent ae67c817ac
commit a229e1ef2c
16 changed files with 668 additions and 606 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -255,10 +255,15 @@ func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sour
} }
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
assetID := stripHotPrefix(strings.TrimSpace(stem)) assetID := stripHotPrefix(strings.TrimSpace(stem))
if assetID == "" { if assetID == "" {
return nil, false return nil, false
} }
assetID, err = sanitizeID(assetID)
if err != nil || assetID == "" {
return nil, false
}
metaPath, err := metaJSONPathForAssetID(assetID) metaPath, err := metaJSONPathForAssetID(assetID)
if err != nil || strings.TrimSpace(metaPath) == "" { if err != nil || strings.TrimSpace(metaPath) == "" {

Binary file not shown.

View File

@ -650,83 +650,21 @@ func ensureMetaJSONForPlayback(ctx context.Context, videoPath string) {
return return
} }
// ID: basename ohne Ext + ohne HOT videoPath = strings.TrimSpace(videoPath)
base := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath)) if videoPath == "" {
id := stripHotPrefix(base)
id = strings.TrimSpace(id)
if id == "" {
return return
} }
metaPath, err := generatedMetaFile(id) fi, err := os.Stat(videoPath)
if err != nil || strings.TrimSpace(metaPath) == "" { if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return return
} }
// existiert schon? // ✅ Zentrale Meta-Logik benutzen (meta.go)
if fi, err := os.Stat(metaPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { // - liest vorhandene gültige meta
return // - erzeugt v2-meta bei Bedarf
} // - fallbackt auf duration-cache-only meta, wenn ffprobe gerade nicht geht
_, _ = ensureVideoMetaForFileBestEffort(ctx, videoPath, "")
// Video stat für spätere Checks / Meta-Write
vfi, err := os.Stat(videoPath)
if err != nil || vfi == nil || vfi.IsDir() || vfi.Size() == 0 {
return
}
// kleiner Timeout: wir wollen Playback nicht “ewig” blockieren
pctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
// Dauer (best effort)
dur := 0.0
if d, derr := durationSecondsCached(pctx, videoPath); derr == nil && d > 0 {
dur = d
}
// Height/Width optional nicht mehr berechnen (wenn helper entfernt wurde)
h := 0
// FPS optional wenn du einen Cache/helper hast, nimm ihn; sonst 0 lassen.
fps := 0.0
// Quelle URL ist bei done-files oft nur in meta; wenn unbekannt, leer lassen.
srcURL := ""
// Sicherstellen, dass Ordner existiert
_ = os.MkdirAll(filepath.Dir(metaPath), 0o755)
// Format: passe an deine readVideoMeta(...) / readVideoMetaDuration(...) Parser-Struktur an!
// Ich nehme hier eine sehr typische Struktur an:
type videoMeta struct {
DurationSeconds float64 `json:"durationSeconds,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
FPS float64 `json:"fps,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
// optional: file info / updatedAt
UpdatedAtUnix int64 `json:"updatedAtUnix,omitempty"`
FileSizeBytes int64 `json:"fileSizeBytes,omitempty"`
}
m := videoMeta{
DurationSeconds: dur,
Width: 0,
Height: h,
FPS: fps,
SourceURL: srcURL,
UpdatedAtUnix: time.Now().Unix(),
FileSizeBytes: vfi.Size(),
}
// Atomisch schreiben (damit parallele Requests kein kaputtes JSON sehen)
tmp := metaPath + ".tmp"
if b, err := json.MarshalIndent(m, "", " "); err == nil {
_ = os.WriteFile(tmp, b, 0o644)
_ = os.Rename(tmp, metaPath)
} else {
_ = os.Remove(tmp)
}
} }
func recordVideo(w http.ResponseWriter, r *http.Request) { func recordVideo(w http.ResponseWriter, r *http.Request) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>App</title> <title>App</title>
<script type="module" crossorigin src="/assets/index-BZ38s29o.js"></script> <script type="module" crossorigin src="/assets/index-C4whm-WW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CZMtb58J.css"> <link rel="stylesheet" crossorigin href="/assets/index-3IFBscEU.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -476,15 +476,48 @@ export default function App() {
// ✅ sagt FinishedDownloads: "bitte ALL neu laden" // ✅ sagt FinishedDownloads: "bitte ALL neu laden"
const finishedReloadTimerRef = useRef<number | null>(null) const finishedReloadTimerRef = useRef<number | null>(null)
const finishedReloadLastDispatchAtRef = useRef(0)
const requestFinishedReload = useCallback(() => { const requestFinishedReload = useCallback((reason = 'unknown') => {
const now = Date.now()
const cooldownMs = 700 // 👈 wichtig gegen Event-Stürme
// Wenn wir nicht im Finished-Tab sind, keine Reload-Events feuern.
// (Count kann separat aktualisiert werden.)
if (selectedTabRef.current !== 'finished') return
// Bereits geplant -> nur koaleszieren
if (finishedReloadTimerRef.current != null) { if (finishedReloadTimerRef.current != null) {
window.clearTimeout(finishedReloadTimerRef.current) return
} }
// Harte Dedupe-Schranke
const sinceLast = now - finishedReloadLastDispatchAtRef.current
if (sinceLast < cooldownMs) {
finishedReloadTimerRef.current = window.setTimeout(() => { finishedReloadTimerRef.current = window.setTimeout(() => {
finishedReloadTimerRef.current = null finishedReloadTimerRef.current = null
window.dispatchEvent(new CustomEvent('finished-downloads:reload')) finishedReloadLastDispatchAtRef.current = Date.now()
window.dispatchEvent(
new CustomEvent('finished-downloads:reload', {
detail: { source: `App.requestFinishedReload(cooldown-tail)`, reason },
})
)
}, cooldownMs - sinceLast)
return
}
// Leichtes Coalescing für Burst im selben Moment
finishedReloadTimerRef.current = window.setTimeout(() => {
finishedReloadTimerRef.current = null
finishedReloadLastDispatchAtRef.current = Date.now()
window.dispatchEvent(
new CustomEvent('finished-downloads:reload', {
detail: { source: 'App.requestFinishedReload', reason },
})
)
}, 150) }, 150)
}, []) }, [])
@ -1249,12 +1282,12 @@ export default function App() {
// ✅ Badge/Count updaten + FinishedDownloads (ALL) reloaden // ✅ Badge/Count updaten + FinishedDownloads (ALL) reloaden
void loadDoneCount() void loadDoneCount()
requestFinishedReload() requestFinishedReload('selectedTab effect')
const onVis = () => { const onVis = () => {
if (!document.hidden) { if (!document.hidden) {
void loadDoneCount() void loadDoneCount()
requestFinishedReload() requestFinishedReload('selectedTab visibilitychange')
} }
} }
document.addEventListener('visibilitychange', onVis) document.addEventListener('visibilitychange', onVis)
@ -1290,7 +1323,7 @@ export default function App() {
if (document.hidden) return if (document.hidden) return
if (selectedTabRef.current === 'finished') { if (selectedTabRef.current === 'finished') {
void loadDoneCount() void loadDoneCount()
requestFinishedReload() requestFinishedReload('done-stream poll tick')
} else { } else {
void loadDoneCount() void loadDoneCount()
} }
@ -1312,7 +1345,7 @@ export default function App() {
lastFireRef.t = Date.now() lastFireRef.t = Date.now()
if (selectedTabRef.current === 'finished') { if (selectedTabRef.current === 'finished') {
void loadDoneCount() void loadDoneCount()
requestFinishedReload() requestFinishedReload('done-stream coalesced requestRefresh')
} else { } else {
void loadDoneCount() void loadDoneCount()
} }
@ -1323,7 +1356,7 @@ export default function App() {
lastFireRef.t = now lastFireRef.t = now
if (selectedTabRef.current === 'finished') { if (selectedTabRef.current === 'finished') {
void loadDoneCount() void loadDoneCount()
requestFinishedReload() requestFinishedReload('done-stream requestRefresh')
} else { } else {
void loadDoneCount() void loadDoneCount()
} }
@ -1436,18 +1469,14 @@ export default function App() {
setDoneCount((c) => Math.max(0, c + delta)) setDoneCount((c) => Math.max(0, c + delta))
} }
// Count darf immer aktualisiert werden (Badge/Header) // ✅ Nur Header/Badge nachziehen
// FinishedDownloads macht seinen Refill selbst.
void loadDoneCount() void loadDoneCount()
// ✅ Nur Finished-Tab wirklich neu laden
if (selectedTabRef.current === 'finished') {
requestFinishedReload()
}
} }
window.addEventListener('finished-downloads:count-hint', onHint as EventListener) window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener) return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
}, [loadDoneCount, requestFinishedReload]) }, [loadDoneCount])
useEffect(() => { useEffect(() => {
const onNav = (ev: Event) => { const onNav = (ev: Event) => {

View File

@ -352,6 +352,9 @@ export default function FinishedDownloads({
const refillInFlightRef = React.useRef(false) const refillInFlightRef = React.useRef(false)
const refillQueuedWhileInFlightRef = React.useRef(false) const refillQueuedWhileInFlightRef = React.useRef(false)
// ✅ schützt gegen alte Effect-Instanzen / StrictMode-Cleanup / Race Conditions
const refillSessionRef = React.useRef(0)
type UndoAction = type UndoAction =
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' } | { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' }
| { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string } | { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string }
@ -416,12 +419,6 @@ export default function FinishedDownloads({
const [refillTick, setRefillTick] = React.useState(0) const [refillTick, setRefillTick] = React.useState(0)
const refillTimerRef = React.useRef<number | null>(null) const refillTimerRef = React.useRef<number | null>(null)
const queueRefill = useCallback(() => {
if (refillTimerRef.current) window.clearTimeout(refillTimerRef.current)
// kurz debouncen, damit bei mehreren Aktionen nicht zig Fetches laufen
refillTimerRef.current = window.setTimeout(() => setRefillTick((n) => n + 1), 80)
}, [])
const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any }) const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any })
const emitCountHint = React.useCallback((delta: number) => { const emitCountHint = React.useCallback((delta: number) => {
@ -552,6 +549,16 @@ export default function FinishedDownloads({
} }
}, []) // nur einmal beim Mount }, []) // nur einmal beim Mount
const queueRefill = useCallback(() => {
// ✅ Schon geplant? Dann nicht nochmal planen.
if (refillTimerRef.current != null) return
refillTimerRef.current = window.setTimeout(() => {
refillTimerRef.current = null
setRefillTick((n) => n + 1)
}, 80)
}, [page, pageSize, sortMode, includeKeep])
useEffect(() => { useEffect(() => {
if (!effectiveAllMode) return if (!effectiveAllMode) return
@ -573,19 +580,32 @@ export default function FinishedDownloads({
const ac = new AbortController() const ac = new AbortController()
let alive = true let alive = true
let finished = false // ✅ pro Effect-Instanz idempotent
// ✅ eigene Session-ID für diese Effect-Instanz
const mySession = ++refillSessionRef.current
const finishRefill = () => { const finishRefill = () => {
if (finished) return
finished = true
// ✅ Nur die AKTUELLE Session darf den globalen Refill-Status verändern
if (refillSessionRef.current !== mySession) {
return
}
refillInFlightRef.current = false refillInFlightRef.current = false
// ✅ Nachlauf-Reload ausführen, falls während des laufenden Refills ein Event kam
if (refillQueuedWhileInFlightRef.current) { if (refillQueuedWhileInFlightRef.current) {
refillQueuedWhileInFlightRef.current = false refillQueuedWhileInFlightRef.current = false
queueRefill() queueRefill()
} }
} }
// ✅ Refill läuft // ✅ Refill läuft (nur wenn diese Session noch aktuell ist)
if (refillSessionRef.current === mySession) {
refillInFlightRef.current = true refillInFlightRef.current = true
}
// ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all" // ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all"
if (effectiveAllMode) { if (effectiveAllMode) {
@ -615,23 +635,17 @@ export default function FinishedDownloads({
;(async () => { ;(async () => {
try { try {
// 1) Liste + count holen // 1) Liste + optional count in EINEM Request holen
const [listRes, metaRes] = await Promise.all([ const listRes = await fetch(
fetch( `/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1${
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}${
includeKeep ? '&includeKeep=1' : '' includeKeep ? '&includeKeep=1' : ''
}`, }`,
{ cache: 'no-store' as any, signal: ac.signal } { cache: 'no-store' as any, signal: ac.signal }
), )
fetch(
`/api/record/done/meta${includeKeep ? '?includeKeep=1' : ''}`,
{ cache: 'no-store' as any, signal: ac.signal }
),
])
if (!alive || ac.signal.aborted) return if (!alive || ac.signal.aborted) return
let okAll = listRes.ok && metaRes.ok const okAll = listRes.ok
if (listRes.ok) { if (listRes.ok) {
const data = await listRes.json().catch(() => null) const data = await listRes.json().catch(() => null)
@ -644,13 +658,9 @@ export default function FinishedDownloads({
: [] : []
setOverrideDoneJobs(items) setOverrideDoneJobs(items)
}
if (metaRes.ok) { // ✅ Count direkt aus /done lesen (withCount=1)
const meta = await metaRes.json().catch(() => null) const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
if (!alive || ac.signal.aborted) return
const countRaw = Number(meta?.count ?? 0)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0 const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
setOverrideDoneTotal(count) setOverrideDoneTotal(count)
@ -1796,12 +1806,15 @@ export default function FinishedDownloads({
useEffect(() => { useEffect(() => {
const onReload = () => { const onReload = () => {
// ✅ Wenn gerade ein Refill läuft, Reload nicht verlieren, sondern merken
if (refillInFlightRef.current) { if (refillInFlightRef.current) {
if (!refillQueuedWhileInFlightRef.current) {
refillQueuedWhileInFlightRef.current = true refillQueuedWhileInFlightRef.current = true
}
return return
} }
if (refillTimerRef.current != null) return
queueRefill() queueRefill()
} }
@ -2108,7 +2121,6 @@ export default function FinishedDownloads({
onChange={(checked) => { onChange={(checked) => {
if (page !== 1) onPageChange(1) if (page !== 1) onPageChange(1)
setIncludeKeep(checked) setIncludeKeep(checked)
queueRefill()
}} }}
/> />
</div> </div>
@ -2267,7 +2279,6 @@ export default function FinishedDownloads({
onChange={(checked) => { onChange={(checked) => {
if (page !== 1) onPageChange(1) if (page !== 1) onPageChange(1)
setIncludeKeep(checked) setIncludeKeep(checked)
queueRefill()
}} }}
ariaLabel="Behaltene Downloads anzeigen" ariaLabel="Behaltene Downloads anzeigen"
size="default" size="default"

View File

@ -617,7 +617,7 @@ export default function FinishedDownloadsCardsView({
? undefined ? undefined
: registerTeaserHost(k) : registerTeaserHost(k)
} }
className="relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5" className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
onMouseEnter={ onMouseEnter={
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k) isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k)
} }
@ -639,7 +639,7 @@ export default function FinishedDownloadsCardsView({
<div <div
className={ className={
'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' + 'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' +
'group-hover:opacity-0 group-focus-within:opacity-0 ' + 'group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0 ' +
(inlineActive ? 'opacity-0' : 'opacity-100') (inlineActive ? 'opacity-0' : 'opacity-100')
} }
> >

View File

@ -462,7 +462,7 @@ export default function FinishedDownloadsGalleryView({
> >
{/* Thumb */} {/* Thumb */}
<div <div
className="group relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5" className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
ref={registerTeaserHostIfNeeded(k)} ref={registerTeaserHostIfNeeded(k)}
onMouseEnter={() => onHoverPreviewKeyChange?.(k)} onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
onMouseLeave={() => { onMouseLeave={() => {
@ -566,7 +566,7 @@ export default function FinishedDownloadsGalleryView({
) : null} ) : null}
{/* Meta-Overlay im Video: unten rechts */} {/* Meta-Overlay im Video: unten rechts */}
<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"> <div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
<div <div
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]" className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size] title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size]

View File

@ -141,6 +141,9 @@ export default function FinishedVideoPreview({
// ✅ falls job.meta keine previewClips enthält: meta.json nachladen // ✅ falls job.meta keine previewClips enthält: meta.json nachladen
const [fetchedMeta, setFetchedMeta] = useState<any | null>(null) const [fetchedMeta, setFetchedMeta] = useState<any | null>(null)
// ✅ verhindert mehrfachen fallback-fetch pro Datei in derselben Komponenteninstanz
const fetchedMetaFilesRef = useRef<Set<string>>(new Set())
// ✅ merge statt "meta ?? fetchedMeta" // ✅ merge statt "meta ?? fetchedMeta"
// job.meta bleibt Basis, fetchedMeta ergänzt fehlende Felder (z.B. previewClips) // job.meta bleibt Basis, fetchedMeta ergänzt fehlende Felder (z.B. previewClips)
const metaForPreview = useMemo(() => { const metaForPreview = useMemo(() => {
@ -150,6 +153,32 @@ export default function FinishedVideoPreview({
return { ...meta, ...fetchedMeta } return { ...meta, ...fetchedMeta }
}, [meta, fetchedMeta]) }, [meta, fetchedMeta])
// ✅ stabiler Guard für den fallback-fetch (berücksichtigt auch bereits geholtes fetchedMeta)
const hasPreviewClipsInAnyMeta = useMemo(() => {
let pcs: any = (metaForPreview as any)?.previewClips
if (typeof pcs === 'string') {
const s = pcs.trim()
if (s.length > 0) {
try {
const parsed = JSON.parse(s)
if (Array.isArray(parsed) && parsed.length > 0) return true
// falls string vorhanden aber nicht parsebar, behandeln wir ihn trotzdem als "vorhanden"
return true
} catch {
return true
}
}
}
if (Array.isArray(pcs) && pcs.length > 0) return true
const nested = (metaForPreview as any)?.preview?.clips
if (Array.isArray(nested) && nested.length > 0) return true
return false
}, [metaForPreview])
const [progressMountTick, setProgressMountTick] = useState(0) const [progressMountTick, setProgressMountTick] = useState(0)
// previewClips mapping: preview.mp4 ist Concatenation von Segmenten // previewClips mapping: preview.mp4 ist Concatenation von Segmenten
@ -299,6 +328,10 @@ export default function FinishedVideoPreview({
const [teaserReady, setTeaserReady] = useState(false) const [teaserReady, setTeaserReady] = useState(false)
const [teaserOk, setTeaserOk] = useState(true) const [teaserOk, setTeaserOk] = useState(true)
// ✅ verhindert doppelte Parent-Callbacks (z.B. Count/Meta-Refresh im Parent)
const emittedDurationRef = useRef<string>('')
const emittedResolutionRef = useRef<string>('')
// inView (Viewport) // inView (Viewport)
const rootRef = useRef<HTMLDivElement | null>(null) const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false) const [inView, setInView] = useState(false)
@ -443,6 +476,12 @@ export default function FinishedVideoPreview({
useEffect(() => { useEffect(() => {
setTeaserReady(false) setTeaserReady(false)
setTeaserOk(true) setTeaserOk(true)
setFetchedMeta(null) // ✅ neue Datei/Asset -> alten fallback-meta cache in state verwerfen
// ✅ neue Datei/Asset -> Callback-Dedupe zurücksetzen
emittedDurationRef.current = ''
emittedResolutionRef.current = ''
setMetaLoaded(false)
}, [previewId, assetNonce, noGenerateTeaser]) }, [previewId, assetNonce, noGenerateTeaser])
useEffect(() => { useEffect(() => {
@ -589,34 +628,50 @@ export default function FinishedVideoPreview({
const shouldPreloadAnimatedAssets = nearView || inView || everInView || (wantsHover && hovered) const shouldPreloadAnimatedAssets = nearView || inView || everInView || (wantsHover && hovered)
// ✅ Wenn meta.json schon alles hat: sofort Callbacks auslösen (kein hidden <video> nötig) // ✅ Wenn meta.json schon alles hat: sofort Callbacks auslösen (kein hidden <video> nötig)
// aber pro Datei/Wert nur 1x (verhindert doppelte Parent-Requests)
useEffect(() => { useEffect(() => {
let did = false let did = false
if (onDuration && hasDuration) { if (onDuration && hasDuration) {
onDuration(job, Number(effectiveDurationSec)) const secs = Number(effectiveDurationSec)
const durationKey = `${file}|dur|${secs}`
if (emittedDurationRef.current !== durationKey) {
emittedDurationRef.current = durationKey
onDuration(job, secs)
did = true did = true
} }
}
if (onResolution && hasResolution) { if (onResolution && hasResolution) {
onResolution(job, Number(effectiveW), Number(effectiveH)) const w = Number(effectiveW)
const h = Number(effectiveH)
const resKey = `${file}|res|${w}x${h}`
if (emittedResolutionRef.current !== resKey) {
emittedResolutionRef.current = resKey
onResolution(job, w, h)
did = true did = true
} }
}
if (did) setMetaLoaded(true) if (did) setMetaLoaded(true)
}, [job, onDuration, onResolution, hasDuration, hasResolution, effectiveDurationSec, effectiveW, effectiveH]) }, [file, job, onDuration, onResolution, hasDuration, hasResolution, effectiveDurationSec, effectiveW, effectiveH])
// ✅ meta fallback fetch: nur wenn previewClips fehlen (für teaser-sprünge) // ✅ meta fallback fetch: nur wenn previewClips fehlen (für teaser-sprünge)
// und pro Datei in dieser Komponenteninstanz nur 1x
useEffect(() => { useEffect(() => {
if (!previewId) return if (!previewId) return
if (!file) return
if (!animated || animatedMode !== 'teaser') return if (!animated || animatedMode !== 'teaser') return
if (!(shouldPreloadAnimatedAssets)) return if (!shouldPreloadAnimatedAssets) return
const pcs = (meta as any)?.previewClips // ✅ wenn wir schon previewClips aus job.meta ODER fetchedMeta haben -> kein Request
const hasPcs = if (hasPreviewClipsInAnyMeta) return
Array.isArray(pcs) || (typeof pcs === 'string' && pcs.length > 0) || Array.isArray((meta as any)?.preview?.clips)
if (hasPcs) return // ✅ gleicher file-Request in dieser Instanz schon gemacht -> nicht nochmal feuern
if (fetchedMetaFilesRef.current.has(file)) return
let aborted = false let aborted = false
const ctrl = new AbortController() const ctrl = new AbortController()
@ -636,15 +691,29 @@ export default function FinishedVideoPreview({
} }
;(async () => { ;(async () => {
const byFile = file ? await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`) : null const byFile = await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`)
if (!aborted && byFile) setFetchedMeta(byFile) if (aborted) return
// ✅ als "versucht" markieren (auch wenn null), damit nearView/inView/hover nicht spammt
fetchedMetaFilesRef.current.add(file)
if (byFile) {
setFetchedMeta(byFile)
}
})() })()
return () => { return () => {
aborted = true aborted = true
ctrl.abort() ctrl.abort()
} }
}, [previewId, file, animated, animatedMode, meta, shouldPreloadAnimatedAssets]) }, [
previewId,
file,
animated,
animatedMode,
shouldPreloadAnimatedAssets,
hasPreviewClipsInAnyMeta,
])
// ✅ Fallback: nur wenn wir wirklich noch nichts haben, über loadedmetadata nachladen // ✅ Fallback: nur wenn wir wirklich noch nichts haben, über loadedmetadata nachladen
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => { const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
@ -654,17 +723,27 @@ export default function FinishedVideoPreview({
if (onDuration && !hasDuration) { if (onDuration && !hasDuration) {
const secs = Number(vv.duration) const secs = Number(vv.duration)
if (Number.isFinite(secs) && secs > 0) onDuration(job, secs) if (Number.isFinite(secs) && secs > 0) {
const durationKey = `${file}|dur|${secs}`
if (emittedDurationRef.current !== durationKey) {
emittedDurationRef.current = durationKey
onDuration(job, secs)
}
}
} }
if (onResolution && !hasResolution) { if (onResolution && !hasResolution) {
const w = Number(vv.videoWidth) const w = Number(vv.videoWidth)
const h = Number(vv.videoHeight) const h = Number(vv.videoHeight)
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) { if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
const resKey = `${file}|res|${w}x${h}`
if (emittedResolutionRef.current !== resKey) {
emittedResolutionRef.current = resKey
onResolution(job, w, h) onResolution(job, w, h)
} }
} }
} }
}
useEffect(() => { useEffect(() => {
setThumbOk(true) setThumbOk(true)