updated
This commit is contained in:
parent
ae67c817ac
commit
a229e1ef2c
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -255,10 +255,15 @@ func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sour
|
||||
}
|
||||
|
||||
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
|
||||
|
||||
assetID := stripHotPrefix(strings.TrimSpace(stem))
|
||||
if assetID == "" {
|
||||
return nil, false
|
||||
}
|
||||
assetID, err = sanitizeID(assetID)
|
||||
if err != nil || assetID == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
metaPath, err := metaJSONPathForAssetID(assetID)
|
||||
if err != nil || strings.TrimSpace(metaPath) == "" {
|
||||
|
||||
Binary file not shown.
@ -650,83 +650,21 @@ func ensureMetaJSONForPlayback(ctx context.Context, videoPath string) {
|
||||
return
|
||||
}
|
||||
|
||||
// ID: basename ohne Ext + ohne HOT
|
||||
base := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
|
||||
id := stripHotPrefix(base)
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
videoPath = strings.TrimSpace(videoPath)
|
||||
if videoPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
metaPath, err := generatedMetaFile(id)
|
||||
if err != nil || strings.TrimSpace(metaPath) == "" {
|
||||
fi, err := os.Stat(videoPath)
|
||||
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// existiert schon?
|
||||
if fi, err := os.Stat(metaPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// ✅ Zentrale Meta-Logik benutzen (meta.go)
|
||||
// - liest vorhandene gültige meta
|
||||
// - erzeugt v2-meta bei Bedarf
|
||||
// - fallbackt auf duration-cache-only meta, wenn ffprobe gerade nicht geht
|
||||
_, _ = ensureVideoMetaForFileBestEffort(ctx, videoPath, "")
|
||||
}
|
||||
|
||||
func recordVideo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
1
backend/web/dist/assets/index-3IFBscEU.css
vendored
Normal file
1
backend/web/dist/assets/index-3IFBscEU.css
vendored
Normal file
File diff suppressed because one or more lines are too long
465
backend/web/dist/assets/index-BZ38s29o.js
vendored
465
backend/web/dist/assets/index-BZ38s29o.js
vendored
File diff suppressed because one or more lines are too long
465
backend/web/dist/assets/index-C4whm-WW.js
vendored
Normal file
465
backend/web/dist/assets/index-C4whm-WW.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-CZMtb58J.css
vendored
1
backend/web/dist/assets/index-CZMtb58J.css
vendored
File diff suppressed because one or more lines are too long
4
backend/web/dist/index.html
vendored
4
backend/web/dist/index.html
vendored
@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>App</title>
|
||||
<script type="module" crossorigin src="/assets/index-BZ38s29o.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CZMtb58J.css">
|
||||
<script type="module" crossorigin src="/assets/index-C4whm-WW.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-3IFBscEU.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -476,15 +476,48 @@ export default function App() {
|
||||
|
||||
// ✅ sagt FinishedDownloads: "bitte ALL neu laden"
|
||||
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) {
|
||||
window.clearTimeout(finishedReloadTimerRef.current)
|
||||
return
|
||||
}
|
||||
|
||||
// Harte Dedupe-Schranke
|
||||
const sinceLast = now - finishedReloadLastDispatchAtRef.current
|
||||
if (sinceLast < cooldownMs) {
|
||||
finishedReloadTimerRef.current = window.setTimeout(() => {
|
||||
finishedReloadTimerRef.current = null
|
||||
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
|
||||
window.dispatchEvent(new CustomEvent('finished-downloads:reload'))
|
||||
finishedReloadLastDispatchAtRef.current = Date.now()
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('finished-downloads:reload', {
|
||||
detail: { source: 'App.requestFinishedReload', reason },
|
||||
})
|
||||
)
|
||||
}, 150)
|
||||
}, [])
|
||||
|
||||
@ -1249,12 +1282,12 @@ export default function App() {
|
||||
|
||||
// ✅ Badge/Count updaten + FinishedDownloads (ALL) reloaden
|
||||
void loadDoneCount()
|
||||
requestFinishedReload()
|
||||
requestFinishedReload('selectedTab effect')
|
||||
|
||||
const onVis = () => {
|
||||
if (!document.hidden) {
|
||||
void loadDoneCount()
|
||||
requestFinishedReload()
|
||||
requestFinishedReload('selectedTab visibilitychange')
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVis)
|
||||
@ -1290,7 +1323,7 @@ export default function App() {
|
||||
if (document.hidden) return
|
||||
if (selectedTabRef.current === 'finished') {
|
||||
void loadDoneCount()
|
||||
requestFinishedReload()
|
||||
requestFinishedReload('done-stream poll tick')
|
||||
} else {
|
||||
void loadDoneCount()
|
||||
}
|
||||
@ -1312,7 +1345,7 @@ export default function App() {
|
||||
lastFireRef.t = Date.now()
|
||||
if (selectedTabRef.current === 'finished') {
|
||||
void loadDoneCount()
|
||||
requestFinishedReload()
|
||||
requestFinishedReload('done-stream coalesced requestRefresh')
|
||||
} else {
|
||||
void loadDoneCount()
|
||||
}
|
||||
@ -1323,7 +1356,7 @@ export default function App() {
|
||||
lastFireRef.t = now
|
||||
if (selectedTabRef.current === 'finished') {
|
||||
void loadDoneCount()
|
||||
requestFinishedReload()
|
||||
requestFinishedReload('done-stream requestRefresh')
|
||||
} else {
|
||||
void loadDoneCount()
|
||||
}
|
||||
@ -1436,18 +1469,14 @@ export default function App() {
|
||||
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()
|
||||
|
||||
// ✅ Nur Finished-Tab wirklich neu laden
|
||||
if (selectedTabRef.current === 'finished') {
|
||||
requestFinishedReload()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
|
||||
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
|
||||
}, [loadDoneCount, requestFinishedReload])
|
||||
}, [loadDoneCount])
|
||||
|
||||
useEffect(() => {
|
||||
const onNav = (ev: Event) => {
|
||||
|
||||
@ -352,6 +352,9 @@ export default function FinishedDownloads({
|
||||
const refillInFlightRef = 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 =
|
||||
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' }
|
||||
| { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string }
|
||||
@ -416,12 +419,6 @@ export default function FinishedDownloads({
|
||||
const [refillTick, setRefillTick] = React.useState(0)
|
||||
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 emitCountHint = React.useCallback((delta: number) => {
|
||||
@ -552,6 +549,16 @@ export default function FinishedDownloads({
|
||||
}
|
||||
}, []) // 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(() => {
|
||||
if (!effectiveAllMode) return
|
||||
|
||||
@ -573,19 +580,32 @@ export default function FinishedDownloads({
|
||||
|
||||
const ac = new AbortController()
|
||||
let alive = true
|
||||
let finished = false // ✅ pro Effect-Instanz idempotent
|
||||
|
||||
// ✅ eigene Session-ID für diese Effect-Instanz
|
||||
const mySession = ++refillSessionRef.current
|
||||
|
||||
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
|
||||
|
||||
// ✅ Nachlauf-Reload ausführen, falls während des laufenden Refills ein Event kam
|
||||
if (refillQueuedWhileInFlightRef.current) {
|
||||
refillQueuedWhileInFlightRef.current = false
|
||||
queueRefill()
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Refill läuft
|
||||
refillInFlightRef.current = true
|
||||
// ✅ Refill läuft (nur wenn diese Session noch aktuell ist)
|
||||
if (refillSessionRef.current === mySession) {
|
||||
refillInFlightRef.current = true
|
||||
}
|
||||
|
||||
// ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all"
|
||||
if (effectiveAllMode) {
|
||||
@ -615,23 +635,17 @@ export default function FinishedDownloads({
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
// 1) Liste + count holen
|
||||
const [listRes, metaRes] = await Promise.all([
|
||||
fetch(
|
||||
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}${
|
||||
includeKeep ? '&includeKeep=1' : ''
|
||||
}`,
|
||||
{ cache: 'no-store' as any, signal: ac.signal }
|
||||
),
|
||||
fetch(
|
||||
`/api/record/done/meta${includeKeep ? '?includeKeep=1' : ''}`,
|
||||
{ cache: 'no-store' as any, signal: ac.signal }
|
||||
),
|
||||
])
|
||||
// 1) Liste + optional count in EINEM Request holen
|
||||
const listRes = await fetch(
|
||||
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1${
|
||||
includeKeep ? '&includeKeep=1' : ''
|
||||
}`,
|
||||
{ cache: 'no-store' as any, signal: ac.signal }
|
||||
)
|
||||
|
||||
if (!alive || ac.signal.aborted) return
|
||||
|
||||
let okAll = listRes.ok && metaRes.ok
|
||||
const okAll = listRes.ok
|
||||
|
||||
if (listRes.ok) {
|
||||
const data = await listRes.json().catch(() => null)
|
||||
@ -644,13 +658,9 @@ export default function FinishedDownloads({
|
||||
: []
|
||||
|
||||
setOverrideDoneJobs(items)
|
||||
}
|
||||
|
||||
if (metaRes.ok) {
|
||||
const meta = await metaRes.json().catch(() => null)
|
||||
if (!alive || ac.signal.aborted) return
|
||||
|
||||
const countRaw = Number(meta?.count ?? 0)
|
||||
// ✅ Count direkt aus /done lesen (withCount=1)
|
||||
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
|
||||
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
||||
|
||||
setOverrideDoneTotal(count)
|
||||
@ -1796,12 +1806,15 @@ export default function FinishedDownloads({
|
||||
|
||||
useEffect(() => {
|
||||
const onReload = () => {
|
||||
// ✅ Wenn gerade ein Refill läuft, Reload nicht verlieren, sondern merken
|
||||
if (refillInFlightRef.current) {
|
||||
refillQueuedWhileInFlightRef.current = true
|
||||
if (!refillQueuedWhileInFlightRef.current) {
|
||||
refillQueuedWhileInFlightRef.current = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (refillTimerRef.current != null) return
|
||||
|
||||
queueRefill()
|
||||
}
|
||||
|
||||
@ -2108,7 +2121,6 @@ export default function FinishedDownloads({
|
||||
onChange={(checked) => {
|
||||
if (page !== 1) onPageChange(1)
|
||||
setIncludeKeep(checked)
|
||||
queueRefill()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -2267,7 +2279,6 @@ export default function FinishedDownloads({
|
||||
onChange={(checked) => {
|
||||
if (page !== 1) onPageChange(1)
|
||||
setIncludeKeep(checked)
|
||||
queueRefill()
|
||||
}}
|
||||
ariaLabel="Behaltene Downloads anzeigen"
|
||||
size="default"
|
||||
|
||||
@ -617,7 +617,7 @@ export default function FinishedDownloadsCardsView({
|
||||
? undefined
|
||||
: 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={
|
||||
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k)
|
||||
}
|
||||
@ -639,7 +639,7 @@ export default function FinishedDownloadsCardsView({
|
||||
<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 ' +
|
||||
'group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0 ' +
|
||||
(inlineActive ? 'opacity-0' : 'opacity-100')
|
||||
}
|
||||
>
|
||||
|
||||
@ -462,7 +462,7 @@ export default function FinishedDownloadsGalleryView({
|
||||
>
|
||||
{/* Thumb */}
|
||||
<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)}
|
||||
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
||||
onMouseLeave={() => {
|
||||
@ -566,7 +566,7 @@ export default function FinishedDownloadsGalleryView({
|
||||
) : null}
|
||||
|
||||
{/* 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
|
||||
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]
|
||||
|
||||
@ -141,6 +141,9 @@ export default function FinishedVideoPreview({
|
||||
// ✅ falls job.meta keine previewClips enthält: meta.json nachladen
|
||||
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"
|
||||
// job.meta bleibt Basis, fetchedMeta ergänzt fehlende Felder (z.B. previewClips)
|
||||
const metaForPreview = useMemo(() => {
|
||||
@ -150,6 +153,32 @@ export default function FinishedVideoPreview({
|
||||
return { ...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)
|
||||
|
||||
// previewClips mapping: preview.mp4 ist Concatenation von Segmenten
|
||||
@ -299,6 +328,10 @@ export default function FinishedVideoPreview({
|
||||
const [teaserReady, setTeaserReady] = useState(false)
|
||||
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)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const [inView, setInView] = useState(false)
|
||||
@ -443,6 +476,12 @@ export default function FinishedVideoPreview({
|
||||
useEffect(() => {
|
||||
setTeaserReady(false)
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
@ -589,34 +628,50 @@ export default function FinishedVideoPreview({
|
||||
const shouldPreloadAnimatedAssets = nearView || inView || everInView || (wantsHover && hovered)
|
||||
|
||||
// ✅ 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(() => {
|
||||
let did = false
|
||||
|
||||
if (onDuration && hasDuration) {
|
||||
onDuration(job, Number(effectiveDurationSec))
|
||||
did = true
|
||||
const secs = Number(effectiveDurationSec)
|
||||
const durationKey = `${file}|dur|${secs}`
|
||||
|
||||
if (emittedDurationRef.current !== durationKey) {
|
||||
emittedDurationRef.current = durationKey
|
||||
onDuration(job, secs)
|
||||
did = true
|
||||
}
|
||||
}
|
||||
|
||||
if (onResolution && hasResolution) {
|
||||
onResolution(job, Number(effectiveW), Number(effectiveH))
|
||||
did = true
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// und pro Datei in dieser Komponenteninstanz nur 1x
|
||||
useEffect(() => {
|
||||
if (!previewId) return
|
||||
if (!file) return
|
||||
if (!animated || animatedMode !== 'teaser') return
|
||||
if (!(shouldPreloadAnimatedAssets)) return
|
||||
if (!shouldPreloadAnimatedAssets) return
|
||||
|
||||
const pcs = (meta as any)?.previewClips
|
||||
const hasPcs =
|
||||
Array.isArray(pcs) || (typeof pcs === 'string' && pcs.length > 0) || Array.isArray((meta as any)?.preview?.clips)
|
||||
// ✅ wenn wir schon previewClips aus job.meta ODER fetchedMeta haben -> kein Request
|
||||
if (hasPreviewClipsInAnyMeta) return
|
||||
|
||||
if (hasPcs) return
|
||||
// ✅ gleicher file-Request in dieser Instanz schon gemacht -> nicht nochmal feuern
|
||||
if (fetchedMetaFilesRef.current.has(file)) return
|
||||
|
||||
let aborted = false
|
||||
const ctrl = new AbortController()
|
||||
@ -636,15 +691,29 @@ export default function FinishedVideoPreview({
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
const byFile = file ? await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`) : null
|
||||
if (!aborted && byFile) setFetchedMeta(byFile)
|
||||
const byFile = await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`)
|
||||
if (aborted) return
|
||||
|
||||
// ✅ als "versucht" markieren (auch wenn null), damit nearView/inView/hover nicht spammt
|
||||
fetchedMetaFilesRef.current.add(file)
|
||||
|
||||
if (byFile) {
|
||||
setFetchedMeta(byFile)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
aborted = true
|
||||
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
|
||||
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
|
||||
@ -654,14 +723,24 @@ export default function FinishedVideoPreview({
|
||||
|
||||
if (onDuration && !hasDuration) {
|
||||
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) {
|
||||
const w = Number(vv.videoWidth)
|
||||
const h = Number(vv.videoHeight)
|
||||
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
|
||||
onResolution(job, w, h)
|
||||
const resKey = `${file}|res|${w}x${h}`
|
||||
if (emittedResolutionRef.current !== resKey) {
|
||||
emittedResolutionRef.current = resKey
|
||||
onResolution(job, w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user