From 3b118e33041548f35d3630d1dd022128696fac33 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Mon, 9 Mar 2026 10:01:31 +0800 Subject: [PATCH] perf(video): perf --- app/api/transfer/[path]/route.ts | 2 +- .../label/ffmpeg/VideoFrameExtractor.tsx | 2 +- app/component/label/ffmpeg/ffmpeg.worker.ts | 552 ++++++++++++++---- .../label/video2image/processVideo.ts | 309 +++++++--- app/layout.tsx | 4 +- app/management/team/employee/page.tsx | 12 +- components/label/LabelNossr.tsx | 433 +++++++++++++- components/label/api/label/index.ts | 60 +- components/label/components/BottomTools.tsx | 27 +- .../label/components/PaperContainer.tsx | 3 +- components/label/components/TopTools.tsx | 33 +- components/layout/AppLayout.tsx | 175 +++--- 12 files changed, 1284 insertions(+), 328 deletions(-) diff --git a/app/api/transfer/[path]/route.ts b/app/api/transfer/[path]/route.ts index 1d56b7e..9c15efd 100644 --- a/app/api/transfer/[path]/route.ts +++ b/app/api/transfer/[path]/route.ts @@ -62,7 +62,7 @@ export async function POST(req: any) { if (method === "POST" || method === "PUT") { let refreshBody = JSON.stringify({ ...JSON.parse(data), - token: userData?.refresh_token, + token: userData?.refresh_token || "", }) fetchOptions.body = isRefresh ? refreshBody : data } else { diff --git a/app/component/label/ffmpeg/VideoFrameExtractor.tsx b/app/component/label/ffmpeg/VideoFrameExtractor.tsx index ef6104e..ac73e91 100644 --- a/app/component/label/ffmpeg/VideoFrameExtractor.tsx +++ b/app/component/label/ffmpeg/VideoFrameExtractor.tsx @@ -239,7 +239,7 @@ export default function VideoFrameExtractor() { file, args: [ "-vf", - "fps=1,scale='min(720,iw)':-1", + "fps=1,scale='min(720,iw)':-2:flags=lanczos+accurate_rnd+full_chroma_int,unsharp=5:5:0.5:3:3:0", "-q:v", "2", "frame_%03d.jpg", diff --git a/app/component/label/ffmpeg/ffmpeg.worker.ts b/app/component/label/ffmpeg/ffmpeg.worker.ts index b8634f7..9587690 100644 --- a/app/component/label/ffmpeg/ffmpeg.worker.ts +++ b/app/component/label/ffmpeg/ffmpeg.worker.ts @@ -1,7 +1,14 @@ // Define types for worker messages export type WorkerMessage = | { type: "LOAD"; baseURL: string } - | { type: "EXEC"; file: File; args: string[]; outputPattern: string } + | { + type: "EXEC" + file: File + args: string[] + outputPattern: string + quickFirstFrame?: boolean + continueAfterQuickFirstFrame?: boolean + } export type WorkerResponse = | { type: "READY" } @@ -35,6 +42,7 @@ ctx.onmessage = async (event: MessageEvent) => { try { switch (type) { case "LOAD": + // Worker 生命周期内只加载一次 wasm,后续任务复用。 if (loaded) { ctx.postMessage({ type: "READY" }) return @@ -53,7 +61,13 @@ ctx.onmessage = async (event: MessageEvent) => { case "EXEC": if (!loaded) throw new Error("FFmpeg not loaded") - const { file, args, outputPattern } = event.data + const { + file, + args, + outputPattern, + quickFirstFrame, + continueAfterQuickFirstFrame, + } = event.data ctx.postMessage({ type: "LOG", @@ -64,6 +78,7 @@ ctx.onmessage = async (event: MessageEvent) => { let inputPath = `${mountDir}/${file.name}` const mountInput = async () => { + // 用 WORKERFS 直接挂载 File,避免先复制到 wasm FS 的额外内存占用。 await ffmpeg.createDir(mountDir) await ffmpeg.mount("WORKERFS" as any, { files: [file] }, mountDir) } @@ -135,6 +150,27 @@ ctx.onmessage = async (event: MessageEvent) => { return String(e) } + const deleteFileQuietly = async (filePath: string) => { + try { + await ffmpeg.deleteFile(filePath) + } catch (e) { + const msg = normalizeError(e) + const lower = msg.toLowerCase() + // Cleanup path may not exist (e.g. ffprobe output not created). + if ( + lower.includes("no such file") || + lower.includes("enoent") || + (lower.includes("errnoerror") && lower.includes("fs error")) + ) { + return + } + ctx.postMessage({ + type: "LOG", + message: `[cleanup] deleteFile failed path=${filePath}: ${msg}`, + }) + } + } + const isOom = (msg: string) => msg.includes("memory access out of bounds") || msg.includes("Cannot enlarge memory") || @@ -154,11 +190,66 @@ ctx.onmessage = async (event: MessageEvent) => { } const logThrottled = makeThrottledLogger(1000) + const metrics = { + startedAtMs: nowMs(), + mode: "unknown", + segmentAttempts: 0, + segmentDurationSec: 0, + fallbackToSingle: false, + reloadCount: 0, + frameCount: 0, + execMs: 0, + readMs: 0, + } + + const fileMB = file.size / 1024 / 1024 + const filterGuardLevel = (() => { + if (isRawH264) return fileMB >= 40 ? 2 : 1 + if (fileMB >= 60) return 2 + if (fileMB >= 30) return 1 + return 0 + })() + + const runtimeArgs = (() => { + const nextArgs = [...args] + if (filterGuardLevel <= 0) return nextArgs + + const vfIdx = nextArgs.indexOf("-vf") + if (vfIdx >= 0 && typeof nextArgs[vfIdx + 1] === "string") { + // 内存守护:高风险场景先去掉 unsharp 并降级 scale flags,减少 wasm 压力。 + let optimizedVf = nextArgs[vfIdx + 1].replace( + /(?:^|,)unsharp=[^,]*/g, + "" + ) + + if (filterGuardLevel >= 2) { + const lightScaleFlag = + file.size >= 40 * 1024 * 1024 ? "bilinear" : "bicubic" + optimizedVf = optimizedVf.replace( + /:flags=[^,']+/g, + `:flags=${lightScaleFlag}` + ) + } + + optimizedVf = optimizedVf + .replace(/,,+/g, ",") + .replace(/^,|,$/g, "") + nextArgs[vfIdx + 1] = optimizedVf + } + + ctx.postMessage({ + type: "LOG", + message: `[oom-guard] apply level=${filterGuardLevel} reason=${ + isRawH264 ? "raw-h264" : "large-file" + }`, + }) + return nextArgs + })() const findArgValue = (flag: string) => { - const idx = args.indexOf(flag) + const idx = runtimeArgs.indexOf(flag) if (idx < 0) return null - const value = args[idx + 1] + const value = runtimeArgs[idx + 1] return typeof value === "string" ? value : null } @@ -171,57 +262,228 @@ ctx.onmessage = async (event: MessageEvent) => { return Number.isFinite(fps) && fps > 0 ? fps : null } - let durationSec = 0 - const durationProbePath = "__duration.txt" - try { - await ffmpeg.ffprobe([ - "-v", - "error", - "-show_entries", - "format=duration", - "-of", - "default=noprint_wrappers=1:nokey=1", - inputPath, - "-o", - durationProbePath, - ]) - const txt = (await ffmpeg.readFile( - durationProbePath, - "utf8" - )) as string - const parsed = Number.parseFloat(String(txt).trim()) - if (Number.isFinite(parsed) && parsed > 0) durationSec = parsed - } catch (e) { - ctx.postMessage({ - type: "LOG", - message: `[probe] ffprobe failed: ${normalizeError(e)}`, - }) - } finally { - await ffmpeg.deleteFile(durationProbePath).catch((err) => { - console.log("deleteFile error", err) - }) + const parseScaleWidthFromFilter = () => { + const vf = findArgValue("-vf") + if (!vf) return null + const minMatch = vf.match(/min\(\s*(\d+)\s*,\s*iw\s*\)/i) + if (minMatch) { + const parsed = Number(minMatch[1]) + return Number.isFinite(parsed) && parsed > 0 ? parsed : null + } + const fixedMatch = vf.match( + /scale\s*=\s*'?\s*(\d+)\s*:\s*-?\d+(?::[^,'\s]+=[^,'\s]+)*\s*'?/i + ) + if (!fixedMatch) return null + const parsed = Number(fixedMatch[1]) + return Number.isFinite(parsed) && parsed > 0 ? parsed : null } - if (!durationSec) { - const logHandler = ({ message }: { message: string }) => { - const match = message.match( - /Duration: (\d+):(\d+):(\d+(?:\.\d+)?)/ + const outputExt = (() => { + for (let i = runtimeArgs.length - 1; i >= 0; i -= 1) { + const v = runtimeArgs[i] + if (typeof v !== "string") continue + const m = v.match(/\.([a-zA-Z0-9]+)$/) + if (m) return m[1].toLowerCase() + } + return "jpg" + })() + + let quickFirstFrameSucceeded = false + if (quickFirstFrame) { + metrics.mode = "first-frame-fast" + try { + const requestedScaleWidth = parseScaleWidthFromFilter() ?? 640 + const fastWidth = Math.max( + 320, + Math.min(requestedScaleWidth, 640) ) - if (match) { - const [, h, m, s] = match - durationSec = - parseFloat(h) * 3600 + parseFloat(m) * 60 + parseFloat(s) + const vf = findArgValue("-vf") + const qv = findArgValue("-q:v") + const fastQ = Number.isFinite(Number(qv)) + ? String(Math.max(Number(qv), 4)) + : "4" + + const fastFilter = (() => { + const baseFilter = + vf + ?.replace(/(?:^|,)fps=\d+(?:\.\d+)?(?=,|$)/g, "") + .replace(/^,|,$/g, "") + .replace(/,,+/g, ",") ?? "" + let filter = baseFilter || `scale='min(${fastWidth},iw)':-2` + filter = filter.replace( + /min\(\s*\d+\s*,\s*iw\s*\)/g, + `min(${fastWidth},iw)` + ) + filter = filter.replace(/:flags=[^,']+/g, ":flags=bilinear") + if (!/:flags=/.test(filter)) filter = `${filter}:flags=bilinear` + if (!/(?:^|,)format=/.test(filter)) + filter = `${filter},format=yuv420p` + return filter + })() + + const fastOutputName = `frame_fast_000001.${outputExt}` + const fastArgs = [ + "-ss", + "0", + "-i", + inputPath, + "-threads", + "1", + "-an", + "-sn", + "-dn", + "-vf", + fastFilter, + "-q:v", + fastQ, + "-pix_fmt", + "yuv420p", + "-frames:v", + "1", + fastOutputName, + ] + + ctx.postMessage({ + type: "LOG", + message: `[mode] quick-first-frame width=${fastWidth} q=${fastQ}`, + }) + + const execStartedAt = nowMs() + const ret = await ffmpeg.exec(fastArgs) + metrics.execMs += nowMs() - execStartedAt + if (ret !== 0) { + throw new Error(`FAST_FIRST_FRAME_RET_${ret}`) } + + const files = await ffmpeg.listDir(".") + const imageFiles = files.filter( + (f) => + !f.isDir && + f.name.startsWith("frame_") && + f.name.endsWith(`.${outputExt}`) + ) + const targetName = imageFiles.find( + (f) => f.name === fastOutputName + )?.name + ? fastOutputName + : imageFiles[0]?.name + if (!targetName) { + throw new Error("NO_FAST_FIRST_FRAME") + } + + const readStartedAt = nowMs() + const raw = (await ffmpeg.readFile(targetName)) as Uint8Array + const copy = raw.slice() + metrics.readMs += nowMs() - readStartedAt + metrics.frameCount = 1 + quickFirstFrameSucceeded = true + await deleteFileQuietly(targetName) + for (const imageFile of imageFiles) { + if (imageFile.name === targetName) continue + await deleteFileQuietly(imageFile.name) + } + + ctx.postMessage( + { + type: "SEGMENT_DATA", + files: [{ name: targetName, data: copy }], + }, + [copy.buffer] + ) + } catch (e) { + const quickError = normalizeError(e) + if (!continueAfterQuickFirstFrame) throw e + ctx.postMessage({ + type: "LOG", + message: `[mode] quick-first-frame failed, fallback to full extraction: ${quickError}`, + }) } - ffmpeg.on("log", logHandler) - try { - await ffmpeg.exec(["-i", inputPath]) - } catch (e) { - void e - } finally { - ffmpeg.off("log", logHandler) + if (!continueAfterQuickFirstFrame) { + ctx.postMessage({ type: "PROGRESS", progress: 1 }) + const elapsedMs = nowMs() - metrics.startedAtMs + ctx.postMessage({ + type: "LOG", + message: `[metrics] phase=done mode=${metrics.mode} frames=1 totalMs=${Math.round( + elapsedMs + )} execMs=${Math.round(metrics.execMs)} readMs=${Math.round( + metrics.readMs + )}`, + }) + ctx.postMessage({ type: "DONE" }) + return } + + if (quickFirstFrameSucceeded) { + ctx.postMessage({ type: "PROGRESS", progress: 0.01 }) + ctx.postMessage({ + type: "LOG", + message: + "[mode] quick-first-frame done; continue full extraction in same worker", + }) + } + } + + let durationSec = 0 + const skipDurationProbe = Boolean( + quickFirstFrame && continueAfterQuickFirstFrame + ) + const durationProbePath = "__duration.txt" + if (!skipDurationProbe) { + try { + await ffmpeg.ffprobe([ + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + inputPath, + "-o", + durationProbePath, + ]) + const txt = (await ffmpeg.readFile( + durationProbePath, + "utf8" + )) as string + const parsed = Number.parseFloat(String(txt).trim()) + if (Number.isFinite(parsed) && parsed > 0) durationSec = parsed + } catch (e) { + ctx.postMessage({ + type: "LOG", + message: `[probe] ffprobe failed: ${normalizeError(e)}`, + }) + } finally { + await deleteFileQuietly(durationProbePath) + } + + if (!durationSec) { + const logHandler = ({ message }: { message: string }) => { + const match = message.match( + /Duration: (\d+):(\d+):(\d+(?:\.\d+)?)/ + ) + if (match) { + const [, h, m, s] = match + durationSec = + parseFloat(h) * 3600 + parseFloat(m) * 60 + parseFloat(s) + } + } + + ffmpeg.on("log", logHandler) + try { + await ffmpeg.exec(["-i", inputPath]) + } catch (e) { + void e + } finally { + ffmpeg.off("log", logHandler) + } + } + } else { + ctx.postMessage({ + type: "LOG", + message: + "[probe] skip duration probe after quick-first-frame to reduce second-frame latency", + }) } ctx.postMessage({ @@ -232,58 +494,72 @@ ctx.onmessage = async (event: MessageEvent) => { const fps = parseFps() ?? 1 const stepSec = 1 / fps + const streamStartSec = quickFirstFrameSucceeded ? stepSec : 0 const preferSingleFrame = file.size >= 20 * 1024 * 1024 const maxFrames = preferSingleFrame ? 30 : 0 const reloadEvery = preferSingleFrame ? 10 : 0 let sentBytes = 0 - const maxWidth = - file.size >= 40 * 1024 * 1024 ? 360 : preferSingleFrame ? 480 : 720 + const requestedScaleWidth = parseScaleWidthFromFilter() ?? 720 + const shouldDownscaleForSafety = + filterGuardLevel >= 2 && requestedScaleWidth > 640 + const maxWidth = shouldDownscaleForSafety + ? Math.max(360, Math.round(requestedScaleWidth * 0.78)) + : requestedScaleWidth ctx.postMessage({ type: "LOG", - message: `[config] fileMB=${Math.round(file.size / 1024 / 1024)} fps=${fps} maxWidth=${maxWidth} preferSingleFrame=${preferSingleFrame}`, + message: `[config] fileMB=${Math.round(fileMB)} fps=${fps} requestedWidth=${requestedScaleWidth} maxWidth=${maxWidth} preferSingleFrame=${preferSingleFrame} filterGuardLevel=${filterGuardLevel} streamStartSec=${streamStartSec}`, + }) + ctx.postMessage({ + type: "LOG", + message: `[metrics] phase=start fileMB=${Math.round(fileMB)} durationSec=${durationSec} fps=${fps} requestedWidth=${requestedScaleWidth} maxWidth=${maxWidth} preferSingleFrame=${preferSingleFrame} streamStartSec=${streamStartSec}`, }) - - const outputExt = (() => { - for (let i = args.length - 1; i >= 0; i -= 1) { - const v = args[i] - if (typeof v !== "string") continue - const m = v.match(/\.([a-zA-Z0-9]+)$/) - if (m) return m[1].toLowerCase() - } - return "jpg" - })() const buildSingleFrameArgs = ( outputPattern: string, - startNumber: number + startNumber: number, + useBasicFilter = false ) => { const vf = findArgValue("-vf") const qv = findArgValue("-q:v") - const baseFilter = - vf - ?.replace(/(?:^|,)fps=\d+(?:\.\d+)?(?=,|$)/g, "") - .replace(/^,|,$/g, "") - .replace(/,,+/g, ",") ?? null - - const patchedFilter = (() => { - if (!baseFilter || !baseFilter.trim()) return null - let f = baseFilter - f = f.replace(/min\(\s*720\s*,\s*iw\s*\)/g, `min(${maxWidth},iw)`) - f = f.replace(/min\(\s*480\s*,\s*iw\s*\)/g, `min(${maxWidth},iw)`) - f = f.replace(/min\(\s*360\s*,\s*iw\s*\)/g, `min(${maxWidth},iw)`) - if (!/(?:^|,)format=/.test(f)) f = `${f},format=yuv420p` - return f - })() - const out: string[] = [] - if (patchedFilter && patchedFilter.trim()) - out.push("-vf", patchedFilter) + if (useBasicFilter) { + const fallbackWidth = Math.max(240, Math.min(maxWidth, 640)) + out.push( + "-vf", + `scale='min(${fallbackWidth},iw)':-2:flags=bicubic,format=yuv420p` + ) + } else { + const baseFilter = + vf + ?.replace(/(?:^|,)fps=\d+(?:\.\d+)?(?=,|$)/g, "") + .replace(/^,|,$/g, "") + .replace(/,,+/g, ",") ?? null + + const patchedFilter = (() => { + if (!baseFilter || !baseFilter.trim()) return null + let f = baseFilter + f = f.replace( + /min\(\s*\d+\s*,\s*iw\s*\)/g, + `min(${maxWidth},iw)` + ) + f = f.replace( + /scale\s*=\s*'?\s*\d+\s*:\s*(-?\d+)((?::[^,'\s]+=[^,'\s]+)*)\s*'?/g, + (_match, h: string, opts?: string) => + `scale='min(${maxWidth},iw)':${h}${opts || ""}` + ) + if (!/(?:^|,)format=/.test(f)) f = `${f},format=yuv420p` + return f + })() + + if (patchedFilter && patchedFilter.trim()) + out.push("-vf", patchedFilter) + } if (qv) { const qNum = Number(qv) if (Number.isFinite(qNum) && preferSingleFrame) { - out.push("-q:v", String(Math.max(qNum, 6))) + out.push("-q:v", String(Math.max(qNum, useBasicFilter ? 4 : 3))) } else { out.push("-q:v", qv) } @@ -295,13 +571,20 @@ ctx.onmessage = async (event: MessageEvent) => { } const segmentCandidates = preferSingleFrame - ? [] + ? [2, 3, 5].filter((v) => v >= stepSec && v > 0) : [2, 1.2, 1].filter((v) => v >= stepSec && v > 0) - const trySegmentDuration = async (segmentDurationSec: number) => { - let t = 0 + const trySegmentDuration = async ( + segmentDurationSec: number, + frameLimit = 0 + ) => { + // 主路径:按时间片执行 ffmpeg,分批读取并回传帧,控制内存峰值。 + metrics.mode = "segment" + metrics.segmentDurationSec = segmentDurationSec + let t = streamStartSec let emptyStreak = 0 let producedAny = false + let producedFrames = 0 while (t < durationSec) { logThrottled( @@ -320,10 +603,12 @@ ctx.onmessage = async (event: MessageEvent) => { "-an", "-sn", "-dn", - ...args, + ...runtimeArgs, ] + const execStartedAt = nowMs() const ret = await ffmpeg.exec(segmentArgs) + metrics.execMs += nowMs() - execStartedAt if (ret !== 0) { logThrottled( `[exec] mode=segment ret=${ret} t=${t.toFixed(3)} dur=${segmentDurationSec}` @@ -348,19 +633,32 @@ ctx.onmessage = async (event: MessageEvent) => { const segmentFiles: { name: string; data: Uint8Array }[] = [] const transfer: ArrayBuffer[] = [] + const readStartedAt = nowMs() - for (const f of imageFiles) { + const remaining = + frameLimit > 0 ? Math.max(frameLimit - producedFrames, 0) : 0 + for (let i = 0; i < imageFiles.length; i += 1) { + const f = imageFiles[i] + if (frameLimit > 0 && i >= remaining) { + await deleteFileQuietly(f.name) + continue + } const raw = (await ffmpeg.readFile(f.name)) as Uint8Array const copy = raw.slice() segmentFiles.push({ name: `t_${t.toFixed(3)}_${f.name}`, data: copy, }) + sentBytes += copy.byteLength transfer.push(copy.buffer) - await ffmpeg.deleteFile(f.name) + await deleteFileQuietly(f.name) } + metrics.readMs += nowMs() - readStartedAt if (segmentFiles.length > 0) { + metrics.frameCount += segmentFiles.length + producedFrames += segmentFiles.length + // 通过 transferable 传输二进制,避免主线程和 worker 双份拷贝。 ctx.postMessage( { type: "SEGMENT_DATA", files: segmentFiles }, transfer @@ -372,6 +670,7 @@ ctx.onmessage = async (event: MessageEvent) => { progress: Math.min((t + segmentDurationSec) / durationSec, 1), }) + if (frameLimit > 0 && producedFrames >= frameLimit) break t += segmentDurationSec } @@ -380,14 +679,19 @@ ctx.onmessage = async (event: MessageEvent) => { } } - const runSingleFrameMode = async () => { - let index = 0 + const runSingleFrameMode = async (useBasicFilter = false) => { + // 回退路径:逐秒/逐帧抽样,牺牲吞吐换稳定性。 + metrics.mode = useBasicFilter ? "single-fallback" : "single" + let index = streamStartSec > 0 ? 1 : 0 let emptyStreak = 0 + let producedCount = 0 const pattern = `frame_%06d.${outputExt}` const reloadCore = async (reason: string) => { if (!lastBaseURL) return + metrics.reloadCount += 1 ctx.postMessage({ type: "LOG", message: `[reload] ${reason}` }) + // 长任务周期性重载 core,缓解 wasm 内存碎片化。 await unmountInput() ffmpeg.terminate() loaded = false @@ -399,7 +703,7 @@ ctx.onmessage = async (event: MessageEvent) => { await mountInput() } - for (let t = 0; t < durationSec; t += stepSec) { + for (let t = streamStartSec; t < durationSec; t += stepSec) { if (maxFrames > 0 && index >= maxFrames) break if (reloadEvery > 0 && index > 0 && index % reloadEvery === 0) { await reloadCore(`periodic frame=${index}`) @@ -416,11 +720,13 @@ ctx.onmessage = async (event: MessageEvent) => { "-an", "-sn", "-dn", - ...(buildSingleFrameArgs(pattern, index) || []), + ...(buildSingleFrameArgs(pattern, index, useBasicFilter) || []), ] try { + const execStartedAt = nowMs() const ret = await ffmpeg.exec(singleFrameArgs) + metrics.execMs += nowMs() - execStartedAt ctx.postMessage({ type: "LOG", message: `[exec] mode=single ret=${ret} t=${t.toFixed(3)} out=${outputName}`, @@ -442,20 +748,20 @@ ctx.onmessage = async (event: MessageEvent) => { )}`, }) } - await ffmpeg.deleteFile(outputName).catch((err) => { - console.log("deleteFile error", err) - }) + await deleteFileQuietly(outputName) index += 1 continue } try { + const readStartedAt = nowMs() const raw = (await ffmpeg.readFile(outputName)) as Uint8Array const copy = raw.slice() + metrics.readMs += nowMs() - readStartedAt sentBytes += copy.byteLength - await ffmpeg.deleteFile(outputName).catch((err) => { - console.log("deleteFile error", err) - }) + metrics.frameCount += 1 + producedCount += 1 + await deleteFileQuietly(outputName) ctx.postMessage( { @@ -484,16 +790,25 @@ ctx.onmessage = async (event: MessageEvent) => { index += 1 } + + if (producedCount === 0) { + throw new Error( + useBasicFilter + ? "NO_FRAMES_SINGLE_FALLBACK" + : "NO_FRAMES_SINGLE" + ) + } } let segmentOk = false for (const seg of segmentCandidates) { + metrics.segmentAttempts += 1 try { ctx.postMessage({ type: "LOG", message: `[mode] trying segmentDuration=${seg}s stepSec=${stepSec}s`, }) - await trySegmentDuration(seg) + await trySegmentDuration(seg, maxFrames) segmentOk = true break } catch (e) { @@ -509,14 +824,49 @@ ctx.onmessage = async (event: MessageEvent) => { } } if (!segmentOk) { + metrics.fallbackToSingle = segmentCandidates.length > 0 ctx.postMessage({ type: "LOG", message: `[mode] segment mode unavailable; fallback to single-frame sampling`, }) - await runSingleFrameMode() + try { + await runSingleFrameMode() + } catch (e) { + const msg = normalizeError(e) + if (!msg.includes("NO_FRAMES_SINGLE")) throw e + ctx.postMessage({ + type: "LOG", + message: + "[mode] single mode produced no frames; retry with basic filter", + }) + await runSingleFrameMode(true) + } } - // Send DONE without data + const elapsedMs = nowMs() - metrics.startedAtMs + const sentMB = Math.round((sentBytes / 1024 / 1024) * 100) / 100 + const avgFrameKB = metrics.frameCount + ? Math.round((sentBytes / metrics.frameCount / 1024) * 10) / 10 + : 0 + const throughputFps = + elapsedMs > 0 + ? Math.round((metrics.frameCount / (elapsedMs / 1000)) * 100) / + 100 + : 0 + ctx.postMessage({ + type: "LOG", + message: `[metrics] phase=done mode=${metrics.mode} frames=${metrics.frameCount} sentMB=${sentMB} avgFrameKB=${avgFrameKB} totalMs=${Math.round( + elapsedMs + )} execMs=${Math.round(metrics.execMs)} readMs=${Math.round( + metrics.readMs + )} throughputFps=${throughputFps} segmentAttempts=${ + metrics.segmentAttempts + } segmentDurationSec=${metrics.segmentDurationSec} fallbackToSingle=${ + metrics.fallbackToSingle + } reloadCount=${metrics.reloadCount}`, + }) + + // 所有帧都已通过 SEGMENT_DATA 流式回传,这里只发完成信号。 ctx.postMessage({ type: "DONE" }) } catch (e: any) { const errorMessage = @@ -555,7 +905,7 @@ ctx.onmessage = async (event: MessageEvent) => { throw e } finally { - // Cleanup input + // 始终清理挂载目录,避免残留状态影响下一次任务。 try { await unmountInput() } catch (e) { diff --git a/app/component/label/video2image/processVideo.ts b/app/component/label/video2image/processVideo.ts index ed9233e..551a43b 100644 --- a/app/component/label/video2image/processVideo.ts +++ b/app/component/label/video2image/processVideo.ts @@ -13,16 +13,41 @@ interface ProcessVideoParams { namePrefix?: string baseURL?: string args?: string[] + timeoutMs?: number + firstFrameTimeoutMs?: number + quickFirstFrame?: boolean + continueAfterQuickFirstFrame?: boolean + onFrames?: (frames: VideoFrameFile[]) => void + onProgress?: (progress: number) => void } const defaultArgs = [ "-vf", - "fps=1,scale='min(720,iw)':-1", + // 基线策略:1fps + Lanczos 缩放 + 轻微锐化,兼顾清晰度与处理速度。 + "fps=1,scale='min(720,iw)':-2:flags=lanczos+accurate_rnd+full_chroma_int,unsharp=5:5:0.5:3:3:0", "-q:v", "2", "frame_%03d.jpg", ] +const lowMemoryArgs = [ + "-vf", + // OOM 回退:降低分辨率并使用更轻滤镜,优先保稳定输出。 + "fps=1,scale='min(480,iw)':-2:flags=bilinear,format=yuv420p", + "-q:v", + "4", + "frame_%03d.jpg", +] + +const timeoutFallbackArgs = [ + "-vf", + // 超时回退:进一步降采样,减少单次解码计算量。 + "fps=1,scale='min(360,iw)':-2:flags=bilinear,format=yuv420p", + "-q:v", + "5", + "frame_%03d.jpg", +] + const mimeByExt: Record = { jpg: "image/jpeg", jpeg: "image/jpeg", @@ -101,7 +126,7 @@ export const processVideo = async ( if (typeof window === "undefined") return [] const args = params.args && params.args.length ? params.args : defaultArgs - const outputExt = getOutputExtFromArgs(args) + const fileExt = getFileExt(params.fileName) const prefix = sanitizePrefix( params.namePrefix || stripFileExt(params.fileName) ) @@ -109,90 +134,208 @@ export const processVideo = async ( params.baseURL || `${window.location.origin}${process.env.NEXT_PUBLIC_BASE_PATH}/wasm` const inputFile = base64ToFile(params.base64Data, params.fileName) + const fileMB = inputFile.size / 1024 / 1024 + const inferredTimeoutMs = (() => { + // 大文件和裸流(.h264/.264)给予更长时间,避免误判超时。 + let ms = 240_000 + if (fileMB > 30) ms += Math.round((fileMB - 30) * 2_500) + if (fileExt === "h264" || fileExt === "264") ms = Math.max(ms, 600_000) + return Math.min(ms, 1_200_000) + })() + const timeoutMs = Number.isFinite(params.timeoutMs) + ? Math.max(10_000, Number(params.timeoutMs)) + : inferredTimeoutMs - const worker = new Worker( - new URL("../ffmpeg/ffmpeg.worker.ts", import.meta.url) - ) - let frameIndex = 0 - const frames: VideoFrameFile[] = [] + const inferredFirstFrameTimeoutMs = (() => { + let ms = 20_000 + if (fileMB > 30) ms += Math.round((fileMB - 30) * 1_000) + if (fileExt === "h264" || fileExt === "264") ms = Math.max(ms, 45_000) + return Math.min(ms, 120_000) + })() + const firstFrameTimeoutMs = Number.isFinite(params.firstFrameTimeoutMs) + ? Math.max(8_000, Number(params.firstFrameTimeoutMs)) + : inferredFirstFrameTimeoutMs + + const runOnce = async ( + runArgs: string[], + runTimeoutMs: number, + runFirstFrameTimeoutMs: number + ) => { + const worker = new Worker( + new URL("../ffmpeg/ffmpeg.worker.ts", import.meta.url) + ) + const outputExt = getOutputExtFromArgs(runArgs) + let frameIndex = 0 + const frames: VideoFrameFile[] = [] + + try { + return await new Promise((resolve, reject) => { + let settled = false + let timeoutId: ReturnType | null = null + let firstFrameTimeoutId: ReturnType | null = null + + const finish = (next: () => void) => { + if (settled) return + settled = true + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = null + } + if (firstFrameTimeoutId) { + clearTimeout(firstFrameTimeoutId) + firstFrameTimeoutId = null + } + next() + } + + timeoutId = setTimeout(() => { + finish(() => + reject( + new Error( + `video process timeout after ${Math.round(runTimeoutMs / 1000)}s` + ) + ) + ) + }, runTimeoutMs) + firstFrameTimeoutId = setTimeout(() => { + if (frames.length > 0) return + finish(() => + reject( + new Error( + `video process timeout before first frame after ${Math.round(runFirstFrameTimeoutMs / 1000)}s` + ) + ) + ) + }, runFirstFrameTimeoutMs) + + worker.onmessage = (event: MessageEvent) => { + const message = event.data + switch (message.type) { + case "READY": { + // Worker 加载完 wasm 后才允许真正执行抽帧命令。 + const runMessage: WorkerMessage = { + type: "EXEC", + file: inputFile, + args: runArgs, + outputPattern: "frame_", + quickFirstFrame: Boolean(params.quickFirstFrame), + continueAfterQuickFirstFrame: Boolean( + params.continueAfterQuickFirstFrame + ), + } + worker.postMessage(runMessage) + return + } + case "SEGMENT_DATA": { + // 分片回传:边抽边传,降低一次性内存峰值并加快首屏可见时间。 + const files = message.files || [] + const batchFrames: VideoFrameFile[] = [] + files.forEach((file) => { + const ext = getFileExt(file.name) || outputExt || "jpg" + const frameNo = String(frameIndex).padStart(6, "0") + const frameName = `${prefix}_frame_${frameNo}.${ext}` + frameIndex += 1 + const frame = { + name: frameName, + dataUrl: `data:${mimeByExt[ext] || "image/jpeg"};base64,${uint8ArrayToBase64(file.data)}`, + } + frames.push(frame) + batchFrames.push(frame) + }) + if (batchFrames.length) { + if (firstFrameTimeoutId) { + clearTimeout(firstFrameTimeoutId) + firstFrameTimeoutId = null + } + try { + params.onFrames?.(batchFrames) + } catch (callbackError) { + console.warn( + "[processVideo] onFrames callback failed", + callbackError + ) + } + } + return + } + case "PROGRESS": { + try { + params.onProgress?.(message.progress || 0) + } catch (callbackError) { + console.warn( + "[processVideo] onProgress callback failed", + callbackError + ) + } + return + } + case "DONE": { + finish(() => { + if (!frames.length) { + reject(new Error("video has no extracted frames")) + } else { + resolve(frames) + } + }) + return + } + case "FATAL": { + finish(() => + reject(new Error(message.error || "video process fatal")) + ) + return + } + case "ERROR": { + finish(() => + reject(new Error(message.error || "video process error")) + ) + return + } + default: + return + } + } + + worker.onerror = (event) => { + finish(() => reject(new Error(event.message || "video worker error"))) + } + + const loadMessage: WorkerMessage = { + type: "LOAD", + baseURL, + } + worker.postMessage(loadMessage) + }) + } finally { + worker.terminate() + } + } try { - return await new Promise((resolve, reject) => { - let settled = false + return await runOnce(args, timeoutMs, firstFrameTimeoutMs) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const isOom = + message.includes("memory access out of bounds") || + message.includes("Cannot enlarge memory") || + message.includes("Aborted") + const isTimeout = message.includes("video process timeout") + if (!isOom && !isTimeout) throw error - const finish = (next: () => void) => { - if (settled) return - settled = true - next() - } - - worker.onmessage = (event: MessageEvent) => { - const message = event.data - switch (message.type) { - case "READY": { - const runMessage: WorkerMessage = { - type: "EXEC", - file: inputFile, - args, - outputPattern: "frame_", - } - worker.postMessage(runMessage) - return - } - case "SEGMENT_DATA": { - const files = message.files || [] - files.forEach((file) => { - const ext = getFileExt(file.name) || outputExt || "jpg" - const frameName = `${prefix}_frame_${String(frameIndex).padStart( - 6, - "0" - )}.${ext}` - frameIndex += 1 - frames.push({ - name: frameName, - dataUrl: `data:${mimeByExt[ext] || "image/jpeg"};base64,${uint8ArrayToBase64(file.data)}`, - }) - }) - return - } - case "DONE": { - finish(() => { - if (!frames.length) { - reject(new Error("video has no extracted frames")) - } else { - resolve(frames) - } - }) - return - } - case "FATAL": { - finish(() => - reject(new Error(message.error || "video process fatal")) - ) - return - } - case "ERROR": { - finish(() => - reject(new Error(message.error || "video process error")) - ) - return - } - default: - return - } - } - - worker.onerror = (event) => { - finish(() => reject(new Error(event.message || "video worker error"))) - } - - const loadMessage: WorkerMessage = { - type: "LOAD", - baseURL, - } - worker.postMessage(loadMessage) - }) - } finally { - worker.terminate() + // 仅对可恢复错误做一次降级重试,避免无限重试拖垮页面。 + const retryArgs = isTimeout ? timeoutFallbackArgs : lowMemoryArgs + const retryTimeoutMs = isTimeout + ? Math.max(timeoutMs, 600_000) + : Math.max(timeoutMs, 300_000) + const retryFirstFrameTimeoutMs = Math.max(firstFrameTimeoutMs, 35_000) + try { + return await runOnce(retryArgs, retryTimeoutMs, retryFirstFrameTimeoutMs) + } catch (retryError) { + const retryMessage = + retryError instanceof Error ? retryError.message : String(retryError) + throw new Error( + `video process failed after ${isTimeout ? "timeout" : "low-memory"} retry: ${retryMessage}` + ) + } } } diff --git a/app/layout.tsx b/app/layout.tsx index 89ec239..a37d479 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -20,6 +20,8 @@ import "./globals.css" import { theme } from "./theme" import { OptStore } from "@/components/label/OptStore" +const GLOBAL_NOTICE_Z_INDEX = 4000 + export default function RootLayout({ children, }: { @@ -55,7 +57,7 @@ export default function RootLayout({ - + diff --git a/app/management/team/employee/page.tsx b/app/management/team/employee/page.tsx index 20ffe7e..0d4517d 100644 --- a/app/management/team/employee/page.tsx +++ b/app/management/team/employee/page.tsx @@ -87,12 +87,12 @@ export default function TeamEmployeePage() { // // ), // }, - // { - // accessor: "group_name", - // title: "组织", - // width: 180, - // textAlign: "center", - // }, + { + accessor: "group_name", + title: "组织", + width: 180, + textAlign: "center", + }, { accessor: "base_city", title: "所在地", diff --git a/components/label/LabelNossr.tsx b/components/label/LabelNossr.tsx index 41f0736..191981e 100644 --- a/components/label/LabelNossr.tsx +++ b/components/label/LabelNossr.tsx @@ -11,8 +11,8 @@ import { import ScaleComponent from "./components/ScaleComponent" import { processVideo } from "@/app/component/label/video2image/processVideo" -import { Box, Flex, LoadingOverlay, Stack } from "@mantine/core" -import { showNotification } from "@mantine/notifications" +import { Box, Button, Flex, LoadingOverlay, Stack } from "@mantine/core" +import { notifications, showNotification } from "@mantine/notifications" import { getLabelResult, getServerImage } from "./api/label" import { Comment, @@ -94,6 +94,37 @@ const getSegmentPoints = (arr: Array) => { } const videoExtPattern = /\.(mp4|mov|avi|mkv|webm|h264|264|ts|m4v)$/i +const GLOBAL_LOADING_Z_INDEX = 900 +const videoMimeByExt: Record = { + mp4: "video/mp4", + mov: "video/quicktime", + webm: "video/webm", + mkv: "video/x-matroska", + avi: "video/x-msvideo", + h264: "video/h264", + "264": "video/h264", + ts: "video/mp2t", + m4v: "video/x-m4v", +} +const streamingFrameArgs = [ + "-vf", + "fps=1,scale='min(640,iw)':-2:flags=bilinear,format=yuv420p", + "-q:v", + "5", + "frame_%03d.jpg", +] + +const getFileExt = (fileName: string) => { + const segments = fileName.split(".") + if (segments.length <= 1) return "" + return segments[segments.length - 1].toLowerCase() +} + +const normalizeBase64 = (text: string) => { + if (!text) return "" + const raw = text.includes(",") ? text.split(",").slice(1).join(",") : text + return raw.replace(/\s+/g, "") +} const normalizeNamePrefix = (name: string) => { const value = name @@ -103,6 +134,23 @@ const normalizeNamePrefix = (name: string) => { return value || "video" } +const isVideoTaskDetail = (detail: Task.DataProps) => { + const sourceNames = detail.data_name + .map((item) => item?.name?.[0] || "") + .filter((name) => !!name) + if (!sourceNames.length) return false + const taskDataType = Number( + (detail as { [key: string]: any })?.data_type ?? + (detail as { [key: string]: any })?.task_data_type ?? + -1 + ) + return ( + taskDataType === 2 || + taskDataType === 7 || + sourceNames.some((name) => videoExtPattern.test(name)) + ) +} + interface ResultImageEntry { frameName: string imageObjects: ImageObjects @@ -168,6 +216,12 @@ const LabelPage = ({ const [isConfirmSave, setIsConfirmSave] = useState(false) const [loading, setLoading] = useState(false) const [sizes, setSizes] = useState([]) + const [videoFetchPendingCount, setVideoFetchPendingCount] = useState(0) + const [hasReadyVideoFrame, setHasReadyVideoFrame] = useState(false) + const [sourceVideoNames, setSourceVideoNames] = useState([]) + const [downloadingVideoName, setDownloadingVideoName] = useState< + string | null + >(null) const labelData = useLabelStore((state) => state.label) const setLabel = useLabelStore((state) => state.setLabel) @@ -281,22 +335,60 @@ const LabelPage = ({ ]) const normalizeVideoTaskData = useCallback( - async (detail: Task.DataProps) => { + async (detail: Task.DataProps, requestSeq: number) => { + const isStaleRequest = () => requestSeq !== latestTaskDetailRequestSeq + + // 后端任务可能是视频(单条 data_name),这里统一展开为可渲染的“帧列表任务”。 const sourceNames = detail.data_name .map((item) => item?.name?.[0] || "") .filter((name) => !!name) if (!sourceNames.length || !projectId || !taskId) return detail - const taskDataType = Number( - (detail as { [key: string]: any })?.data_type ?? - (detail as { [key: string]: any })?.task_data_type ?? - -1 - ) - const isVideoTask = - taskDataType === 2 || - taskDataType === 7 || - sourceNames.some((name) => videoExtPattern.test(name)) - if (!isVideoTask) return detail + const isVideoTask = isVideoTaskDetail(detail) + if (!isVideoTask) { + setSourceVideoNames([]) + setHasReadyVideoFrame(true) + return detail + } + setSourceVideoNames(sourceNames) + + const appendFramesToPage = (frameNames: string[]) => { + if (isStaleRequest() || !frameNames.length) return + + const bottomStore = useBottomToolsStore.getState() + const nextAllItems = [...bottomStore.allItems] + const existedItems = new Set(nextAllItems) + const appendNames: string[] = [] + + frameNames.forEach((name) => { + if (!name || existedItems.has(name)) return + existedItems.add(name) + nextAllItems.push(name) + appendNames.push(name) + }) + if (!appendNames.length) return + + bottomStore.setAllItems(nextAllItems) + setTaskDetail((prev) => { + const baseDetail = prev ?? { ...detail, data_name: [] } + const existedFrameNames = new Set( + baseDetail.data_name.map((item) => item?.name?.[0] || "") + ) + const additions = appendNames + .filter((name) => !existedFrameNames.has(name)) + .map((name) => ({ + name: [name] as [string], + related_images: [] as [], + })) + if (!additions.length) { + return baseDetail === prev ? prev : baseDetail + } + return { + ...baseDetail, + data_name: [...baseDetail.data_name, ...additions], + } + }) + } const imagesMap = new Map(useImagesStore.getState().images) const frameMapUpdates = new Map() @@ -304,6 +396,7 @@ const LabelPage = ({ const normalizedDataName: Task.DataProps["data_name"] = [] for (const videoName of sourceNames) { + // 任务级缓存键:相同 project/task/video 可复用历史抽帧结果,避免重复解码。 const cacheKey = `${projectId}:${taskId}:${videoName}` let frameNames = useVideoFrameStore.getState().videoFrames.get(cacheKey) ?? [] @@ -317,32 +410,201 @@ const LabelPage = ({ if (!hasAllCachedFrames) { let frameData: Awaited> = [] + const noticeId = `video-process-${cacheKey}` + const fetchStartedAt = Date.now() + let decodeStartedAt: number | null = null + let fetchNoticeTicker: ReturnType | null = null + let progress = 0 + let firstFrameDelaySec: string | null = null + const streamFrameNames: string[] = [] + const streamFrameNameSet = new Set() + let lastNoticeUpdateAt = 0 + let noticeTicker: ReturnType | null = null + const getFetchSec = (anchorTs?: number) => { + const endTs = decodeStartedAt ?? anchorTs ?? Date.now() + return ((endTs - fetchStartedAt) / 1000).toFixed(1) + } + + const updateProgressNotice = (force = false) => { + if (isStaleRequest()) return + const now = Date.now() + if (!force && now - lastNoticeUpdateAt < 500) return + lastNoticeUpdateAt = now + const decodeElapsedSec = decodeStartedAt + ? ((now - decodeStartedAt) / 1000).toFixed(1) + : "0.0" + const fetchSec = getFetchSec(now) + const progressPct = Math.max(0, Math.min(100, progress * 100)) + const progressText = + progressPct < 1 + ? `${progressPct.toFixed(1)}%` + : `${Math.round(progressPct)}%` + const firstFrameText = firstFrameDelaySec + ? `,首帧解码 ${firstFrameDelaySec}s` + : decodeStartedAt && now - decodeStartedAt >= 8000 + ? ",首帧生成中" + : "" + notifications.update({ + id: noticeId, + loading: true, + autoClose: false, + withCloseButton: false, + title: "视频抽帧中", + message: `${videoName} ${progressText},已生成 ${streamFrameNames.length} 帧,解码用时 ${decodeElapsedSec}s,拉取 ${fetchSec}s${firstFrameText}`, + }) + } + + const applyFrameBatch = ( + batchFrames: Awaited> + ) => { + if (isStaleRequest() || !batchFrames.length) return + const appendNames: string[] = [] + batchFrames.forEach((frame) => { + if (!frame?.name || streamFrameNameSet.has(frame.name)) return + streamFrameNameSet.add(frame.name) + streamFrameNames.push(frame.name) + appendNames.push(frame.name) + imagesMap.set(frame.name, frame.dataUrl) + }) + if (!appendNames.length) return + + if (!firstFrameDelaySec) { + const anchor = decodeStartedAt ?? Date.now() + const firstDelayMs = Date.now() - anchor + firstFrameDelaySec = (firstDelayMs / 1000).toFixed(1) + progress = Math.max(progress, 0.01) + setHasReadyVideoFrame(true) + } + imageMapUpdated = true + useImagesStore.getState().setImages(new Map(imagesMap)) + useVideoFrameStore + .getState() + .setVideoFrames(cacheKey, [...streamFrameNames]) + appendFramesToPage(appendNames) + updateProgressNotice(true) + } + + // 用计数器驱动全局 LoadingOverlay,仅在首帧未到达前阻塞页面。 + setVideoFetchPendingCount((prev) => prev + 1) + notifications.show({ + id: noticeId, + loading: true, + autoClose: false, + withCloseButton: false, + title: "视频加载中", + message: `正在获取 ${videoName},拉取用时 0.0s`, + }) + fetchNoticeTicker = setInterval(() => { + if (isStaleRequest()) return + notifications.update({ + id: noticeId, + loading: true, + autoClose: false, + withCloseButton: false, + title: "视频加载中", + message: `正在获取 ${videoName},拉取用时 ${getFetchSec()}s`, + }) + }, 1000) try { const base64Video = await getServerImage({ data_names: [videoName], data_type: 2, project_id: projectId, }) + if (isStaleRequest()) return detail + decodeStartedAt = Date.now() + const fetchSecAtDecode = getFetchSec(decodeStartedAt) + if (fetchNoticeTicker) { + clearInterval(fetchNoticeTicker) + fetchNoticeTicker = null + } + + notifications.update({ + id: noticeId, + loading: true, + autoClose: false, + withCloseButton: false, + title: "视频抽帧中", + message: `正在快速提取并持续抽帧 ${videoName},拉取 ${fetchSecAtDecode}s`, + }) + noticeTicker = setInterval(() => { + updateProgressNotice(true) + }, 1000) + frameData = await processVideo({ base64Data: String(base64Video || ""), fileName: videoName, + // 输出帧名带 project/task 前缀,降低跨任务重名覆盖风险。 namePrefix: normalizeNamePrefix( `${projectId}_${taskId}_${videoName.replace(/\.[^.]+$/, "")}` ), + args: streamingFrameArgs, + quickFirstFrame: true, + continueAfterQuickFirstFrame: true, + onProgress: (value) => { + if (isStaleRequest()) return + progress = Math.max(0, Math.min(1, Number(value) || 0)) + updateProgressNotice(false) + }, + onFrames: (batchFrames) => { + applyFrameBatch(batchFrames) + }, + }) + if (isStaleRequest()) return detail + + const totalSec = ((Date.now() - fetchStartedAt) / 1000).toFixed(1) + const fetchSecFinal = decodeStartedAt + ? ((decodeStartedAt - fetchStartedAt) / 1000).toFixed(1) + : "未统计" + notifications.update({ + id: noticeId, + loading: false, + color: "teal", + title: "视频处理完成", + message: `${videoName} 已生成 ${frameData.length} 帧(首帧解码 ${firstFrameDelaySec || "未统计"}s,总耗时 ${totalSec}s,拉取 ${fetchSecFinal}s)`, + autoClose: 2600, + withCloseButton: true, }) } catch (error) { const message = error instanceof Error ? error.message : "未知错误" + const fetchSec = getFetchSec() + notifications.update({ + id: noticeId, + loading: false, + color: "red", + title: "视频处理失败", + message: `${videoName}(${message},拉取 ${fetchSec}s)`, + autoClose: 5000, + withCloseButton: true, + }) throw new Error(`视频帧生成失败:${videoName}(${message})`) + } finally { + if (fetchNoticeTicker) { + clearInterval(fetchNoticeTicker) + fetchNoticeTicker = null + } + if (noticeTicker) { + clearInterval(noticeTicker) + noticeTicker = null + } + setVideoFetchPendingCount((prev) => Math.max(prev - 1, 0)) } frameNames = frameData.map((frame) => frame.name) if (!frameNames.length) { throw new Error(`视频帧生成失败:${videoName}`) } frameData.forEach((frame) => { - imagesMap.set(frame.name, frame.dataUrl) + if (!imagesMap.has(frame.name)) { + imagesMap.set(frame.name, frame.dataUrl) + } }) imageMapUpdated = true frameMapUpdates.set(cacheKey, frameNames) + appendFramesToPage(frameNames) + } else { + // 已命中缓存时也立即解锁页面并展示帧列表。 + setHasReadyVideoFrame(true) + appendFramesToPage(frameNames) } if (!frameNames.length) { @@ -350,6 +612,7 @@ const LabelPage = ({ } frameNames.forEach((frameName) => { + // 将视频帧映射回 Task.DataProps 结构,复用现有图片标注渲染逻辑。 normalizedDataName.push({ name: [frameName] as [string], related_images: [], @@ -362,11 +625,13 @@ const LabelPage = ({ } if (imageMapUpdated) { + // 批量写入,避免每帧 setState 触发频繁重渲染。 useImagesStore.getState().setImages(imagesMap) } frameMapUpdates.forEach((frameNames, cacheKey) => { useVideoFrameStore.getState().setVideoFrames(cacheKey, frameNames) }) + setHasReadyVideoFrame(true) return { ...detail, @@ -376,11 +641,112 @@ const LabelPage = ({ [projectId, taskId] ) + const downloadVideoToLocal = useCallback( + (base64Video: string, name: string) => { + // 调试入口:把服务端 base64 视频还原成本地文件,便于和抽帧结果对比。 + const payload = normalizeBase64(String(base64Video || "")) + if (!payload) throw new Error("视频数据为空") + const binary = atob(payload) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i) + } + const ext = getFileExt(name) + const blob = new Blob([bytes], { + type: videoMimeByExt[ext] || "video/mp4", + }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = name + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(url) + }, + [] + ) + + const handleDownloadSourceVideo = useCallback(async () => { + const videoName = sourceVideoNames[0] + if (!videoName || !projectId) return + const noticeId = `video-download-${projectId}-${taskId || 0}-${videoName}` + const fetchStartedAt = Date.now() + let fetchNoticeTicker: ReturnType | null = null + setDownloadingVideoName(videoName) + setVideoFetchPendingCount((prev) => prev + 1) + notifications.show({ + id: noticeId, + loading: true, + autoClose: false, + withCloseButton: false, + title: "准备下载视频", + message: `正在获取 ${videoName},拉取用时 0.0s`, + }) + fetchNoticeTicker = setInterval(() => { + const fetchSec = ((Date.now() - fetchStartedAt) / 1000).toFixed(1) + notifications.update({ + id: noticeId, + loading: true, + autoClose: false, + withCloseButton: false, + title: "准备下载视频", + message: `正在获取 ${videoName},拉取用时 ${fetchSec}s`, + }) + }, 1000) + try { + const base64Video = await getServerImage({ + data_names: [videoName], + data_type: 2, + project_id: projectId, + }) + if (fetchNoticeTicker) { + clearInterval(fetchNoticeTicker) + fetchNoticeTicker = null + } + const fetchSec = ((Date.now() - fetchStartedAt) / 1000).toFixed(1) + downloadVideoToLocal(String(base64Video || ""), videoName) + notifications.update({ + id: noticeId, + loading: false, + color: "teal", + title: "下载已开始", + message: + sourceVideoNames.length > 1 + ? `${videoName}(当前任务共 ${sourceVideoNames.length} 个视频,拉取 ${fetchSec}s)` + : `${videoName}(拉取 ${fetchSec}s)`, + autoClose: 2500, + withCloseButton: true, + }) + } catch (error) { + const message = error instanceof Error ? error.message : "未知错误" + const fetchSec = ((Date.now() - fetchStartedAt) / 1000).toFixed(1) + notifications.update({ + id: noticeId, + loading: false, + color: "red", + title: "下载失败", + message: `${videoName}(${message},拉取 ${fetchSec}s)`, + autoClose: 5000, + withCloseButton: true, + }) + } finally { + if (fetchNoticeTicker) { + clearInterval(fetchNoticeTicker) + fetchNoticeTicker = null + } + setDownloadingVideoName(null) + setVideoFetchPendingCount((prev) => Math.max(prev - 1, 0)) + } + }, [downloadVideoToLocal, projectId, sourceVideoNames, taskId]) + const asyncGetTaskDetail = useCallback(async () => { if (taskId) { const requestSeq = ++latestTaskDetailRequestSeq try { usePaperStore.getState().setLoadingData(true) + setHasReadyVideoFrame(false) + setSourceVideoNames([]) // 获取当前任务全量数据 const res = await getTaskList({ id: [taskId], @@ -397,7 +763,17 @@ const LabelPage = ({ return } let detail = res.task_list[0] - detail = await normalizeVideoTaskData(detail) + if (isVideoTaskDetail(detail)) { + // 视频任务先清空帧列表占位,首帧到达后再逐步追加并可立即开始操作。 + setTaskDetail({ + ...detail, + data_name: [], + }) + useBottomToolsStore.getState().setAllItems([]) + } else { + setHasReadyVideoFrame(true) + } + detail = await normalizeVideoTaskData(detail, requestSeq) if (requestSeq !== latestTaskDetailRequestSeq) { console.warn("[LabelNossr] ignore stale normalized task response", { taskId, @@ -765,6 +1141,7 @@ const LabelPage = ({ } catch (err) { if (requestSeq !== latestTaskDetailRequestSeq) return console.log(err) + setSourceVideoNames([]) setOldWorkLoad(null) setTaskDetail(undefined) useBottomToolsStore.getState().setAllItems([]) @@ -1376,11 +1753,12 @@ const LabelPage = ({ return ( 0 && !hasReadyVideoFrame)} + zIndex={GLOBAL_LOADING_Z_INDEX} overlayProps={{ radius: "sm", blur: 2 }} /> - + + {sourceVideoNames.length > 0 && ( + + + + )} {/* */} diff --git a/components/label/api/label/index.ts b/components/label/api/label/index.ts index 706660c..d328016 100644 --- a/components/label/api/label/index.ts +++ b/components/label/api/label/index.ts @@ -37,32 +37,42 @@ export const getServerImage = async (data: { flag?: number project_id: number }) => { - let data_type = data.data_type - return new Promise((resolve, reject) => { - httpFetch({ - url: BASE_LABEL_API + "/api/v1/label_server/data/list", - method: "POST", - body: JSON.stringify(data), - }).then((res) => { - if (res.proxy) { - reject({ proxy: res.proxy, params: data }) - } else { - if (res.data_list && res.data_list.length) { - const { image_data, video_data, pointcloud_data } = res.data_list[0] - resolve({ - base64data: - data_type === 0 - ? image_data - : data_type === 2 - ? video_data - : pointcloud_data, - }) - } - } - }) - }).then(({ base64data }: any) => { - return base64data + const res = await httpFetch({ + url: BASE_LABEL_API + "/api/v1/label_server/data/list", + method: "POST", + body: JSON.stringify(data), }) + + if (res.proxy) { + throw new Error(`get_data_list requires proxy=${res.proxy}`) + } + + const first = res.data_list?.[0] + if (!first) { + throw new Error( + `get_data_list empty data_list, data_name=${data.data_names?.[0] || ""}` + ) + } + + const payload = + data.data_type === 0 + ? first.image_data + : data.data_type === 2 + ? first.video_data + : first.pointcloud_data + + if (payload == null) { + throw new Error( + `get_data_list missing payload type=${data.data_type}, data_name=${first.data_name || data.data_names?.[0] || ""}` + ) + } + if (typeof payload === "string" && !payload.trim()) { + throw new Error( + `get_data_list empty payload type=${data.data_type}, data_name=${first.data_name || data.data_names?.[0] || ""}` + ) + } + + return payload // .catch(async ({ proxy, params }) => { // try { // const res = await httpFetch({ diff --git a/components/label/components/BottomTools.tsx b/components/label/components/BottomTools.tsx index 682cdf2..35b382d 100644 --- a/components/label/components/BottomTools.tsx +++ b/components/label/components/BottomTools.tsx @@ -99,11 +99,34 @@ const BottomTools: React.FC = (props) => { }, [activeImage, isShowKeyFrame, keyFrameData, labelState]) useEffect(() => { - const [firstKey] = labelState.keys() + const keys = Array.from(labelState.keys()) + if (!keys.length) { + setActiveImage("") + setInputNumber("") + setActiveImageId(0) + return + } + + const currentIndex = activeImage + ? keys.findIndex((k) => k === activeImage) + : -1 + if (currentIndex >= 0) { + setInputNumber(String(currentIndex + 1)) + setActiveImageId(currentIndex + 1) + return + } + + const firstKey = keys[0] setActiveImage(firstKey) setInputNumber("1") setActiveImageId(1) - }, [labelState, setActiveImage, setActiveImageId, setInputNumber]) + }, [ + activeImage, + labelState, + setActiveImage, + setActiveImageId, + setInputNumber, + ]) const scrollToIndex = (index: number) => { const key = currentKeys[index] diff --git a/components/label/components/PaperContainer.tsx b/components/label/components/PaperContainer.tsx index 6ee87be..3efc9ab 100644 --- a/components/label/components/PaperContainer.tsx +++ b/components/label/components/PaperContainer.tsx @@ -1472,7 +1472,8 @@ const PaperContainer = ( ) useEffect(() => { - if (loadingData) return + const hasCachedImage = !!useImagesStore.getState().images.get(activeImage) + if (loadingData && !hasCachedImage) return if (!imgSize?.clientWidth || !imgSize?.clientHeight) return const currentGroup = usePaperStore.getState().group if (currentGroup && projectDetail) { diff --git a/components/label/components/TopTools.tsx b/components/label/components/TopTools.tsx index 630c02e..651bd16 100644 --- a/components/label/components/TopTools.tsx +++ b/components/label/components/TopTools.tsx @@ -157,7 +157,7 @@ const TopTools = ( renderPolygons, } = props const { backUrl } = useBackUrlStore() - const { mode } = usePaperStore() + const { mode, loadingData } = usePaperStore() const router = useRouter() const user_id = usePermissionStore.getState().user_id @@ -418,6 +418,10 @@ const TopTools = ( ) const handleSave = useCallback(async () => { + if (loadingData) { + return + } + if (!oldWorkLoad) { notifications.hide("save") notifications.show({ @@ -869,6 +873,7 @@ const TopTools = ( transferPath, updateOldWorkLoad, user_id, + loadingData, ]) const handleBackup = useCallback( @@ -1029,6 +1034,13 @@ const TopTools = ( // 任务跳转 const getNextTaskData = async (needSave = false) => { + if (loadingData) { + notifications.show({ + color: "yellow", + message: "视频帧仍在同步,请稍后再切换任务", + }) + return + } if (needSave) { notifications.show({ id: "save", @@ -1066,6 +1078,13 @@ const TopTools = ( } const commitTaskByActionKey = async (key?: number) => { + if (loadingData) { + notifications.show({ + color: "yellow", + message: "视频帧仍在同步,请稍后再提交", + }) + return + } if (!key) { // 提交时对数据进行校验 let imgBool = taskDetail!.data_name.some((item) => { @@ -2079,8 +2098,16 @@ const TopTools = ( + style={{ + width: "auto", + display: "flex", + gap: 4, + opacity: loadingData ? 0.5 : 1, + cursor: loadingData ? "not-allowed" : "pointer", + }} + onClick={() => { + if (!loadingData) handleSave() + }}> 保存 diff --git a/components/layout/AppLayout.tsx b/components/layout/AppLayout.tsx index 895c7ac..6b07776 100644 --- a/components/layout/AppLayout.tsx +++ b/components/layout/AppLayout.tsx @@ -8,6 +8,7 @@ import { Flex, Menu, NavLink, + ScrollArea, Space, Stack, Tooltip, @@ -227,101 +228,103 @@ export default function AppLayout({ - {activeHeaderMenu?.items?.map((item) => { - if (item.items?.length) { - if (matches) { - if (isOpen) { - return ( - - ) + + {activeHeaderMenu?.items?.map((item) => { + if (item.items?.length) { + if (matches) { + if (isOpen) { + return ( + + ) + } else { + return ( + + + + ) + } + label={item.title} + /> + + + {item.items?.map((sub) => + renderMenuItem( + sub, + `${activeHeaderMenu.url}/${item.url}` + ) + )} + + + ) + } } else { return ( - - - - ) - } - label={item.title} - /> - - - {item.items?.map((sub) => - renderMenuItem( - sub, - `${activeHeaderMenu.url}/${item.url}` - ) - )} - - + + + ) } } else { return ( - - + { + toggleMobile() + router.push(`/${activeHeaderMenu.url}/${item.url}`) + }} + noWrap={true} + active={pathname.startsWith( + `/${activeHeaderMenu.url}/${item.url}` + )} + leftSection={ + + } + label={item.title} /> - + ) } - } else { - return ( - - { - toggleMobile() - router.push(`/${activeHeaderMenu.url}/${item.url}`) - }} - noWrap={true} - active={pathname.startsWith( - `/${activeHeaderMenu.url}/${item.url}` - )} - leftSection={ - - } - label={item.title} - /> - - ) - } - })} + })} +