perf(video): perf
This commit is contained in:
@@ -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 =
|
||||
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<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) => {
|
||||
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<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%"}> */}
|
||||
|
||||
Reference in New Issue
Block a user