perf(video): perf
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<WorkerMessage>) => {
|
||||
try {
|
||||
switch (type) {
|
||||
case "LOAD":
|
||||
// Worker 生命周期内只加载一次 wasm,后续任务复用。
|
||||
if (loaded) {
|
||||
ctx.postMessage({ type: "READY" })
|
||||
return
|
||||
@@ -53,7 +61,13 @@ ctx.onmessage = async (event: MessageEvent<WorkerMessage>) => {
|
||||
|
||||
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<WorkerMessage>) => {
|
||||
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<WorkerMessage>) => {
|
||||
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<WorkerMessage>) => {
|
||||
}
|
||||
|
||||
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,8 +262,174 @@ ctx.onmessage = async (event: MessageEvent<WorkerMessage>) => {
|
||||
return Number.isFinite(fps) && fps > 0 ? fps : null
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
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}`,
|
||||
})
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -197,9 +454,7 @@ ctx.onmessage = async (event: MessageEvent<WorkerMessage>) => {
|
||||
message: `[probe] ffprobe failed: ${normalizeError(e)}`,
|
||||
})
|
||||
} finally {
|
||||
await ffmpeg.deleteFile(durationProbePath).catch((err) => {
|
||||
console.log("deleteFile error", err)
|
||||
})
|
||||
await deleteFileQuietly(durationProbePath)
|
||||
}
|
||||
|
||||
if (!durationSec) {
|
||||
@@ -223,6 +478,13 @@ ctx.onmessage = async (event: MessageEvent<WorkerMessage>) => {
|
||||
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({
|
||||
type: "LOG",
|
||||
@@ -232,35 +494,43 @@ ctx.onmessage = async (event: MessageEvent<WorkerMessage>) => {
|
||||
|
||||
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 out: string[] = []
|
||||
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, "")
|
||||
@@ -270,20 +540,26 @@ ctx.onmessage = async (event: MessageEvent<WorkerMessage>) => {
|
||||
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)`)
|
||||
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
|
||||
})()
|
||||
|
||||
const out: string[] = []
|
||||
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<WorkerMessage>) => {
|
||||
}
|
||||
|
||||
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<WorkerMessage>) => {
|
||||
"-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<WorkerMessage>) => {
|
||||
|
||||
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<WorkerMessage>) => {
|
||||
progress: Math.min((t + segmentDurationSec) / durationSec, 1),
|
||||
})
|
||||
|
||||
if (frameLimit > 0 && producedFrames >= frameLimit) break
|
||||
t += segmentDurationSec
|
||||
}
|
||||
|
||||
@@ -380,14 +679,19 @@ ctx.onmessage = async (event: MessageEvent<WorkerMessage>) => {
|
||||
}
|
||||
}
|
||||
|
||||
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<WorkerMessage>) => {
|
||||
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<WorkerMessage>) => {
|
||||
"-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<WorkerMessage>) => {
|
||||
)}`,
|
||||
})
|
||||
}
|
||||
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<WorkerMessage>) => {
|
||||
|
||||
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<WorkerMessage>) => {
|
||||
}
|
||||
}
|
||||
if (!segmentOk) {
|
||||
metrics.fallbackToSingle = segmentCandidates.length > 0
|
||||
ctx.postMessage({
|
||||
type: "LOG",
|
||||
message: `[mode] segment mode unavailable; fallback to single-frame sampling`,
|
||||
})
|
||||
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<WorkerMessage>) => {
|
||||
|
||||
throw e
|
||||
} finally {
|
||||
// Cleanup input
|
||||
// 始终清理挂载目录,避免残留状态影响下一次任务。
|
||||
try {
|
||||
await unmountInput()
|
||||
} catch (e) {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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,50 +134,139 @@ 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 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<VideoFrameFile[]>((resolve, reject) => {
|
||||
let settled = false
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let firstFrameTimeoutId: ReturnType<typeof setTimeout> | 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<WorkerResponse>) => {
|
||||
const message = event.data
|
||||
switch (message.type) {
|
||||
case "READY": {
|
||||
// Worker 加载完 wasm 后才允许真正执行抽帧命令。
|
||||
const runMessage: WorkerMessage = {
|
||||
type: "EXEC",
|
||||
file: inputFile,
|
||||
args,
|
||||
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 frameName = `${prefix}_frame_${String(frameIndex).padStart(
|
||||
6,
|
||||
"0"
|
||||
)}.${ext}`
|
||||
const frameNo = String(frameIndex).padStart(6, "0")
|
||||
const frameName = `${prefix}_frame_${frameNo}.${ext}`
|
||||
frameIndex += 1
|
||||
frames.push({
|
||||
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": {
|
||||
@@ -196,3 +310,32 @@ export const processVideo = async (
|
||||
worker.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
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 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}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</head>
|
||||
<body>
|
||||
<MantineProvider defaultColorScheme="auto" theme={theme}>
|
||||
<Notifications />
|
||||
<Notifications zIndex={GLOBAL_NOTICE_Z_INDEX} withinPortal />
|
||||
<InfoCheck />
|
||||
<OptStore />
|
||||
<ModalsProvider>
|
||||
|
||||
@@ -87,12 +87,12 @@ export default function TeamEmployeePage() {
|
||||
// </Badge>
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// accessor: "group_name",
|
||||
// title: "组织",
|
||||
// width: 180,
|
||||
// textAlign: "center",
|
||||
// },
|
||||
{
|
||||
accessor: "group_name",
|
||||
title: "组织",
|
||||
width: 180,
|
||||
textAlign: "center",
|
||||
},
|
||||
{
|
||||
accessor: "base_city",
|
||||
title: "所在地",
|
||||
|
||||
@@ -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<number[]>) => {
|
||||
}
|
||||
|
||||
const videoExtPattern = /\.(mp4|mov|avi|mkv|webm|h264|264|ts|m4v)$/i
|
||||
const GLOBAL_LOADING_Z_INDEX = 900
|
||||
const videoMimeByExt: Record<string, string> = {
|
||||
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<number[]>([])
|
||||
const [videoFetchPendingCount, setVideoFetchPendingCount] = useState(0)
|
||||
const [hasReadyVideoFrame, setHasReadyVideoFrame] = useState(false)
|
||||
const [sourceVideoNames, setSourceVideoNames] = useState<string[]>([])
|
||||
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 = 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 isVideoTask =
|
||||
taskDataType === 2 ||
|
||||
taskDataType === 7 ||
|
||||
sourceNames.some((name) => videoExtPattern.test(name))
|
||||
if (!isVideoTask) return detail
|
||||
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<string, string[]>()
|
||||
@@ -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<ReturnType<typeof processVideo>> = []
|
||||
const noticeId = `video-process-${cacheKey}`
|
||||
const fetchStartedAt = Date.now()
|
||||
let decodeStartedAt: number | null = null
|
||||
let fetchNoticeTicker: ReturnType<typeof setInterval> | null = null
|
||||
let progress = 0
|
||||
let firstFrameDelaySec: string | null = null
|
||||
const streamFrameNames: string[] = []
|
||||
const streamFrameNameSet = new Set<string>()
|
||||
let lastNoticeUpdateAt = 0
|
||||
let noticeTicker: ReturnType<typeof setInterval> | 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<ReturnType<typeof processVideo>>
|
||||
) => {
|
||||
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) => {
|
||||
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<typeof setInterval> | 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 (
|
||||
<Stack w="100%" h="100vh" pos="relative" ref={fullscreenRef} gap={0}>
|
||||
<LoadingOverlay
|
||||
visible={loading}
|
||||
zIndex={2000}
|
||||
// 视频场景仅阻塞到首帧可见;后续抽帧在后台继续,页面保持可操作。
|
||||
visible={loading || (videoFetchPendingCount > 0 && !hasReadyVideoFrame)}
|
||||
zIndex={GLOBAL_LOADING_Z_INDEX}
|
||||
overlayProps={{ radius: "sm", blur: 2 }}
|
||||
/>
|
||||
<Box w="100%" h={80}>
|
||||
<Box w="100%" h={80} pos="relative">
|
||||
<TopTools
|
||||
ref={topRef}
|
||||
taskDetail={taskDetail}
|
||||
@@ -1394,6 +1772,25 @@ const LabelPage = ({
|
||||
: null
|
||||
}
|
||||
/>
|
||||
{sourceVideoNames.length > 0 && (
|
||||
<Flex
|
||||
pos="absolute"
|
||||
top={8}
|
||||
right={12}
|
||||
style={{ zIndex: 2101 }}
|
||||
gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
// 当前仅下载第一个视频,便于快速核对源视频与抽帧质量。
|
||||
loading={downloadingVideoName === sourceVideoNames[0]}
|
||||
onClick={handleDownloadSourceVideo}>
|
||||
{sourceVideoNames.length > 1
|
||||
? `下载源视频(1/${sourceVideoNames.length})`
|
||||
: "下载源视频"}
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
<Flex w="calc(100% - 4px)" h={mainBoxHeight}>
|
||||
{/* <Flex h="100%" w={"100%"}> */}
|
||||
|
||||
@@ -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<ServerResponse>({
|
||||
const res = await httpFetch<ServerResponse>({
|
||||
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,
|
||||
})
|
||||
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] || ""}`
|
||||
)
|
||||
}
|
||||
})
|
||||
}).then(({ base64data }: any) => {
|
||||
return base64data
|
||||
})
|
||||
|
||||
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<ServerResponse>({
|
||||
|
||||
@@ -99,11 +99,34 @@ const BottomTools: React.FC<BottomToolsComponentProps> = (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]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = (
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
c="var(--mantine-color-text)"
|
||||
style={{ width: "auto", display: "flex", gap: 4 }}
|
||||
onClick={handleSave}>
|
||||
style={{
|
||||
width: "auto",
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
opacity: loadingData ? 0.5 : 1,
|
||||
cursor: loadingData ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!loadingData) handleSave()
|
||||
}}>
|
||||
<Cloud style={{ width: 20, height: 20 }} />
|
||||
<Text size="xs">保存</Text>
|
||||
</ActionIcon>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Flex,
|
||||
Menu,
|
||||
NavLink,
|
||||
ScrollArea,
|
||||
Space,
|
||||
Stack,
|
||||
Tooltip,
|
||||
@@ -227,6 +228,7 @@ export default function AppLayout({
|
||||
</AppShell.Header>
|
||||
<AppShell.Navbar style={{ transition: "width 0.2s ease" }}>
|
||||
<Stack justify="start" gap={4} flex={1}>
|
||||
<ScrollArea h="calc(100vh - 95px)">
|
||||
{activeHeaderMenu?.items?.map((item) => {
|
||||
if (item.items?.length) {
|
||||
if (matches) {
|
||||
@@ -322,6 +324,7 @@ export default function AppLayout({
|
||||
)
|
||||
}
|
||||
})}
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
<Stack justify="center" gap={0}>
|
||||
<Flex
|
||||
|
||||
Reference in New Issue
Block a user