功能增加:点击 Canvas mask 后,右侧语义分类树会按 classId/className/label 自动匹配分类,并滚动聚焦到对应分类按钮。
功能增加:工作区新增按起止帧批量清空片段遮罩,复用传播范围输入,范围内已保存标注走 DELETE /api/ai/annotations/{id},本地 draft mask 同步移除。
功能增加:右侧语义分类树上方新增工作区 mask 透明度滑杆,写入 Zustand maskPreviewOpacity,Canvas mask 预览按该值渲染并保留选中加亮反馈。
功能增加:视频处理进度条记录最近自动传播区间,使用不同色系深浅渐变提示最近处理片段。
功能增加:工作区自动传播前会先保存 draft/dirty seed mask,使用稳定后端 source_annotation_id 入队,减少二次传播重复结果。
Bugfix:后端传播任务对旧临时 seed id、不同 SAM 2.1 权重结果做兼容清理;相同 seed 和相同权重才跳过,否则先删旧自动传播标注再重传。
Bugfix:修复 polygon 顶点拖拽结束后触发 Stage 平移导致画布中心偏移的问题,并补充测试环境对 drag target 的模拟。
Bugfix:工具提示会在数秒后自动隐藏,避免创建多边形/矩形等提示长期遮挡画布。
UI 调整:移除右侧面板顶部‘本体论与属性分类管理树’说明栏,减少无效占位。
UI 调整:左侧工具栏和右侧语义面板使用低对比 seg-scrollbar;左侧工具栏外扩滚动条槽位,避免滚动条挤占图标列。
UI 调整:工作区模型状态徽标改为紧凑显示,减少与传播权重选择重复;传播权重下拉改成深色背景和青色文字,避免灰底白字不可读。
UI 调整:缩略图状态框固定优先级,当前帧、人工/AI 标注帧、自动传播帧可用外框/内框组合同时表达。
测试:补充 VideoWorkspace、CanvasArea、FrameTimeline、OntologyInspector、ToolsPalette、useStore 和后端 test_ai 覆盖新增交互、传播去重、批量清空、透明度、滚动条和 UI 状态。
文档:同步更新 README、AGENTS 和 doc/03、doc/04、doc/07、doc/08、doc/09,记录当前功能、接口契约、需求设计冻结和测试覆盖。
893 lines
38 KiB
TypeScript
893 lines
38 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { Redo, Undo } from 'lucide-react';
|
||
import { useStore } from '../store/useStore';
|
||
import {
|
||
annotationToMask,
|
||
buildAnnotationPayload,
|
||
cancelTask,
|
||
deleteAnnotation,
|
||
exportCoco,
|
||
exportMasks,
|
||
getProjectAnnotations,
|
||
getProjectFrames,
|
||
getTask,
|
||
getTemplates,
|
||
importGtMask,
|
||
queuePropagationTask,
|
||
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 { DEFAULT_AI_MODEL_ID, SAM2_MODEL_OPTIONS, type AiModelId, type Frame, type Mask } from '../store/useStore';
|
||
|
||
type PropagationDirection = 'forward' | 'backward';
|
||
type PropagationProgress = {
|
||
currentStep: number;
|
||
completedSteps: number;
|
||
totalSteps: number;
|
||
processedCount: number;
|
||
createdCount: number;
|
||
label: string;
|
||
} | null;
|
||
type PropagationHistorySegment = {
|
||
id: string;
|
||
startFrame: number;
|
||
endFrame: number;
|
||
colorIndex: number;
|
||
label: string;
|
||
};
|
||
|
||
const PROPAGATION_POLL_INTERVAL_MS = 250;
|
||
const STATUS_MESSAGE_TTL_MS = 3600;
|
||
|
||
export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }) {
|
||
const gtMaskInputRef = React.useRef<HTMLInputElement>(null);
|
||
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 maskHistory = useStore((state) => state.maskHistory);
|
||
const maskFuture = useStore((state) => state.maskFuture);
|
||
const activeTemplateId = useStore((state) => state.activeTemplateId);
|
||
const aiModel = useStore((state) => state.aiModel);
|
||
const selectedMaskIds = useStore((state) => state.selectedMaskIds);
|
||
const latestSelectedMaskIdsRef = React.useRef<string[]>(selectedMaskIds);
|
||
if (selectedMaskIds.length > 0) {
|
||
latestSelectedMaskIdsRef.current = selectedMaskIds;
|
||
}
|
||
const setFrames = useStore((state) => state.setFrames);
|
||
const setCurrentFrame = useStore((state) => state.setCurrentFrame);
|
||
const setMasks = useStore((state) => state.setMasks);
|
||
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
|
||
const undoMasks = useStore((state) => state.undoMasks);
|
||
const redoMasks = useStore((state) => state.redoMasks);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [isExporting, setIsExporting] = useState(false);
|
||
const [isImportingGt, setIsImportingGt] = useState(false);
|
||
const [isPropagating, setIsPropagating] = useState(false);
|
||
const [statusMessage, setStatusMessage] = useState('');
|
||
const [propagationStartFrame, setPropagationStartFrame] = useState(1);
|
||
const [propagationEndFrame, setPropagationEndFrame] = useState(1);
|
||
const [isPropagationRangeSelecting, setIsPropagationRangeSelecting] = useState(false);
|
||
const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false);
|
||
const [propagationProgress, setPropagationProgress] = useState<PropagationProgress>(null);
|
||
const [propagationTaskId, setPropagationTaskId] = useState<number | null>(null);
|
||
const [propagationWeight, setPropagationWeight] = useState<AiModelId>(aiModel || DEFAULT_AI_MODEL_ID);
|
||
const [hasCustomPropagationWeight, setHasCustomPropagationWeight] = useState(false);
|
||
const [propagationHistory, setPropagationHistory] = useState<PropagationHistorySegment[]>([]);
|
||
|
||
useEffect(() => {
|
||
if (!hasCustomPropagationWeight) {
|
||
setPropagationWeight(aiModel || DEFAULT_AI_MODEL_ID);
|
||
}
|
||
}, [aiModel, hasCustomPropagationWeight]);
|
||
|
||
useEffect(() => {
|
||
setPropagationHistory([]);
|
||
}, [currentProject?.id]);
|
||
|
||
const propagationWeightLabel = useMemo(
|
||
() => SAM2_MODEL_OPTIONS.find((option) => option.id === propagationWeight)?.label || propagationWeight,
|
||
[propagationWeight],
|
||
);
|
||
useEffect(() => {
|
||
const handleWorkspaceShortcuts = (event: KeyboardEvent) => {
|
||
const target = event.target as HTMLElement | null;
|
||
const tagName = target?.tagName?.toLowerCase();
|
||
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select' || target?.isContentEditable) return;
|
||
if (!event.metaKey && !event.ctrlKey) return;
|
||
|
||
const key = event.key.toLowerCase();
|
||
if (key === 'z') {
|
||
event.preventDefault();
|
||
if (event.shiftKey) redoMasks();
|
||
else undoMasks();
|
||
return;
|
||
}
|
||
if (key === 'y') {
|
||
event.preventDefault();
|
||
redoMasks();
|
||
}
|
||
};
|
||
|
||
window.addEventListener('keydown', handleWorkspaceShortcuts);
|
||
return () => window.removeEventListener('keydown', handleWorkspaceShortcuts);
|
||
}, [redoMasks, undoMasks]);
|
||
|
||
const hydrateSavedAnnotations = useCallback(async (
|
||
projectId: string,
|
||
projectFrames: Frame[],
|
||
preserveSelectedIds: string[] = [],
|
||
excludeUnsavedMaskIds: string[] = [],
|
||
) => {
|
||
const frameById = new Map(projectFrames.map((frame) => [frame.id, frame]));
|
||
const projectFrameIds = new Set(projectFrames.map((frame) => frame.id));
|
||
const excludedDraftIds = new Set(excludeUnsavedMaskIds);
|
||
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<typeof mask> => Boolean(mask));
|
||
const currentMasks = useStore.getState().masks;
|
||
const unsavedMasks = currentMasks.filter((mask) => (
|
||
!projectFrameIds.has(mask.frameId) || (!mask.annotationId && !excludedDraftIds.has(mask.id))
|
||
));
|
||
const mergedMasks = [...unsavedMasks, ...savedMasks];
|
||
setMasks(mergedMasks);
|
||
if (preserveSelectedIds.length > 0) {
|
||
const mergedMaskIds = new Set(mergedMasks.map((mask) => mask.id));
|
||
const nextSelectedIds = preserveSelectedIds.filter((id) => mergedMaskIds.has(id));
|
||
if (nextSelectedIds.length > 0) {
|
||
setSelectedMaskIds(nextSelectedIds);
|
||
}
|
||
}
|
||
}, [setMasks, setSelectedMaskIds]);
|
||
|
||
useEffect(() => {
|
||
if (!currentProject?.id) return;
|
||
let cancelled = false;
|
||
|
||
const loadFrames = async () => {
|
||
const selectedIdsBeforeLoad = latestSelectedMaskIdsRef.current;
|
||
try {
|
||
const data = await getProjectFrames(String(currentProject.id));
|
||
if (cancelled) return;
|
||
|
||
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,
|
||
timestampMs: f.timestamp_ms ?? undefined,
|
||
sourceFrameNumber: f.source_frame_number ?? undefined,
|
||
}));
|
||
setFrames(mappedFrames);
|
||
setCurrentFrame(0);
|
||
if (mappedFrames.length === 0) {
|
||
setMasks([]);
|
||
if (currentProject.status === 'parsing') {
|
||
setStatusMessage('生成帧任务正在后台运行,可在 Dashboard 查看进度');
|
||
} else if (currentProject.video_path) {
|
||
setStatusMessage('该项目已导入视频但尚未生成帧,请在项目库点击“生成帧”');
|
||
} else {
|
||
setStatusMessage('当前项目没有可显示帧');
|
||
}
|
||
return;
|
||
}
|
||
setStatusMessage('');
|
||
await hydrateSavedAnnotations(String(currentProject.id), mappedFrames, selectedIdsBeforeLoad);
|
||
} 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 totalFrames = frames.length;
|
||
const frameById = useMemo(() => new Map(frames.map((frame) => [frame.id, frame])), [frames]);
|
||
const projectFrameIds = useMemo(() => new Set(frames.map((frame) => frame.id)), [frames]);
|
||
const currentFrameNumber = currentFrameIndex + 1;
|
||
const isWorkspaceBusy = isSaving || isExporting || isImportingGt || isPropagating || Boolean(propagationProgress);
|
||
|
||
useEffect(() => {
|
||
if (!statusMessage || isWorkspaceBusy || totalFrames === 0) return undefined;
|
||
const timer = window.setTimeout(() => setStatusMessage(''), STATUS_MESSAGE_TTL_MS);
|
||
return () => window.clearTimeout(timer);
|
||
}, [isWorkspaceBusy, statusMessage, totalFrames]);
|
||
|
||
useEffect(() => {
|
||
if (totalFrames === 0) {
|
||
setPropagationStartFrame(1);
|
||
setPropagationEndFrame(1);
|
||
return;
|
||
}
|
||
setPropagationStartFrame(currentFrameNumber);
|
||
setPropagationEndFrame(Math.min(totalFrames, currentFrameNumber + 29));
|
||
setIsPropagationRangeSelecting(false);
|
||
setHasExplicitPropagationRange(false);
|
||
}, [currentFrameNumber, totalFrames]);
|
||
|
||
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 createItems = pendingMasks
|
||
.map((mask) => {
|
||
const frame = frameById.get(mask.frameId);
|
||
const payload = frame ? buildAnnotationPayload(currentProject.id, mask, frame, activeTemplateId) : null;
|
||
return payload ? { maskId: mask.id, payload } : null;
|
||
})
|
||
.filter((item): item is NonNullable<typeof item> => Boolean(item));
|
||
|
||
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<typeof item> => Boolean(item));
|
||
|
||
if (createItems.length === 0 && updatePayloads.length === 0) {
|
||
setStatusMessage('没有可保存的标注数据');
|
||
return 0;
|
||
}
|
||
|
||
await Promise.all([
|
||
...createItems.map(({ payload }) => saveAnnotation(payload)),
|
||
...updatePayloads.map(({ annotationId, payload }) => updateAnnotation(annotationId, payload)),
|
||
]);
|
||
await hydrateSavedAnnotations(
|
||
currentProject.id,
|
||
frames,
|
||
useStore.getState().selectedMaskIds,
|
||
createItems.map(({ maskId }) => maskId),
|
||
);
|
||
const savedCount = createItems.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 handleClearFrameRangeMasks = useCallback(async () => {
|
||
if (frames.length === 0) return;
|
||
const clampRangeFrameNumber = (value: number) => {
|
||
if (totalFrames <= 0) return 1;
|
||
return Math.min(Math.max(value, 1), totalFrames);
|
||
};
|
||
const startFrameNumber = clampRangeFrameNumber(propagationStartFrame);
|
||
const endFrameNumber = clampRangeFrameNumber(propagationEndFrame);
|
||
const rangeStartIndex = Math.min(startFrameNumber, endFrameNumber) - 1;
|
||
const rangeEndIndex = Math.max(startFrameNumber, endFrameNumber) - 1;
|
||
const frameIdsToClear = new Set(
|
||
frames.slice(rangeStartIndex, rangeEndIndex + 1).map((frame) => String(frame.id)),
|
||
);
|
||
const rangeMasks = masks.filter((mask) => frameIdsToClear.has(String(mask.frameId)));
|
||
if (rangeMasks.length === 0) {
|
||
setStatusMessage(`第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧没有可清空的遮罩`);
|
||
return;
|
||
}
|
||
const annotationIds = Array.from(new Set(
|
||
rangeMasks
|
||
.map((mask) => mask.annotationId)
|
||
.filter((annotationId): annotationId is string => Boolean(annotationId)),
|
||
));
|
||
|
||
setIsSaving(true);
|
||
setStatusMessage(annotationIds.length > 0
|
||
? `正在删除第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的已保存标注...`
|
||
: `正在清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的本地遮罩...`);
|
||
try {
|
||
await Promise.all(annotationIds.map((annotationId) => deleteAnnotation(annotationId)));
|
||
const latestMasks = useStore.getState().masks;
|
||
const clearedMaskIds = new Set(
|
||
latestMasks
|
||
.filter((mask) => frameIdsToClear.has(String(mask.frameId)))
|
||
.map((mask) => mask.id),
|
||
);
|
||
setMasks(latestMasks.filter((mask) => !frameIdsToClear.has(String(mask.frameId))));
|
||
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !clearedMaskIds.has(id)));
|
||
setStatusMessage(`已清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的 ${rangeMasks.length} 个遮罩,其中后端标注 ${annotationIds.length} 个`);
|
||
} catch (err) {
|
||
console.error('Delete range annotations failed:', err);
|
||
setStatusMessage('批量清空失败,请检查后端服务');
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}, [frames, masks, propagationEndFrame, propagationStartFrame, setMasks, setSelectedMaskIds, totalFrames]);
|
||
|
||
const handleDeleteMaskAnnotations = useCallback(async (annotationIds: string[]) => {
|
||
if (annotationIds.length === 0) return;
|
||
try {
|
||
await Promise.all(annotationIds.map((annotationId) => deleteAnnotation(annotationId)));
|
||
setStatusMessage(`已删除 ${annotationIds.length} 个被合并标注`);
|
||
} catch (err) {
|
||
console.error('Delete merged annotations failed:', err);
|
||
setStatusMessage('合并后删除原标注失败,请检查后端服务');
|
||
throw err;
|
||
}
|
||
}, []);
|
||
|
||
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);
|
||
}
|
||
};
|
||
|
||
const downloadBlob = (blob: Blob, filename: string) => {
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = filename;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
const handleExportMasks = async () => {
|
||
if (!currentProject?.id) return;
|
||
setIsExporting(true);
|
||
setStatusMessage('正在准备导出语义 Mask ZIP...');
|
||
try {
|
||
await savePendingAnnotations({ silent: true });
|
||
const blob = await exportMasks(currentProject.id);
|
||
downloadBlob(blob, `project_${currentProject.id}_masks.zip`);
|
||
setStatusMessage('PNG Mask ZIP 已导出');
|
||
} catch (err) {
|
||
console.error('Mask export failed:', err);
|
||
setStatusMessage('Mask 导出失败,请检查后端服务');
|
||
} finally {
|
||
setIsExporting(false);
|
||
}
|
||
};
|
||
|
||
const handleImportGtMask = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
if (!file || !currentProject?.id || !currentFrame?.id) return;
|
||
setIsImportingGt(true);
|
||
setStatusMessage('正在导入 GT Mask...');
|
||
try {
|
||
const imported = await importGtMask(file, currentProject.id, currentFrame.id);
|
||
await hydrateSavedAnnotations(currentProject.id, frames);
|
||
setStatusMessage(`已导入 ${imported.length} 个 GT 区域`);
|
||
} catch (err) {
|
||
console.error('GT mask import failed:', err);
|
||
setStatusMessage('GT Mask 导入失败,请检查文件或后端服务');
|
||
} finally {
|
||
setIsImportingGt(false);
|
||
event.target.value = '';
|
||
}
|
||
};
|
||
|
||
const clampFrameNumber = useCallback((value: number) => {
|
||
if (totalFrames <= 0) return 1;
|
||
return Math.min(Math.max(value, 1), totalFrames);
|
||
}, [totalFrames]);
|
||
|
||
const buildSeedPayload = useCallback((seedMask: Mask) => {
|
||
if (!currentProject?.id || !currentFrame) return null;
|
||
const seedPayload = buildAnnotationPayload(currentProject.id, seedMask, currentFrame, activeTemplateId);
|
||
if (!seedPayload?.mask_data?.polygons?.length && !seedPayload?.bbox) {
|
||
return null;
|
||
}
|
||
const sourceAnnotationId = seedMask.annotationId && /^\d+$/.test(seedMask.annotationId)
|
||
? Number(seedMask.annotationId)
|
||
: undefined;
|
||
return {
|
||
polygons: seedPayload.mask_data?.polygons,
|
||
bbox: seedPayload.bbox,
|
||
points: seedPayload.points,
|
||
label: seedPayload.mask_data?.label,
|
||
color: seedPayload.mask_data?.color,
|
||
class_metadata: seedPayload.mask_data?.class,
|
||
template_id: seedPayload.template_id,
|
||
source_mask_id: seedMask.id,
|
||
source_annotation_id: sourceAnnotationId,
|
||
};
|
||
}, [activeTemplateId, currentFrame, currentProject?.id]);
|
||
|
||
const handlePropagationRangeChange = useCallback((startFrame: number, endFrame: number) => {
|
||
const nextStart = clampFrameNumber(startFrame);
|
||
const nextEnd = clampFrameNumber(endFrame);
|
||
setPropagationStartFrame(nextStart);
|
||
setPropagationEndFrame(nextEnd);
|
||
setHasExplicitPropagationRange(true);
|
||
setStatusMessage(`已选择自动传播范围:第 ${Math.min(nextStart, nextEnd)}-${Math.max(nextStart, nextEnd)} 帧`);
|
||
}, [clampFrameNumber]);
|
||
|
||
const handlePropagationStartInput = (value: number) => {
|
||
setPropagationStartFrame(clampFrameNumber(value || 1));
|
||
setHasExplicitPropagationRange(true);
|
||
};
|
||
|
||
const handlePropagationEndInput = (value: number) => {
|
||
setPropagationEndFrame(clampFrameNumber(value || 1));
|
||
setHasExplicitPropagationRange(true);
|
||
};
|
||
|
||
const runAutoPropagate = async () => {
|
||
if (!currentProject?.id || !currentFrame?.id) return;
|
||
const initialSeedMasks = masks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
|
||
if (initialSeedMasks.length === 0) {
|
||
setStatusMessage('请先在当前参考帧创建或保存至少一个 mask');
|
||
return;
|
||
}
|
||
|
||
const hasUnstableSeedMasks = initialSeedMasks.some((mask) => !mask.annotationId || mask.saveStatus === 'dirty');
|
||
if (hasUnstableSeedMasks) {
|
||
setStatusMessage('正在先保存参考帧 mask,确保二次传播可以替换旧结果...');
|
||
await savePendingAnnotations({ silent: true });
|
||
}
|
||
|
||
const seedMasks = useStore.getState().masks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
|
||
if (seedMasks.length === 0) {
|
||
setStatusMessage('参考帧 mask 保存后未能回显,请先检查归档保存是否成功');
|
||
return;
|
||
}
|
||
|
||
const startFrameNumber = clampFrameNumber(propagationStartFrame);
|
||
const endFrameNumber = clampFrameNumber(propagationEndFrame);
|
||
const rangeStartIndex = Math.min(startFrameNumber, endFrameNumber) - 1;
|
||
const rangeEndIndex = Math.max(startFrameNumber, endFrameNumber) - 1;
|
||
const propagationDirections: Array<{ direction: PropagationDirection; maxFrames: number }> = [];
|
||
if (rangeStartIndex < currentFrameIndex) {
|
||
propagationDirections.push({
|
||
direction: 'backward',
|
||
maxFrames: currentFrameIndex - rangeStartIndex + 1,
|
||
});
|
||
}
|
||
if (rangeEndIndex > currentFrameIndex) {
|
||
propagationDirections.push({
|
||
direction: 'forward',
|
||
maxFrames: rangeEndIndex - currentFrameIndex + 1,
|
||
});
|
||
}
|
||
if (propagationDirections.length === 0) {
|
||
setStatusMessage('传播范围只包含当前帧,请选择前后至少一帧');
|
||
return;
|
||
}
|
||
|
||
const seeds = seedMasks
|
||
.map((mask) => ({ mask, seed: buildSeedPayload(mask) }))
|
||
.filter((item): item is { mask: Mask; seed: NonNullable<ReturnType<typeof buildSeedPayload>> } => Boolean(item.seed));
|
||
if (seeds.length === 0) {
|
||
setStatusMessage('所选区域缺少可传播的 polygon 或 bbox');
|
||
return;
|
||
}
|
||
|
||
setIsPropagationRangeSelecting(false);
|
||
setIsPropagating(true);
|
||
const totalSteps = seeds.length * propagationDirections.length;
|
||
setPropagationProgress({
|
||
currentStep: 0,
|
||
completedSteps: 0,
|
||
totalSteps,
|
||
processedCount: 0,
|
||
createdCount: 0,
|
||
label: '准备传播',
|
||
});
|
||
setStatusMessage(`${propagationWeightLabel} 权重正在以第 ${currentFrameNumber} 帧为参考,自动传播 ${seeds.length} 个 mask 到第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧...`);
|
||
try {
|
||
const steps = seeds.flatMap(({ seed }) => (
|
||
propagationDirections.map(({ direction, maxFrames }) => ({
|
||
seed,
|
||
direction,
|
||
max_frames: maxFrames,
|
||
}))
|
||
));
|
||
const task = await queuePropagationTask({
|
||
project_id: Number(currentProject.id),
|
||
frame_id: Number(currentFrame.id),
|
||
model: propagationWeight,
|
||
steps,
|
||
include_source: false,
|
||
save_annotations: true,
|
||
});
|
||
setPropagationTaskId(task.id);
|
||
setStatusMessage(`自动传播任务已入队 #${task.id},可在 Dashboard 查看进度`);
|
||
|
||
let currentTask = task;
|
||
while (!['success', 'failed', 'cancelled'].includes(currentTask.status)) {
|
||
await new Promise((resolve) => setTimeout(resolve, PROPAGATION_POLL_INTERVAL_MS));
|
||
currentTask = await getTask(task.id);
|
||
const result = currentTask.result || {};
|
||
const completedSteps = Number(result.completed_steps || 0);
|
||
const processedCount = Number(result.processed_frame_count || 0);
|
||
const createdCount = Number(result.created_annotation_count || 0);
|
||
setPropagationProgress({
|
||
currentStep: Math.min(completedSteps + 1, totalSteps),
|
||
completedSteps,
|
||
totalSteps,
|
||
processedCount,
|
||
createdCount,
|
||
label: currentTask.message || `自动传播任务 #${task.id}`,
|
||
});
|
||
setStatusMessage(currentTask.message || `自动传播任务 #${task.id} 运行中...`);
|
||
if (createdCount > 0) {
|
||
await hydrateSavedAnnotations(currentProject.id, frames);
|
||
}
|
||
}
|
||
|
||
const result = currentTask.result || {};
|
||
const createdCount = Number(result.created_annotation_count || 0);
|
||
const processedCount = Number(result.processed_frame_count || 0);
|
||
const skippedCount = Number(result.skipped_seed_count || 0);
|
||
const deletedCount = Number(result.deleted_annotation_count || 0);
|
||
await hydrateSavedAnnotations(currentProject.id, frames);
|
||
if (currentTask.status === 'failed') {
|
||
setStatusMessage(currentTask.error ? `传播失败:${currentTask.error}` : '传播失败,请检查权重状态或后端日志');
|
||
return;
|
||
}
|
||
if (currentTask.status === 'cancelled') {
|
||
setStatusMessage('自动传播任务已取消');
|
||
return;
|
||
}
|
||
if (processedCount > 0) {
|
||
setPropagationHistory((previous) => {
|
||
const nextColorIndex = previous.length > 0 ? previous[previous.length - 1].colorIndex + 1 : 0;
|
||
return [
|
||
...previous,
|
||
{
|
||
id: `propagation-${Date.now()}-${rangeStartIndex + 1}-${rangeEndIndex + 1}`,
|
||
startFrame: rangeStartIndex + 1,
|
||
endFrame: rangeEndIndex + 1,
|
||
colorIndex: nextColorIndex,
|
||
label: `${propagationWeightLabel} 自动传播:第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧`,
|
||
},
|
||
].slice(-8);
|
||
});
|
||
}
|
||
setStatusMessage(createdCount > 0
|
||
? `已自动传播 ${seeds.length} 个参考 mask,处理 ${processedCount} 帧次,删除旧区域 ${deletedCount} 个,保存 ${createdCount} 个区域`
|
||
: skippedCount > 0
|
||
? `自动传播已完成:${skippedCount} 个未改变 mask 已跳过,没有生成重复区域`
|
||
: `自动传播已完成,但没有生成新的 mask;请检查参考 mask、传播范围或 ${propagationWeightLabel} 权重状态`);
|
||
} catch (err) {
|
||
console.error('Propagation failed:', err);
|
||
const detail = (err as any)?.response?.data?.detail;
|
||
setStatusMessage(detail ? `传播失败:${detail}` : '传播失败,请检查权重状态或后端日志');
|
||
} finally {
|
||
setIsPropagating(false);
|
||
setPropagationProgress(null);
|
||
setPropagationTaskId(null);
|
||
}
|
||
};
|
||
|
||
const handleAutoPropagate = async () => {
|
||
if (!hasExplicitPropagationRange && !isPropagationRangeSelecting) {
|
||
setIsPropagationRangeSelecting(true);
|
||
setStatusMessage('请在播放进度条或视频处理进度条上点击/拖拽选择传播起止帧,再点击“开始传播”');
|
||
return;
|
||
}
|
||
await runAutoPropagate();
|
||
};
|
||
|
||
const handleCancelPropagationRangeSelection = () => {
|
||
setIsPropagationRangeSelecting(false);
|
||
setHasExplicitPropagationRange(false);
|
||
setPropagationStartFrame(currentFrameNumber || 1);
|
||
setPropagationEndFrame(Math.min(Math.max(totalFrames, 1), (currentFrameNumber || 1) + 29));
|
||
setStatusMessage('已取消自动传播范围选择');
|
||
};
|
||
|
||
const handleCancelPropagation = async () => {
|
||
if (!propagationTaskId) return;
|
||
try {
|
||
await cancelTask(propagationTaskId);
|
||
setStatusMessage(`正在取消自动传播任务 #${propagationTaskId}...`);
|
||
} catch (err) {
|
||
console.error('Cancel propagation failed:', err);
|
||
setStatusMessage('取消自动传播失败,请稍后重试');
|
||
}
|
||
};
|
||
|
||
const propagationPercent = propagationProgress
|
||
? Math.round((propagationProgress.completedSteps / Math.max(propagationProgress.totalSteps, 1)) * 100)
|
||
: 0;
|
||
|
||
return (
|
||
<div className="w-full h-full flex flex-col bg-[#0a0a0a]">
|
||
{/* Top Header / Status bar */}
|
||
<div className="h-14 border-b border-white/5 bg-[#111] flex items-center justify-between px-6 shrink-0">
|
||
<div className="flex items-center gap-4">
|
||
<h2 className="text-xs font-semibold uppercase tracking-widest text-gray-400">核心分割工作区</h2>
|
||
<div className="h-4 w-px bg-white/10"></div>
|
||
<span className="text-sm text-white font-mono">{currentProject?.name || '未选择项目'}</span>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
{statusMessage && (
|
||
<span className="text-[10px] text-gray-500 font-mono max-w-48 truncate" title={statusMessage}>
|
||
{statusMessage}
|
||
</span>
|
||
)}
|
||
{propagationProgress && (
|
||
<div
|
||
className="w-56 rounded-md border border-blue-500/20 bg-blue-500/5 px-2 py-1"
|
||
aria-label="自动传播进度"
|
||
title={`已处理 ${propagationProgress.processedCount} 帧次,已保存 ${propagationProgress.createdCount} 个区域`}
|
||
>
|
||
<div className="mb-1 flex items-center justify-between gap-2 text-[10px] font-mono text-blue-200">
|
||
<span className="truncate">{propagationProgress.label}</span>
|
||
<span>{propagationPercent}%</span>
|
||
</div>
|
||
<div className="h-1.5 overflow-hidden rounded-full bg-zinc-700">
|
||
<div
|
||
className="h-full rounded-full bg-blue-400 transition-all"
|
||
style={{ width: `${propagationPercent}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-1 py-1">
|
||
<button
|
||
type="button"
|
||
onClick={undoMasks}
|
||
disabled={maskHistory.length === 0}
|
||
aria-label="撤销操作"
|
||
title="撤销操作 (Ctrl+Z)"
|
||
className="h-7 px-2 rounded text-gray-400 hover:bg-white/5 hover:text-white inline-flex items-center gap-1.5 text-xs transition-colors disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-gray-400 disabled:cursor-not-allowed"
|
||
>
|
||
<Undo size={14} />
|
||
撤销
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={redoMasks}
|
||
disabled={maskFuture.length === 0}
|
||
aria-label="重做操作"
|
||
title="重做操作 (Ctrl+Shift+Z / Ctrl+Y)"
|
||
className="h-7 px-2 rounded text-gray-400 hover:bg-white/5 hover:text-white inline-flex items-center gap-1.5 text-xs transition-colors disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-gray-400 disabled:cursor-not-allowed"
|
||
>
|
||
<Redo size={14} />
|
||
重做
|
||
</button>
|
||
</div>
|
||
<ModelStatusBadge compact />
|
||
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-2 py-1">
|
||
<span className="text-[10px] text-gray-500 whitespace-nowrap">传播权重</span>
|
||
<select
|
||
aria-label="传播权重"
|
||
value={propagationWeight}
|
||
onChange={(event) => {
|
||
setHasCustomPropagationWeight(true);
|
||
setPropagationWeight(event.target.value as AiModelId);
|
||
}}
|
||
disabled={isPropagating || isSaving || isExporting || isImportingGt}
|
||
className="h-6 w-24 rounded border border-cyan-500/20 bg-[#050809] px-1 text-[10px] text-cyan-100 outline-none focus:border-cyan-400/70 disabled:opacity-40"
|
||
>
|
||
{SAM2_MODEL_OPTIONS.map((option) => (
|
||
<option key={option.id} value={option.id} className="bg-[#050809] text-cyan-100">
|
||
{option.shortLabel}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<input
|
||
ref={gtMaskInputRef}
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/bmp,image/tiff"
|
||
className="hidden"
|
||
onChange={handleImportGtMask}
|
||
/>
|
||
<button
|
||
onClick={() => gtMaskInputRef.current?.click()}
|
||
disabled={!currentProject?.id || !currentFrame?.id || isImportingGt || isSaving || isExporting || isPropagating}
|
||
className="px-4 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-md text-xs transition-colors text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
{isImportingGt ? '导入中...' : '导入 GT Mask'}
|
||
</button>
|
||
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-2 py-1">
|
||
<span className="text-[10px] text-gray-500 whitespace-nowrap">参考帧 {currentFrameNumber || 0}</span>
|
||
<span className="text-[10px] text-gray-600">帧</span>
|
||
<input
|
||
aria-label="传播起始帧"
|
||
type="number"
|
||
min={1}
|
||
max={Math.max(totalFrames, 1)}
|
||
value={propagationStartFrame}
|
||
onChange={(event) => handlePropagationStartInput(Number(event.target.value))}
|
||
disabled={isPropagating || isSaving || isExporting || isImportingGt || totalFrames === 0}
|
||
className="h-6 w-14 rounded bg-black/20 border border-white/10 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40"
|
||
/>
|
||
<span className="text-[10px] text-gray-600">-</span>
|
||
<input
|
||
aria-label="传播结束帧"
|
||
type="number"
|
||
min={1}
|
||
max={Math.max(totalFrames, 1)}
|
||
value={propagationEndFrame}
|
||
onChange={(event) => handlePropagationEndInput(Number(event.target.value))}
|
||
disabled={isPropagating || isSaving || isExporting || isImportingGt || totalFrames === 0}
|
||
className="h-6 w-14 rounded bg-black/20 border border-white/10 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40"
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={handleClearFrameRangeMasks}
|
||
disabled={frames.length === 0 || isSaving || isExporting || isImportingGt || isPropagating}
|
||
title="按当前起止帧清空这一段视频内的全部遮罩"
|
||
className="px-3 py-1.5 bg-red-500/10 hover:bg-red-500/20 border border-red-500/25 rounded-md text-xs transition-colors text-red-200 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
清空片段遮罩
|
||
</button>
|
||
<button
|
||
onClick={handleAutoPropagate}
|
||
disabled={!currentProject?.id || !currentFrame?.id || isSaving || isExporting || isImportingGt || isPropagating}
|
||
className="px-4 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-md text-xs transition-colors text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
{isPropagating ? '传播中...' : isPropagationRangeSelecting ? '开始传播' : '自动传播'}
|
||
</button>
|
||
{isPropagationRangeSelecting && (
|
||
<button
|
||
onClick={handleCancelPropagationRangeSelection}
|
||
disabled={isPropagating}
|
||
className="px-3 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/25 rounded-md text-xs transition-colors text-amber-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
取消选区
|
||
</button>
|
||
)}
|
||
{propagationTaskId && (
|
||
<button
|
||
onClick={handleCancelPropagation}
|
||
className="px-3 py-1.5 bg-red-500/10 hover:bg-red-500/20 border border-red-500/20 rounded-md text-xs transition-colors text-red-200"
|
||
>
|
||
取消传播
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={handleExportMasks}
|
||
disabled={!currentProject?.id || isExporting || isSaving || isPropagating}
|
||
className="px-4 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-md text-xs transition-colors text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
{isExporting ? '导出中...' : '导出 PNG Mask ZIP'}
|
||
</button>
|
||
<button
|
||
onClick={handleExport}
|
||
disabled={!currentProject?.id || isExporting || isSaving || isPropagating}
|
||
className="px-4 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-md text-xs transition-colors text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
{isExporting ? '导出中...' : '导出 JSON 标注集'}
|
||
</button>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={!currentProject?.id || isSaving || isExporting || isPropagating}
|
||
className="px-4 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white text-xs font-medium rounded-md transition-shadow shadow-lg shadow-cyan-900/20 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
{isSaving ? '保存中...' : '结构化归档保存'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main Workspace Area */}
|
||
<div className="flex-1 flex overflow-hidden">
|
||
<ToolsPalette
|
||
activeTool={activeTool}
|
||
setActiveTool={setActiveTool}
|
||
onTriggerAI={onNavigateToAI}
|
||
onUndo={undoMasks}
|
||
onRedo={redoMasks}
|
||
canUndo={maskHistory.length > 0}
|
||
canRedo={maskFuture.length > 0}
|
||
/>
|
||
|
||
<div className="flex-1 relative flex items-center justify-center p-8 bg-[#151515] overflow-hidden">
|
||
<div className="relative w-full h-full bg-[#1e1e1e] border border-white/5 shadow-2xl rounded-sm">
|
||
<CanvasArea
|
||
activeTool={activeTool}
|
||
frame={currentFrame}
|
||
onClearMasks={handleClearCurrentFrameMasks}
|
||
onDeleteMaskAnnotations={handleDeleteMaskAnnotations}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<OntologyInspector />
|
||
</div>
|
||
|
||
{/* Bottom Timeline */}
|
||
<FrameTimeline
|
||
propagationRange={{
|
||
startFrame: propagationStartFrame,
|
||
endFrame: propagationEndFrame,
|
||
}}
|
||
propagationHistory={propagationHistory}
|
||
propagationRangeSelectionActive={isPropagationRangeSelecting}
|
||
propagationRangeDisabled={isPropagating || isSaving || isExporting || isImportingGt}
|
||
onPropagationRangeChange={handlePropagationRangeChange}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|