import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useStore } from '../store/useStore'; import { annotationToMask, buildAnnotationPayload, deleteAnnotation, exportCoco, getProjectAnnotations, getProjectFrames, getTask, getTemplates, parseMedia, saveAnnotation, updateAnnotation, } from '../lib/api'; import { CanvasArea } from './CanvasArea'; import { ToolsPalette } from './ToolsPalette'; import { OntologyInspector } from './OntologyInspector'; import { FrameTimeline } from './FrameTimeline'; import { ModelStatusBadge } from './ModelStatusBadge'; import type { Frame } from '../store/useStore'; function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }) { const activeTool = useStore((state) => state.activeTool); const setActiveTool = useStore((state) => state.setActiveTool); const currentProject = useStore((state) => state.currentProject); const frames = useStore((state) => state.frames); const currentFrameIndex = useStore((state) => state.currentFrameIndex); const masks = useStore((state) => state.masks); const activeTemplateId = useStore((state) => state.activeTemplateId); const setFrames = useStore((state) => state.setFrames); const setCurrentFrame = useStore((state) => state.setCurrentFrame); const setMasks = useStore((state) => state.setMasks); const [isSaving, setIsSaving] = useState(false); const [isExporting, setIsExporting] = useState(false); const [statusMessage, setStatusMessage] = useState(''); const hydrateSavedAnnotations = useCallback(async (projectId: string, projectFrames: Frame[]) => { const frameById = new Map(projectFrames.map((frame) => [frame.id, frame])); const annotations = await getProjectAnnotations(projectId); const savedMasks = annotations .map((annotation) => { const frame = annotation.frame_id ? frameById.get(String(annotation.frame_id)) : null; return frame ? annotationToMask(annotation, frame) : null; }) .filter((mask): mask is NonNullable => Boolean(mask)); setMasks(savedMasks); }, [setMasks]); useEffect(() => { if (!currentProject?.id) return; let cancelled = false; const loadFrames = async () => { try { const data = await getProjectFrames(String(currentProject.id)); if (cancelled) return; if (data.length === 0 && currentProject.video_path) { // No frames yet but video exists -> queue parsing and poll the task. try { const task = await parseMedia(String(currentProject.id)); if (cancelled) return; setStatusMessage(`解析任务已入队 #${task.id}`); let completed = false; for (let attempt = 0; attempt < 60; attempt += 1) { const freshTask = await getTask(task.id); if (cancelled) return; setStatusMessage(freshTask.message || `解析进度 ${freshTask.progress}%`); if (freshTask.status === 'success') { completed = true; break; } if (freshTask.status === 'failed') { setStatusMessage(freshTask.error || '解析任务失败'); return; } await sleep(2000); } if (!completed) { setStatusMessage('解析仍在后台运行,可稍后刷新工作区'); return; } const fresh = await getProjectFrames(String(currentProject.id)); if (cancelled) return; const mappedFrames = fresh.map((f) => ({ id: String(f.id), projectId: String(f.project_id), index: f.frame_index, url: f.image_url, width: f.width ?? 0, height: f.height ?? 0, })); setFrames(mappedFrames); setCurrentFrame(0); await hydrateSavedAnnotations(String(currentProject.id), mappedFrames); } catch (err) { console.error('Parse failed:', err); } } else { const mappedFrames = data.map((f) => ({ id: String(f.id), projectId: String(f.project_id), index: f.frame_index, url: f.image_url, width: f.width ?? 0, height: f.height ?? 0, })); setFrames(mappedFrames); setCurrentFrame(0); await hydrateSavedAnnotations(String(currentProject.id), mappedFrames); } } catch (err) { console.error('Failed to load frames:', err); } }; loadFrames(); return () => { cancelled = true; }; }, [currentProject?.id, currentProject?.video_path, hydrateSavedAnnotations, setFrames, setCurrentFrame]); const templates = useStore((state) => state.templates); const setTemplates = useStore((state) => state.setTemplates); useEffect(() => { if (templates.length === 0) { getTemplates().then((data) => setTemplates(data)).catch(console.error); } }, [templates.length, setTemplates]); const currentFrame = frames[currentFrameIndex] || null; const frameById = useMemo(() => new Map(frames.map((frame) => [frame.id, frame])), [frames]); const projectFrameIds = useMemo(() => new Set(frames.map((frame) => frame.id)), [frames]); const savePendingAnnotations = useCallback(async ({ silent = false } = {}) => { if (!currentProject?.id) return 0; const projectMasks = masks.filter((mask) => projectFrameIds.has(mask.frameId)); const pendingMasks = projectMasks.filter((mask) => !mask.annotationId); const dirtyMasks = projectMasks.filter((mask) => mask.annotationId && mask.saveStatus === 'dirty'); if (pendingMasks.length === 0 && dirtyMasks.length === 0) { if (!silent) setStatusMessage('没有待保存标注'); return 0; } setIsSaving(true); setStatusMessage('正在保存标注...'); try { const createPayloads = pendingMasks .map((mask) => { const frame = frameById.get(mask.frameId); return frame ? buildAnnotationPayload(currentProject.id, mask, frame, activeTemplateId) : null; }) .filter((payload): payload is NonNullable => Boolean(payload)); const updatePayloads = dirtyMasks .map((mask) => { const frame = frameById.get(mask.frameId); const payload = frame ? buildAnnotationPayload(currentProject.id, mask, frame, activeTemplateId) : null; if (!payload || !mask.annotationId) return null; const updatePayload = { template_id: payload.template_id, mask_data: payload.mask_data, points: payload.points, bbox: payload.bbox, }; return { annotationId: mask.annotationId, payload: updatePayload }; }) .filter((item): item is NonNullable => Boolean(item)); if (createPayloads.length === 0 && updatePayloads.length === 0) { setStatusMessage('没有可保存的标注数据'); return 0; } await Promise.all([ ...createPayloads.map((payload) => saveAnnotation(payload)), ...updatePayloads.map(({ annotationId, payload }) => updateAnnotation(annotationId, payload)), ]); await hydrateSavedAnnotations(currentProject.id, frames); const savedCount = createPayloads.length + updatePayloads.length; setStatusMessage(`已保存 ${savedCount} 个标注`); return savedCount; } catch (err) { console.error('Save annotations failed:', err); setStatusMessage('保存失败,请检查后端服务'); throw err; } finally { setIsSaving(false); } }, [activeTemplateId, currentProject?.id, frameById, frames, hydrateSavedAnnotations, masks, projectFrameIds]); const handleClearCurrentFrameMasks = useCallback(async () => { if (!currentFrame) return; const frameMasks = masks.filter((mask) => mask.frameId === currentFrame.id); const annotationIds = frameMasks .map((mask) => mask.annotationId) .filter((annotationId): annotationId is string => Boolean(annotationId)); setIsSaving(true); setStatusMessage(annotationIds.length > 0 ? '正在删除已保存标注...' : '正在清空本帧遮罩...'); try { await Promise.all(annotationIds.map((annotationId) => deleteAnnotation(annotationId))); setMasks(masks.filter((mask) => mask.frameId !== currentFrame.id)); setStatusMessage(annotationIds.length > 0 ? `已删除 ${annotationIds.length} 个后端标注` : '已清空本帧未保存遮罩'); } catch (err) { console.error('Delete annotations failed:', err); setStatusMessage('删除失败,请检查后端服务'); } finally { setIsSaving(false); } }, [currentFrame, masks, setMasks]); const handleSave = async () => { try { await savePendingAnnotations(); } catch { // status message is set in savePendingAnnotations } }; const handleExport = async () => { if (!currentProject?.id) return; setIsExporting(true); setStatusMessage('正在准备导出...'); try { await savePendingAnnotations({ silent: true }); const blob = await exportCoco(currentProject.id); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `project_${currentProject.id}_coco.json`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); setStatusMessage('COCO JSON 已导出'); } catch (err) { console.error('Export failed:', err); setStatusMessage('导出失败,请检查后端服务'); } finally { setIsExporting(false); } }; return (
{/* Top Header / Status bar */}

核心分割工作区

{currentProject?.name || '未选择项目'}
{statusMessage && ( {statusMessage} )}
{/* Main Workspace Area */}
{/* Bottom Timeline */}
); }