"use client" import { useCallback, useEffect, useMemo, useReducer, useRef, useState, } from "react" import ScaleComponent from "./components/ScaleComponent" import { processVideo } from "@/app/component/label/video2image/processVideo" import { Box, Button, Flex, LoadingOverlay, Stack } from "@mantine/core" import { notifications, showNotification } from "@mantine/notifications" import { getLabelResult, getServerImage } from "./api/label" import { Comment, ImageObjects, LabelResult, WorkLoad, } from "./api/label/typing" import { getProjectDetail } from "./api/project" import { Project } from "./api/project/typing" import { getTaskList, getTaskWorkFlow } from "./api/task" import { Task } from "./api/task/typing" import BottomTools from "./components/BottomTools" import ConfirmModal from "./components/ConfirmModal" import { splitWord } from "./components/EditorContainer" import PaperContainer from "./components/PaperContainer" import RightDescTools from "./components/RightDescTools" import RightGroupTools from "./components/RightGroupTools" import RightObjectTools from "./components/RightObjectTools" import RightQATools from "./components/RightQATools" import RightTaskTools from "./components/RightTaskTools" import ScaleToolContainer from "./components/ScaleToolContainer" import TopTools from "./components/TopTools" import { useImagesStore, useKeyEventStore, useLabelStore, useObjectStore, useVideoFrameStore, } from "./store" import { usePermissionStore } from "./store/auth" import { useBottomToolsStore } from "./useBottomToolsStore" import { useDescToolsStore } from "./useDescToolsStore" import { useKeyboardStore } from "./useKeyBoardStore" import { usePaperStore } from "./usePaperStore" import { useRightToolsStore } from "./useRightToolsStore" import { useIntervalStore, useLabelTimeStore, useTimerStore, } from "./useTimerStore" import { useTopToolsStore } from "./useTopToolsStore" import { findGroupKey } from "./util" import { safeClone } from "./utils/clone" import { labelTypeMap } from "./utils/constants" // Splitter component - will need custom implementation or alternative // 定义状态的类型 export type LabelState = Map // 定义操作的类型 type Action = | { type: "INIT_IMAGES" imageIds: string[] objectOperations: Project.LabelSchemaList[] } | { type: "REMOVE_IMAGE"; imageId: string } // 定义初始状态 const initialState: LabelState = new Map() const operationTypeList = [101, 102, 103, 104] let latestProjectDetailRequestSeq = 0 let latestTaskDetailRequestSeq = 0 // utils const getRectPoints = ([sx, sy, w, h]: number[]) => { return [ [sx, sy + h], [sx, sy], [sx + w, sy], [sx + w, sy + h], ] } const getSegmentPoints = (arr: Array) => { return arr.map((item) => [item[0], item[1]]) } 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 .replace(/[^a-zA-Z0-9_-]+/g, "_") .replace(/_+/g, "_") .replace(/^_|_$/g, "") 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 } const expandLabelResultImages = (item: LabelResult): ResultImageEntry[] => { if (item.image_objects) { return [ { frameName: item.data_name, imageObjects: item.image_objects, }, ] } if (!item.video_objects) return [] return Object.entries(item.video_objects).map(([frameName, imageObjects]) => { return { frameName, imageObjects, } }) } interface LabelProps { headerHeight: number leftWidth: number project_id: number task_id: number } const LabelPage = ({ headerHeight = 0, leftWidth = 0, project_id = -1, task_id = -1, }: LabelProps) => { const projectId = useMemo(() => { return project_id }, [project_id]) const taskId = useMemo(() => { return task_id }, [task_id]) // const searchParams = useSearchParams() // const id1 = searchParams.get("project_id") // const projectId = // typeof id1 === "string" && !isNaN(Number(id1)) ? Number(id1) : 1 // const id2 = searchParams.get("task_id") // const taskId = // typeof id2 === "string" && !isNaN(Number(id2)) ? Number(id2) : 2 const user_id = usePermissionStore.getState().user_id const [projectDetail, setProjectDetail] = useState() const [taskDetail, setTaskDetail] = useState() const [oldWorkLoad, setOldWorkLoad] = useState(null) // const [objectOperations, setObjectOperations] = useState< // Project.LabelSchemaList[] // >([]); const topRef = useRef(null) const autoSaveTimerRef = useRef | null>(null) const autoSaveInProgressRef = useRef(false) const paperContainerRef = useRef(null) const qaToolContainerRef = useRef(null) 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) const setStateStack = useLabelStore((state) => state.setStateStack) const setLabelTime = useLabelTimeStore((state) => state.setLabelTime) const isAutoSave = useIntervalStore((state) => state.isAutoSave) const autoSaveGap = useIntervalStore((state) => state.autoSaveGap) const { editMode, setEditMode, setPressA, activeOperation, setActiveOperation, showObjectList, showGroupList, showTaskList, showDescList, showMultiFrame, setShowMultiFrame, isView, setIsView, scale, setScale, objectOperations, setObjectOperations, } = useTopToolsStore() const { activeImage } = useBottomToolsStore() const { setDescOperations, metaOperation, setMetaOperation, setQaOperations, setQaData, resetData, } = useDescToolsStore() const asyncGetProjectDetail = useCallback(async () => { if (projectId) { const requestSeq = ++latestProjectDetailRequestSeq try { const res = await getProjectDetail(projectId) if (requestSeq !== latestProjectDetailRequestSeq) { console.warn("[LabelNossr] ignore stale project detail response", { projectId, requestSeq, latestRequestSeq: latestProjectDetailRequestSeq, }) return } setProjectDetail(res) let operations: Project.LabelSchemaList[] = [] let textOperations: Project.LabelSchemaList[] = [] let metaOperation: Project.LabelSchemaList | null = null res.label_schema_list?.forEach((schema) => { if (operationTypeList.includes(schema.label_type)) { operations.push(schema) } else if ([201].includes(schema.label_type)) { if (schema.label_class === "元操作序列") metaOperation = schema else textOperations.push(schema) } }) setObjectOperations(operations) if (res.label_type === 5) { setDescOperations(textOperations) if (metaOperation) setMetaOperation(metaOperation) } else { setDescOperations([]) setMetaOperation(null) } if (res.label_type === 6) { let obj: any = {} textOperations.forEach((item) => { let arr = item.sub_attributes_describe.map((sub) => { return { [sub.chinese_name]: { value: "", id: sub.round_id, is_pre: true, tag: 0, uid: 0, create_timestamp: 0, modify_uid: 0, modify_timestamp: 0, comment: "", flag: 0, }, } }) obj[item.label_class] = arr }) let map = new Map() map.set("init", obj) console.log(map) setQaData(map) setQaOperations(textOperations) } else { setQaOperations([]) } resetData() } catch {} } }, [ projectId, resetData, setDescOperations, setMetaOperation, setObjectOperations, setQaData, setQaOperations, ]) const normalizeVideoTaskData = useCallback( 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 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() let imageMapUpdated = false 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) ?? [] const hasAllCachedFrames = frameNames.length > 0 && frameNames.every((frameName) => { const cache = imagesMap.get(frameName) return typeof cache === "string" && cache.length > 0 }) 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) => { 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) { throw new Error(`视频帧映射为空:${videoName}`) } frameNames.forEach((frameName) => { // 将视频帧映射回 Task.DataProps 结构,复用现有图片标注渲染逻辑。 normalizedDataName.push({ name: [frameName] as [string], related_images: [], }) }) } if (!normalizedDataName.length) { throw new Error("未生成可用视频帧") } if (imageMapUpdated) { // 批量写入,避免每帧 setState 触发频繁重渲染。 useImagesStore.getState().setImages(imagesMap) } frameMapUpdates.forEach((frameNames, cacheKey) => { useVideoFrameStore.getState().setVideoFrames(cacheKey, frameNames) }) setHasReadyVideoFrame(true) return { ...detail, data_name: normalizedDataName, } }, [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], get_data: true, page_number: 1, page_size: 1, }) if (requestSeq !== latestTaskDetailRequestSeq) { console.warn("[LabelNossr] ignore stale task list response", { taskId, requestSeq, latestRequestSeq: latestTaskDetailRequestSeq, }) return } let detail = res.task_list[0] 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, requestSeq, latestRequestSeq: latestTaskDetailRequestSeq, }) return } setTaskDetail(detail) // 帧列表 useBottomToolsStore .getState() .setAllItems(detail.data_name.map((item) => item.name[0] || "")) // 获取当前任务流转记录 let flowCommentArr: Comment[] = [] let turn1time = 1 let turn2time = 1 const flowRes = await getTaskWorkFlow(taskId) if (requestSeq !== latestTaskDetailRequestSeq) { console.warn("[LabelNossr] ignore stale workflow response", { taskId, requestSeq, latestRequestSeq: latestTaskDetailRequestSeq, }) return } flowRes.forEach((item) => { if (item.task_status_src !== 4 && item.task_status_dst === 4) { flowCommentArr.push({ uid: item.new_uid, turn: 1, times: turn1time, check_box_comments: [], text_comments: [], comment_type: 0, date: 0, scene_info: { type: 0, }, }) turn1time++ } else if (item.task_status_src !== 6 && item.task_status_dst === 6) { flowCommentArr.push({ uid: item.new_uid, turn: 2, times: turn2time, check_box_comments: [], text_comments: [], comment_type: 0, date: 0, scene_info: { type: 0, }, }) turn2time++ } }) console.log(flowCommentArr) useLabelStore.getState().setLabelDefaultComments(flowCommentArr) const { work_time, label_status } = detail setLabelTime({ label: work_time.label_work_time, review1: work_time.first_review_work_time, review2: work_time.second_review_work_time, }) // 获取并处理当前任务标注结果数据 const dataRes = await getLabelResult(taskId) if (requestSeq !== latestTaskDetailRequestSeq) { console.warn("[LabelNossr] ignore stale label result response", { taskId, requestSeq, latestRequestSeq: latestTaskDetailRequestSeq, }) return } const { results } = dataRes const resultImageEntries = results.flatMap((item) => expandLabelResultImages(item) ) let currentWorkTime = 0 if (label_status === 2) { currentWorkTime = work_time.label_work_time } else if (label_status === 4) { currentWorkTime = work_time.first_review_work_time } else if (label_status === 6) { currentWorkTime = work_time.second_review_work_time } // 生成当前workload const workLoad: WorkLoad = { object_size: {}, prelabel_modify_size: {}, review_size: 0, comment_size: 0, work_time: currentWorkTime, dynamic_size: {}, static_size: {}, review_dynamic_size: {}, review_static_size: {}, review_question_size: {}, key_frame_size: {}, review_key_frame_size: {}, question_size: {}, } resultImageEntries.forEach(({ frameName, imageObjects }) => { const { annotations, images, key_frame, desc } = imageObjects const rasterName = frameName || images.file_name if (rasterName) { usePaperStore .getState() .setRasterSize(rasterName, [images.width, images.height]) } annotations.forEach((annotation) => { let reviewed = false const { comment, prelabel, uid } = annotation if (prelabel) workLoad.prelabel_modify_size[uid] ? workLoad.prelabel_modify_size[uid]++ : (workLoad.prelabel_modify_size[uid] = 1) else workLoad.object_size[uid] ? workLoad.object_size[uid]++ : (workLoad.object_size[uid] = 1) if (comment) { comment.forEach((c) => { if (c.comment_type > 0 && c.uid === user_id) reviewed = true if (c.comment_type === 2 && c.uid === user_id) workLoad.comment_size++ }) } if (reviewed) workLoad.review_size++ }) if (key_frame) { if (label_status === 2) { let id = imageObjects.label1_uid! if (id) workLoad.key_frame_size[id] ? workLoad.key_frame_size[id]++ : (workLoad.key_frame_size[id] = 1) } else if (label_status === 4) { let id = imageObjects.review1_uid! if (id) workLoad.review_key_frame_size[id] ? workLoad.review_key_frame_size[id]++ : (workLoad.review_key_frame_size[id] = 1) } else if (label_status === 6) { let id = imageObjects.review2_uid! if (id) workLoad.review_key_frame_size[id] ? workLoad.review_key_frame_size[id]++ : (workLoad.review_key_frame_size[id] = 1) } } if (useDescToolsStore.getState().qaOperations.length) { if (desc) { const { other_desc } = desc const descData = other_desc ? JSON.parse(other_desc) : {} Object.values(descData).forEach((questionArr: any) => { questionArr.forEach((q: any) => { const v: any = Object.values(q)[0] if (v.uid) { workLoad.question_size[v.uid] ? workLoad.question_size[v.uid]++ : (workLoad.question_size[v.uid] = 1) } if (v.tag) { const uid = usePermissionStore.getState().user_id workLoad.review_question_size[uid] ? workLoad.review_question_size[uid]++ : (workLoad.review_question_size[uid] = 1) } }) }) } } }) console.log("获取时计算的工作量统计", workLoad) setOldWorkLoad(workLoad) const taskLabelData = new Map() const pathGroupData = new Map() // 重置 PathIds useRightToolsStore.getState().setPathIds([]) // 重置 PathGroupIds useRightToolsStore.getState().setPathGroupIds([]) // 生成标注结果 resultImageEntries.forEach(({ frameName, imageObjects }) => { const key = frameName if (!key) return const categoryMap = new Map() const { annotations, image_groups, desc } = imageObjects // 赋值用过的 id annotations.map((annotation) => { useRightToolsStore.getState().pushPathId(annotation.id) }) // 赋值用过的 groupId image_groups && Object.keys(image_groups)?.map((groupId) => { useRightToolsStore.getState().pushPathGroupId(+groupId) }) annotations.forEach((annotation) => { const { id, category_id, comment } = annotation let current_comment: Comment[] = [] if (comment) { current_comment = flowCommentArr.map((c) => { const { turn, times } = c let modified_comment = null comment.forEach((item) => { if (item.turn === turn && item.times === times) modified_comment = item }) return modified_comment ? modified_comment : c }) } else { current_comment = [...flowCommentArr] } let detail: any = { ...annotation, comment: current_comment, } // group info if (image_groups) { const key = findGroupKey(image_groups as any, id) if (key !== -1) detail.parentGroupId = key } const categoryKey = `${category_id}` let arr = categoryMap.has(categoryKey) ? categoryMap.get(categoryKey) : [] if (annotation.rect) { // arr.push([id, getRectPoints(annotation.rect), detail, []]); arr.push([id, [getRectPoints(annotation.rect)], detail, []]) } if (annotation.line_segment) { arr.push([ id, annotation.line_segment.map((item) => getSegmentPoints(item)), detail, [], ]) } if (annotation.segmentation) { arr.push([ id, // getSegmentPoints(annotation.segmentation[0]), annotation.segmentation.map((item) => getSegmentPoints(item)), detail, annotation.hollow_segmentation?.map((item) => getSegmentPoints(item) ) || [], ]) } if (annotation.key_points) { arr.push([ id, // getSegmentPoints(annotation.segmentation[0]), [ getSegmentPoints( annotation.key_points.map((item) => item.point) ), ], Object.assign(detail, { circles: annotation.key_points, }), [], ]) } categoryMap.set(categoryKey, arr) }) taskLabelData.set(key, categoryMap) // 关键帧信息 const keyFrame = { key_frame: imageObjects.key_frame || false, label1_ts: imageObjects.label1_ts || 0, label1_uid: imageObjects.label1_uid || 0, review1_ts: imageObjects.review1_ts || 0, review1_uid: imageObjects.review1_uid || 0, review2_ts: imageObjects.review2_ts || 0, review2_uid: imageObjects.review2_uid || 0, } useBottomToolsStore.getState().setKeyFrameData(key, keyFrame) // 大语言模型 两种数据情况 if (useDescToolsStore.getState().qaOperations.length) { if (desc) { const { other_desc } = desc let current_data: any = {} const descData = other_desc ? JSON.parse(other_desc) : {} Object.entries(descData).forEach( ([label_class, questionArr]: any) => { let arr = questionArr.map((question: any) => { let [name, val]: any = Object.entries(question)[0] return { [name]: { ...val, value: val.value ? `${val.value}${splitWord}${val.value}` : undefined, comment: val.comment ? `${val.comment}${splitWord}${val.comment}` : undefined, }, } }) current_data[label_class] = arr } ) const qaData = useDescToolsStore.getState().qaData // 无文本描述时 赋予预设值 if (JSON.stringify(current_data) === "{}") { current_data = qaData.get("init") } const qaMap = safeClone(qaData) qaMap.set(key, current_data) useDescToolsStore.getState().setQaData(qaMap) } } else if (useDescToolsStore.getState().descOperations.length) { if (desc) { const { meta_operation, other_desc } = desc const currentMetaMap = safeClone( useDescToolsStore.getState().metaData ) currentMetaMap.set(key, meta_operation || []) useDescToolsStore.getState().setMetaData(currentMetaMap) const currentDescMap = safeClone( useDescToolsStore.getState().descData ) let descMap = new Map() const descData = other_desc ? JSON.parse(other_desc) : {} console.log(descData) Object.entries(descData).forEach( ([label_class, { value, is_correct, comment }]: any) => { descMap.set(label_class, { value: (typeof value === "string" ? `${value}${splitWord}${value}` : value) || undefined, is_correct: is_correct || undefined, comment: comment || undefined, }) } ) currentDescMap.set(key, descMap) useDescToolsStore.getState().setDescData(currentDescMap) } } image_groups && pathGroupData.set( key, new Map( Object.entries(image_groups).map(([key, value]: any) => [ key * 1, value, ]) ) ) }) setLabel(taskLabelData) // 获取任务详情时设置状态栈初始值 setStateStack([taskLabelData]) useDescToolsStore.getState().updateFlag(true) useRightToolsStore.getState().setPathGroupMap(pathGroupData) } catch (err) { if (requestSeq !== latestTaskDetailRequestSeq) return console.log(err) setSourceVideoNames([]) setOldWorkLoad(null) setTaskDetail(undefined) useBottomToolsStore.getState().setAllItems([]) setLabel(new Map()) setStateStack([]) useRightToolsStore.getState().setPathGroupMap(new Map()) if (err instanceof Error && err.message.includes("视频")) { showNotification({ color: "red", title: "视频解码失败", message: err.message, }) } } finally { if (requestSeq === latestTaskDetailRequestSeq) { usePaperStore.getState().setLoadingData(false) } } } }, [ normalizeVideoTaskData, setLabel, setLabelTime, setStateStack, taskId, user_id, ]) // 保存时更新当前workload const updateOldWorkLoad = (data: WorkLoad) => { setOldWorkLoad(data) } useEffect(() => { asyncGetProjectDetail() asyncGetTaskDetail() }, [asyncGetProjectDetail, asyncGetTaskDetail]) // 定义 reducer 函数 const reducer = useCallback( (state: LabelState, action: Action): LabelState => { switch (action.type) { case "INIT_IMAGES": const newState = new Map() // 创建状态的副本 action.imageIds.forEach((imageId) => { newState.set( imageId, action.objectOperations.map((item) => item.category_id.toString()) ) // 将操作列表添加到新状态中 }) return newState case "REMOVE_IMAGE": const stateAfterRemoval = new Map(state) if (stateAfterRemoval.has(action.imageId)) { stateAfterRemoval.delete(action.imageId) // 删除该图片的条目 } return stateAfterRemoval default: return state // 返回当前状态 } }, [] ) const [labelState, dispatch] = useReducer(reducer, initialState) const initImages = ( imageIds: string[], objectOperations: Project.LabelSchemaList[] ) => { dispatch({ type: "INIT_IMAGES", imageIds, objectOperations, }) } const [isLabelTask, setIsLabelTask] = useState(false) useEffect(() => { if (!taskDetail || !projectDetail) { setIsLabelTask(false) setIsView(true) setEditMode(false) return } if (taskDetail) { if ( ![201, 202, 203, 500].includes(projectDetail!.status) || ![2, 4, 6, 7].includes(taskDetail.label_status) || taskDetail.current_uid !== user_id ) { // 如果不是能标注的任务 setIsLabelTask(false) setIsView(true) setEditMode(false) } else { setIsLabelTask(true) setIsView(false) } initImages( taskDetail.data_name.map((item) => item.name?.[0]), objectOperations ) if (taskDetail.data_name?.length) setShowMultiFrame(true) } }, [ objectOperations, setIsView, setShowMultiFrame, user_id, taskDetail, projectDetail, setEditMode, ]) const { setPaperMode, setToolOption, setGroupScale } = usePaperStore() // @ts-expect-error to_do const forceUpdate = useReducer((bool) => !bool)[1] const getOperationSchema = useCallback( (operationId: string) => { return ( projectDetail?.label_schema_list?.find( (item) => item.category_id === +operationId && item.label_type !== 201 ) || null ) }, [projectDetail?.label_schema_list] ) const setDrawType = useCallback(() => { if (isView) { setPaperMode("pan") } else { let operationSchema = getOperationSchema(activeOperation) if (operationSchema) { let strokeColor = `rgb(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]})` let fillColor = `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.3)` let blankColor = `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.01)` if (editMode) { switch ( operationSchema && labelTypeMap.get(operationSchema.label_type) ) { case "多边形": setToolOption({ strokeColor: strokeColor, strokeWidth: 2, fillColor: fillColor, blankColor: blankColor, }) setPaperMode("polygon") return case "关键点": setToolOption({ strokeColor: strokeColor, strokeWidth: 2, fillColor: fillColor, blankColor: blankColor, }) setPaperMode("point") return case "多线段": setToolOption({ strokeColor: strokeColor, strokeWidth: 2, fillColor: fillColor, blankColor: blankColor, }) setPaperMode("brush") return case "2D框": setToolOption({ strokeColor: strokeColor, strokeWidth: 2, fillColor: fillColor, blankColor: blankColor, }) setPaperMode("rectangle") return default: setPaperMode("polygon") return } } else { setPaperMode("pan") } } } }, [ activeOperation, editMode, getOperationSchema, isView, setPaperMode, setToolOption, ]) useEffect(() => { setDrawType() }, [editMode, setDrawType]) const { setShift, focusInput } = useKeyEventStore() const handleShortcut = useCallback( (key: string, ctrl: boolean) => { let checkOperation = getOperationSchema(key) const mode = usePaperStore.getState().mode // 标注方案里面存在的非文本类型标注才能切换 if (checkOperation) { // 辅助标注模式下非多边形类型不能切换 if (mode === "support" && checkOperation.label_type !== 102) return if (ctrl) { setActiveOperation((+key + 10).toString()) } else { setActiveOperation(key) } } }, [getOperationSchema, setActiveOperation] ) useEffect(() => { const down = (e: KeyboardEvent) => { // console.log(e, e.key); if (focusInput || isView) return console.log(e.key) const mode = usePaperStore.getState().mode if (e.key === "Alt" && mode !== "support") { e.preventDefault() // console.log(e.key); setEditMode(!editMode) if (!editMode) setPressA(false) } if (e.key === "Shift") { e.preventDefault() setShift(true) useKeyboardStore.getState().setShift(true) } if (mode !== "support") if (Number(e.key)) { handleShortcut(e.key, e.ctrlKey) } else if (Number(e.key) === 0) { handleShortcut("10", e.ctrlKey) } if (e.key === "s" && (e.metaKey || e.ctrlKey)) { e.preventDefault() setIsConfirmSave(true) } if (e.key === "g" && (e.metaKey || e.ctrlKey)) { e.preventDefault() paperContainerRef.current.handleCreatePathGroup() } if (e.key === "a") { e.preventDefault() setPressA(true) } if (e.key === "Control") { e.preventDefault() useKeyboardStore.getState().setCtrl(true) } // 辅助标注 分割 if (e.key === "F1") { // 判断类型 e.preventDefault() const schema = getOperationSchema( useTopToolsStore.getState().activeOperation ) console.log(schema) if (schema?.label_type !== 102) { showNotification({ title: "错误", message: "当前类型不支持辅助标注!", color: "red", }) } else { if (mode === "support") { // 辅助标注生成对应标注结果 paperContainerRef.current.saveSupportAnnotationData() } else { usePaperStore.getState().setPaperMode("support") } } } // 追踪 if (e.key === "i") { e.preventDefault() paperContainerRef.current.renderTrackingAnnotation() } // 复制 if (e.key === "c" && e.ctrlKey) { e.preventDefault() useKeyboardStore .getState() .setCopyDataIds( useObjectStore.getState().selectedPath[ useBottomToolsStore.getState().activeImage ] ) } // 粘贴 if (e.key === "v" && e.ctrlKey) { e.preventDefault() paperContainerRef.current.copyAnnotationDataToMultiFrame() } // 撤回 if (e.key === "z" && e.ctrlKey) { e.preventDefault() // 清除当前页数据 const clear = () => { usePaperStore .getState() .group!.getItems({}) .filter((item) => item.data.id) .forEach((item) => { item.remove() }) } const data = useLabelStore.getState().popStateStack() console.log("弹出栈的数据", data) if (data) { clear() const imageId = useBottomToolsStore.getState().activeImage const pathGroupMap = new Map() for (const [imageKey, categories] of data) { const groupMap = new Map() for (const [key, value] of categories) { value.forEach((item) => { const vId = item[2].parentGroupId if (vId) if (groupMap.has(vId)) groupMap.get(vId).push(item[0]) else groupMap.set(vId, [item[0]]) }) if (imageId === imageKey) paperContainerRef.current.renderPolygons(key, categories) } pathGroupMap.set(imageKey, groupMap) } useRightToolsStore.getState().setPathGroupMap(pathGroupMap) console.log(pathGroupMap) // if (data.size) { // if (data.has(imageId)) { // const map = data.get(imageId); // if (map) { // for (const [key, value] of map) { // console.log(key, value); // paperContainerRef.current.renderPolygons(key, map); // } // } // } // } // 更新当前数据 useLabelStore.getState().setLabel(safeClone(data)) } } if (e.key === "p") { e.preventDefault() if (projectDetail?.label_type === 6 && qaToolContainerRef.current) qaToolContainerRef.current.checkWrongWords() } // if (e.key === "=" && (e.metaKey || e.ctrlKey)) { // e.preventDefault(); // setScale((num) => { // let newScale = num + 0.1; // return newScale; // }); // } else if (e.key === "-" && (e.metaKey || e.ctrlKey)) { // e.preventDefault(); // setScale((num) => { // if (num <= 1) { // let newScale = num * 0.9; // return newScale; // } else { // let newScale = num - 0.1; // return newScale; // } // }); // } else if (e.key === "3") { // e.preventDefault(); // } else if (e.key === "4") { // e.preventDefault(); // } else if (e.key === "5") { // e.preventDefault(); // } else if (e.key === "a" && (e.metaKey || e.ctrlKey)) { // e.preventDefault(); // } } const up = (e: KeyboardEvent) => { // console.log("键盘抬起", e); if (e.key === "Shift") { setShift(false) useKeyboardStore.getState().setShift(false) } if (e.key === "Control") { useKeyboardStore.getState().setCtrl(false) } } const wheel = (e: WheelEvent) => { e.preventDefault() const currentScale = useTopToolsStore.getState().scale let newScale = e.deltaY < 0 ? currentScale * 1.1 : currentScale * 0.9 if (newScale >= 100) return setScale(newScale) setGroupScale(newScale) } const mouseclick = (_e: MouseEvent) => { useTopToolsStore.getState().setSubAttrPresetShow(false) } const mousemove = (event: { clientX: number; clientY: number }) => { paperContainerRef.current.handleCrosshairMove(event) } let element = document.getElementById("resize-container") document.addEventListener("keydown", down) document.addEventListener("keyup", up) element?.addEventListener("wheel", wheel) element?.addEventListener("mousedown", mouseclick) element?.addEventListener("mousemove", mousemove) return () => { document.removeEventListener("keydown", down) document.removeEventListener("keyup", up) element?.removeEventListener("wheel", wheel) element?.removeEventListener("mousedown", mouseclick) element?.removeEventListener("mousemove", mousemove) } }, [ editMode, focusInput, getOperationSchema, handleShortcut, isView, projectDetail?.label_type, setEditMode, setGroupScale, setPressA, setScale, setShift, ]) const setGlobalLoading = useCallback((flag: boolean) => { setLoading(flag) }, []) // 切换图片时清空复制保存的数组 useEffect(() => { useKeyboardStore.getState().setCopyDataIds([]) }, [activeImage]) const handleBeforeUnload = (e: any) => { e.preventDefault() // 备份 topRef.current.handleBackup("auto_backup") // 清空操作栏状态 useObjectStore.getState().resetPathAndOperationStatus() } // 更新Splitter状态 const updateSplitterSizes = useCallback(() => { console.log( "document.documentElement.clientWidth", document.documentElement.clientWidth ) const len = document.documentElement.clientWidth - 4 let arr = [len, 0, 0, 0, 0, 0] const flags = [ showTaskList, showGroupList, showDescList && projectDetail && projectDetail.label_type === 5, showDescList && projectDetail && projectDetail.label_type === 6, showDescList && projectDetail && projectDetail.label_type === 7, showObjectList, ] let count = 0 flags.forEach((flag) => { if (flag) count++ }) if (count === 1 || count === 2) { arr[0] = count === 1 ? len * 0.8 : len * 0.6 flags.forEach((flag, index) => { if (flag) { arr[index + 1] = len * 0.2 } }) } else if (count === 3) { arr[0] = len * 0.55 flags.forEach((flag, index) => { if (flag) { arr[index + 1] = len * 0.15 } }) } else if (count === 4) { arr[0] = len * 0.6 flags.forEach((flag, index) => { if (flag) { arr[index + 1] = len * 0.1 } }) } setSizes(arr) }, [projectDetail, showDescList, showGroupList, showObjectList, showTaskList]) useEffect(() => { updateSplitterSizes() }, [updateSplitterSizes]) useEffect(() => { if (!isView) { const delay = 10 * 60000 // 编辑模式下,修改labelData时启动或更新定时器 useTimerStore.getState().startTimer(() => { useTopToolsStore.getState().setIsView(true) }, delay) } else { useTimerStore.getState().clearTimer() } // 组件卸载时清理定时器 return () => { useTimerStore.getState().clearTimer() } }, [isView, labelData]) // auto save useEffect(() => { let cancelled = false if (autoSaveTimerRef.current !== null) { clearTimeout(autoSaveTimerRef.current) autoSaveTimerRef.current = null } if (!isAutoSave || isView) return const minute = 60 * 1000 const delay = autoSaveGap * minute const scheduleNext = () => { autoSaveTimerRef.current = setTimeout(async () => { if (cancelled) return if (autoSaveInProgressRef.current) { scheduleNext() return } autoSaveInProgressRef.current = true try { await topRef.current?.handleSave?.() } catch (error) { console.error("[LabelNossr] auto save failed", error) } finally { autoSaveInProgressRef.current = false if (!cancelled) scheduleNext() } }, delay) } scheduleNext() return () => { cancelled = true if (autoSaveTimerRef.current !== null) { clearTimeout(autoSaveTimerRef.current) autoSaveTimerRef.current = null } } }, [autoSaveGap, isAutoSave, isView]) useEffect(() => { window.addEventListener("beforeunload", handleBeforeUnload) return () => { window.removeEventListener("beforeunload", handleBeforeUnload) } }, []) const mainBoxHeight = useMemo(() => { if (projectDetail?.label_type === 5 && metaOperation) { let h = `calc(100% - ${headerHeight}px - 198px)` return h } else if (showMultiFrame) { let h = `calc(100% - ${headerHeight}px - 150px)` return h } let h = `calc(100% - ${headerHeight}px - 70px)` return h }, [projectDetail?.label_type, metaOperation, showMultiFrame, headerHeight]) const fullscreenRef = useRef(null) return ( 0 && !hasReadyVideoFrame)} zIndex={GLOBAL_LOADING_Z_INDEX} overlayProps={{ radius: "sm", blur: 2 }} /> {sourceVideoNames.length > 0 && ( )} {/* */} {showTaskList && ( )} {showGroupList && ( )} {showDescList && projectDetail && projectDetail.label_type === 5 && ( )} {showDescList && projectDetail && projectDetail.label_type === 6 && ( )} {showObjectList && ( )} {/* */} {isConfirmSave && ( { try { await topRef.current?.handleSave() setIsConfirmSave(false) } catch {} }} onCancel={() => { setIsConfirmSave(false) }} /> )} ) } export default LabelPage