Files
Pre_Seg_Server/src/components/VideoWorkspace.tsx
admin 4899c8a08a feat: 完善分割工作区交互与传播去重
功能增加:点击 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,记录当前功能、接口契约、需求设计冻结和测试覆盖。
2026-05-02 06:45:47 +08:00

893 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}