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,记录当前功能、接口契约、需求设计冻结和测试覆盖。
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetStore } from '../test/storeTestUtils';
|
||||
import { useStore } from '../store/useStore';
|
||||
@@ -65,6 +65,31 @@ describe('CanvasArea', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('uses the workspace mask opacity setting for mask preview rendering', () => {
|
||||
useStore.setState({
|
||||
masks: [{
|
||||
id: 'm-opacity',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 0 0 L 10 0 L 10 10 Z',
|
||||
label: 'Mask',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[0, 0, 10, 0, 10, 10]],
|
||||
}],
|
||||
maskPreviewOpacity: 50,
|
||||
});
|
||||
|
||||
render(<CanvasArea activeTool="move" frame={frame} />);
|
||||
|
||||
const maskGroup = () => screen.getAllByTestId('konva-group').find((group) => group.getAttribute('data-opacity'));
|
||||
expect(maskGroup()).toHaveAttribute('data-opacity', '0.5');
|
||||
|
||||
act(() => {
|
||||
useStore.getState().setMaskPreviewOpacity(30);
|
||||
});
|
||||
|
||||
expect(maskGroup()).toHaveAttribute('data-opacity', '0.3');
|
||||
});
|
||||
|
||||
it('refines one SAM2 candidate mask from an initial box with positive and negative points', async () => {
|
||||
apiMock.predictMask
|
||||
.mockResolvedValueOnce({
|
||||
@@ -442,6 +467,41 @@ describe('CanvasArea', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not pan or recenter the stage when a polygon vertex drag ends', () => {
|
||||
useStore.setState({
|
||||
selectedMaskIds: ['draft-1'],
|
||||
masks: [
|
||||
{
|
||||
id: 'draft-1',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 10 10 L 90 10 L 90 40 Z',
|
||||
label: 'Draft',
|
||||
color: '#06b6d4',
|
||||
saveStatus: 'draft',
|
||||
segmentation: [[10, 10, 90, 10, 90, 40]],
|
||||
bbox: [10, 10, 80, 30],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<CanvasArea activeTool="edit_polygon" frame={frame} />);
|
||||
const stage = screen.getByTestId('konva-stage');
|
||||
const initialX = stage.getAttribute('data-x');
|
||||
const initialY = stage.getAttribute('data-y');
|
||||
const handles = screen.getAllByTestId('konva-circle')
|
||||
.filter((element) => element.getAttribute('data-fill') === '#ffffff');
|
||||
|
||||
fireEvent.mouseUp(handles[0], { clientX: 25, clientY: 35 });
|
||||
fireEvent.dragEnd(handles[0], { clientX: 25, clientY: 35 });
|
||||
|
||||
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
|
||||
pathData: 'M 25 35 L 90 10 L 90 40 Z',
|
||||
segmentation: [[25, 35, 90, 10, 90, 40]],
|
||||
}));
|
||||
expect(screen.getByTestId('konva-stage')).toHaveAttribute('data-x', initialX || '');
|
||||
expect(screen.getByTestId('konva-stage')).toHaveAttribute('data-y', initialY || '');
|
||||
});
|
||||
|
||||
it('deletes a selected polygon vertex without dropping below three points', () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
@@ -923,6 +983,20 @@ describe('CanvasArea', () => {
|
||||
expect(screen.getByText(/第一个是保留主区域/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('auto-hides contextual tool guidance after a few seconds', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
render(<CanvasArea activeTool="create_rectangle" frame={frame} />);
|
||||
expect(screen.getByText('创建矩形')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3600);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('创建矩形')).not.toBeInTheDocument();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('applies the selected class to current-frame masks and marks saved masks dirty', () => {
|
||||
useStore.setState({
|
||||
activeTemplateId: '2',
|
||||
|
||||
@@ -25,6 +25,7 @@ const POINT_TOOL = 'create_point';
|
||||
const BOOLEAN_TOOLS = new Set(['area_merge', 'area_remove']);
|
||||
const POLYGON_CLOSE_RADIUS = 8;
|
||||
const DEFAULT_IMAGE_FIT_RATIO = 0.86;
|
||||
const TOOL_HINT_TTL_MS = 3600;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
@@ -246,6 +247,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
const previousFrameIdRef = useRef<string | undefined>(frame?.id);
|
||||
const [isInferencing, setIsInferencing] = useState(false);
|
||||
const [inferenceMessage, setInferenceMessage] = useState('');
|
||||
const [isToolHintVisible, setIsToolHintVisible] = useState(false);
|
||||
const lastAutoFitKeyRef = useRef('');
|
||||
|
||||
const masks = useStore((state) => state.masks);
|
||||
@@ -254,6 +256,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
const clearMasks = useStore((state) => state.clearMasks);
|
||||
const setMasks = useStore((state) => state.setMasks);
|
||||
const setGlobalSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
|
||||
const maskPreviewOpacity = useStore((state) => state.maskPreviewOpacity);
|
||||
const storeActiveTool = useStore((state) => state.activeTool);
|
||||
const aiModel = useStore((state) => state.aiModel);
|
||||
const activeTemplateId = useStore((state) => state.activeTemplateId);
|
||||
@@ -359,6 +362,18 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
return null;
|
||||
}, [booleanSelectedMasks.length, effectiveTool, frame, polygonPoints.length, samPromptBox, selectedMask]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toolHint) {
|
||||
setIsToolHintVisible(false);
|
||||
return;
|
||||
}
|
||||
setIsToolHintVisible(true);
|
||||
const timer = window.setTimeout(() => {
|
||||
setIsToolHintVisible(false);
|
||||
}, TOOL_HINT_TTL_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [toolHint?.body, toolHint?.title]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (containerRef.current) {
|
||||
@@ -480,7 +495,8 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
};
|
||||
|
||||
const handleStageDragEnd = (e: any) => {
|
||||
const stage = e.target;
|
||||
const stage = e.target?.getStage?.();
|
||||
if (!stage || e.target !== stage) return;
|
||||
setPosition({
|
||||
x: stage.x(),
|
||||
y: stage.y(),
|
||||
@@ -1078,7 +1094,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
{inferenceMessage}
|
||||
</div>
|
||||
)}
|
||||
{toolHint && (
|
||||
{toolHint && isToolHintVisible && (
|
||||
<div className="absolute top-4 left-4 z-20 max-w-sm rounded-lg border border-cyan-400/20 bg-[#0d0d0d]/95 px-3 py-2 shadow-xl pointer-events-none">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-widest text-cyan-300">{toolHint.title}</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-gray-300">{toolHint.body}</div>
|
||||
@@ -1132,7 +1148,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
fillRule: undefined,
|
||||
}));
|
||||
return (
|
||||
<Group key={mask.id} opacity={isMaskSelected ? 0.65 : 0.5}>
|
||||
<Group key={mask.id} opacity={Math.min(1, Math.max(0.1, maskPreviewOpacity / 100 + (isMaskSelected ? 0.15 : 0)))}>
|
||||
{paths.map(({ data, polygonIndex, fillRule }) => (
|
||||
<Path
|
||||
key={`${mask.id}-polygon-${polygonIndex}`}
|
||||
|
||||
@@ -86,6 +86,33 @@ describe('FrameTimeline', () => {
|
||||
expect(screen.queryByLabelText('跳转到已编辑帧 3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders recent propagation history segments with distinct gradient colors', () => {
|
||||
useStore.setState({
|
||||
frames: [
|
||||
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
|
||||
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
|
||||
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
|
||||
{ id: 'f4', projectId: 'p1', index: 3, url: '/4.jpg', width: 640, height: 360 },
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<FrameTimeline
|
||||
propagationHistory={[
|
||||
{ id: 'history-1', startFrame: 1, endFrame: 2, colorIndex: 0, label: '第一次传播' },
|
||||
{ id: 'history-2', startFrame: 3, endFrame: 4, colorIndex: 1, label: '第二次传播' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const segments = screen.getAllByTestId('propagation-history-segment');
|
||||
expect(segments).toHaveLength(2);
|
||||
expect(segments[0]).toHaveAttribute('title', '第一次传播');
|
||||
expect(segments[0]).toHaveStyle({ left: '0%', width: '50%' });
|
||||
expect(segments[0].getAttribute('style')).toContain('linear-gradient');
|
||||
expect(segments[1].getAttribute('style')).toContain('124, 58, 237');
|
||||
});
|
||||
|
||||
it('jumps from the processing progress bar and frame status markers', () => {
|
||||
useStore.setState({
|
||||
frames: [
|
||||
@@ -152,6 +179,7 @@ describe('FrameTimeline', () => {
|
||||
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
|
||||
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
|
||||
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
|
||||
{ id: 'f4', projectId: 'p1', index: 3, url: '/4.jpg', width: 640, height: 360 },
|
||||
],
|
||||
masks: [
|
||||
{ id: 'm1', frameId: 'f2', pathData: 'M 0 0 Z', label: 'Draft', color: '#ef4444' },
|
||||
@@ -163,6 +191,15 @@ describe('FrameTimeline', () => {
|
||||
color: '#3b82f6',
|
||||
metadata: { propagated_from_frame_id: 'f1' },
|
||||
},
|
||||
{ id: 'm3', frameId: 'f4', pathData: 'M 1 1 Z', label: 'Manual', color: '#ef4444' },
|
||||
{
|
||||
id: 'm4',
|
||||
frameId: 'f4',
|
||||
pathData: 'M 2 2 Z',
|
||||
label: 'Tracked',
|
||||
color: '#3b82f6',
|
||||
metadata: { source: 'sam2.1_hiera_tiny_propagation' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -171,6 +208,9 @@ describe('FrameTimeline', () => {
|
||||
expect(screen.getByAltText('frame-0').closest('div')?.className).toContain('border-cyan-500');
|
||||
expect(screen.getByAltText('frame-1').closest('div')?.className).toContain('border-red-500');
|
||||
expect(screen.getByAltText('frame-2').closest('div')?.className).toContain('border-blue-500');
|
||||
const manuallyAdjustedPropagatedTile = screen.getByAltText('frame-3').closest('div');
|
||||
expect(manuallyAdjustedPropagatedTile?.className).toContain('border-red-500');
|
||||
expect(manuallyAdjustedPropagatedTile?.className).toContain('inset_0_0_0_2px_rgba(59,130,246,0.85)');
|
||||
});
|
||||
|
||||
it('keeps the current frame blue border while showing an inner red ring for annotated frames', () => {
|
||||
@@ -182,6 +222,14 @@ describe('FrameTimeline', () => {
|
||||
],
|
||||
masks: [
|
||||
{ id: 'm1', frameId: 'f2', pathData: 'M 0 0 Z', label: 'Draft', color: '#ef4444' },
|
||||
{
|
||||
id: 'm2',
|
||||
frameId: 'f2',
|
||||
pathData: 'M 1 1 Z',
|
||||
label: 'Tracked',
|
||||
color: '#3b82f6',
|
||||
metadata: { source: 'sam2.1_hiera_tiny_propagation' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@ interface FrameTimelineProps {
|
||||
startFrame: number;
|
||||
endFrame: number;
|
||||
};
|
||||
propagationHistory?: Array<{
|
||||
id: string;
|
||||
startFrame: number;
|
||||
endFrame: number;
|
||||
colorIndex: number;
|
||||
label?: string;
|
||||
}>;
|
||||
propagationRangeSelectionActive?: boolean;
|
||||
propagationRangeDisabled?: boolean;
|
||||
onPropagationRangeChange?: (startFrame: number, endFrame: number) => void;
|
||||
@@ -15,6 +22,7 @@ interface FrameTimelineProps {
|
||||
|
||||
export function FrameTimeline({
|
||||
propagationRange,
|
||||
propagationHistory = [],
|
||||
propagationRangeSelectionActive = false,
|
||||
propagationRangeDisabled = false,
|
||||
onPropagationRangeChange,
|
||||
@@ -97,6 +105,21 @@ export function FrameTimeline({
|
||||
const rangeWidth = visibleSelectedRange && totalFrames > 0
|
||||
? ((visibleSelectedRange.endFrame - visibleSelectedRange.startFrame + 1) / totalFrames) * 100
|
||||
: 0;
|
||||
const propagationHistoryColors = [
|
||||
{ dark: 'rgba(8, 145, 178, 0.68)', light: 'rgba(103, 232, 249, 0.9)', glow: 'rgba(34, 211, 238, 0.38)' },
|
||||
{ dark: 'rgba(124, 58, 237, 0.66)', light: 'rgba(196, 181, 253, 0.9)', glow: 'rgba(167, 139, 250, 0.34)' },
|
||||
{ dark: 'rgba(5, 150, 105, 0.66)', light: 'rgba(110, 231, 183, 0.9)', glow: 'rgba(52, 211, 153, 0.34)' },
|
||||
{ dark: 'rgba(217, 119, 6, 0.66)', light: 'rgba(253, 186, 116, 0.9)', glow: 'rgba(251, 146, 60, 0.34)' },
|
||||
{ dark: 'rgba(219, 39, 119, 0.66)', light: 'rgba(251, 113, 133, 0.9)', glow: 'rgba(244, 114, 182, 0.34)' },
|
||||
];
|
||||
const visiblePropagationHistory = useMemo(() => (
|
||||
propagationHistory
|
||||
.map((segment, order) => {
|
||||
const range = normalizeRange(segment.startFrame, segment.endFrame);
|
||||
return { ...segment, ...range, order };
|
||||
})
|
||||
.filter((segment) => totalFrames > 0 && segment.endFrame >= 1 && segment.startFrame <= totalFrames)
|
||||
), [propagationHistory, totalFrames]);
|
||||
|
||||
const frameFromPointerEvent = (event: React.PointerEvent<HTMLElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
@@ -297,6 +320,27 @@ export function FrameTimeline({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{visiblePropagationHistory.map((segment) => {
|
||||
const color = propagationHistoryColors[segment.colorIndex % propagationHistoryColors.length];
|
||||
const left = totalFrames > 0 ? ((segment.startFrame - 1) / totalFrames) * 100 : 0;
|
||||
const width = totalFrames > 0 ? ((segment.endFrame - segment.startFrame + 1) / totalFrames) * 100 : 0;
|
||||
const opacity = Math.max(0.48, 0.92 - (visiblePropagationHistory.length - 1 - segment.order) * 0.12);
|
||||
return (
|
||||
<div
|
||||
key={segment.id}
|
||||
data-testid="propagation-history-segment"
|
||||
title={segment.label || `自动传播记录:第 ${segment.startFrame}-${segment.endFrame} 帧`}
|
||||
className="pointer-events-none absolute inset-y-0 z-[15] rounded-[2px] border-x border-white/25"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
opacity,
|
||||
background: `linear-gradient(to right, ${color.dark}, ${color.light})`,
|
||||
boxShadow: `0 0 10px ${color.glow}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{annotatedFrameMarkers.map(({ frame, index }) => {
|
||||
const left = totalFrames > 1 ? (index / Math.max(totalFrames - 1, 1)) * 100 : 0;
|
||||
return (
|
||||
@@ -359,10 +403,10 @@ export function FrameTimeline({
|
||||
key={frame.id}
|
||||
onClick={() => setCurrentFrame(idx)}
|
||||
title={
|
||||
isPropagatedFrame
|
||||
? `自动传播帧 ${idx + 1}`
|
||||
: isAnnotatedFrame
|
||||
? `人工/AI 标注帧 ${idx + 1}`
|
||||
isAnnotatedFrame
|
||||
? `人工/AI 标注帧 ${idx + 1}`
|
||||
: isPropagatedFrame
|
||||
? `自动传播帧 ${idx + 1}`
|
||||
: `视频帧 ${idx + 1}`
|
||||
}
|
||||
className={cn(
|
||||
@@ -372,12 +416,19 @@ export function FrameTimeline({
|
||||
"w-28 h-16 border-2 border-cyan-500 bg-gray-700 z-10",
|
||||
isAnnotatedFrame
|
||||
? "shadow-[inset_0_0_0_2px_rgba(239,68,68,0.95),0_0_15px_rgba(6,182,212,0.3)]"
|
||||
: "shadow-[0_0_15px_rgba(6,182,212,0.3)]",
|
||||
: isPropagatedFrame
|
||||
? "shadow-[inset_0_0_0_2px_rgba(59,130,246,0.65),0_0_15px_rgba(6,182,212,0.3)]"
|
||||
: "shadow-[0_0_15px_rgba(6,182,212,0.3)]",
|
||||
)
|
||||
: isPropagatedFrame
|
||||
? "w-16 h-12 border border-blue-500 bg-blue-950/30 opacity-80 shadow-[0_0_10px_rgba(59,130,246,0.22)] hover:opacity-100"
|
||||
: isAnnotatedFrame
|
||||
? "w-16 h-12 border border-red-500 bg-red-950/30 opacity-85 shadow-[0_0_10px_rgba(239,68,68,0.22)] hover:opacity-100"
|
||||
: isAnnotatedFrame
|
||||
? cn(
|
||||
"w-16 h-12 border border-red-500 bg-red-950/30 opacity-85 hover:opacity-100",
|
||||
isPropagatedFrame
|
||||
? "shadow-[inset_0_0_0_2px_rgba(59,130,246,0.85),0_0_10px_rgba(239,68,68,0.22)]"
|
||||
: "shadow-[0_0_10px_rgba(239,68,68,0.22)]",
|
||||
)
|
||||
: isPropagatedFrame
|
||||
? "w-16 h-12 border border-blue-500 bg-blue-950/30 opacity-80 shadow-[0_0_10px_rgba(59,130,246,0.22)] hover:opacity-100"
|
||||
: "w-16 h-12 border border-white/5 bg-gray-800/50 opacity-40 hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetStore } from '../test/storeTestUtils';
|
||||
import { useStore } from '../store/useStore';
|
||||
@@ -44,15 +44,52 @@ describe('OntologyInspector', () => {
|
||||
});
|
||||
|
||||
it('shows template classes and changes the active template', () => {
|
||||
render(<OntologyInspector />);
|
||||
const { container } = render(<OntologyInspector />);
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 't1' } });
|
||||
const templateSelect = screen.getByRole('combobox');
|
||||
expect(container.querySelector('.seg-scrollbar')).toBeInTheDocument();
|
||||
expect(screen.queryByText('本体论与属性分类管理树')).not.toBeInTheDocument();
|
||||
fireEvent.change(templateSelect, { target: { value: 't1' } });
|
||||
|
||||
expect(useStore.getState().activeTemplateId).toBe('t1');
|
||||
expect(screen.getByText('胆囊')).toBeInTheDocument();
|
||||
expect(screen.getByText('肝脏')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adjusts workspace mask opacity from above the semantic tree', () => {
|
||||
render(<OntologyInspector />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('遮罩透明度'), { target: { value: '35' } });
|
||||
|
||||
expect(useStore.getState().maskPreviewOpacity).toBe(35);
|
||||
expect(screen.getByText('35%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('focuses the matching semantic class when a mask is selected', async () => {
|
||||
if (!HTMLElement.prototype.scrollIntoView) {
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
}
|
||||
useStore.setState({
|
||||
masks: [{
|
||||
id: 'm1',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: '肝脏',
|
||||
color: '#00ff00',
|
||||
classId: 'c2',
|
||||
className: '肝脏',
|
||||
}],
|
||||
selectedMaskIds: ['m1'],
|
||||
});
|
||||
|
||||
render(<OntologyInspector />);
|
||||
|
||||
const liverButton = screen.getByRole('button', { name: /肝脏/ });
|
||||
await waitFor(() => expect(useStore.getState().activeClassId).toBe('c2'));
|
||||
expect(liverButton).toHaveAttribute('aria-current', 'true');
|
||||
expect(document.activeElement).toBe(liverButton);
|
||||
});
|
||||
|
||||
it('selects a concrete class for subsequent masks', () => {
|
||||
render(<OntologyInspector />);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Layers, ChevronDown, Tag, Eye, Plus, X, Loader2 } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChevronDown, Tag, Eye, Plus, X, Loader2 } from 'lucide-react';
|
||||
import { useStore } from '../store/useStore';
|
||||
import type { TemplateClass } from '../store/useStore';
|
||||
import { cn } from '../lib/utils';
|
||||
@@ -15,10 +15,12 @@ export function OntologyInspector() {
|
||||
const currentFrameIndex = useStore((state) => state.currentFrameIndex);
|
||||
const masks = useStore((state) => state.masks);
|
||||
const selectedMaskIds = useStore((state) => state.selectedMaskIds);
|
||||
const maskPreviewOpacity = useStore((state) => state.maskPreviewOpacity);
|
||||
const setMasks = useStore((state) => state.setMasks);
|
||||
const updateTemplateStore = useStore((state) => state.updateTemplate);
|
||||
const setActiveTemplateId = useStore((state) => state.setActiveTemplateId);
|
||||
const setActiveClass = useStore((state) => state.setActiveClass);
|
||||
const setMaskPreviewOpacity = useStore((state) => state.setMaskPreviewOpacity);
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newClassName, setNewClassName] = useState('');
|
||||
@@ -34,6 +36,33 @@ export function OntologyInspector() {
|
||||
const allClasses = [...templateClasses].sort((a, b) => b.zIndex - a.zIndex);
|
||||
const selectedMask = masks.find((mask) => selectedMaskIds.includes(mask.id)) || null;
|
||||
const currentFrame = frames[currentFrameIndex] || null;
|
||||
const classButtonRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
|
||||
const selectedMaskClass = useMemo(() => {
|
||||
if (!selectedMask) return null;
|
||||
const allTemplateClasses = templates.flatMap((template) => (
|
||||
template.classes.map((templateClass) => ({ template, templateClass }))
|
||||
));
|
||||
const selectedName = selectedMask.className || selectedMask.label;
|
||||
return allTemplateClasses.find(({ templateClass }) => selectedMask.classId && templateClass.id === selectedMask.classId)
|
||||
|| allTemplateClasses.find(({ templateClass }) => templateClass.name === selectedName && templateClass.color === selectedMask.color)
|
||||
|| allTemplateClasses.find(({ templateClass }) => templateClass.name === selectedName)
|
||||
|| null;
|
||||
}, [selectedMask?.classId, selectedMask?.className, selectedMask?.color, selectedMask?.id, selectedMask?.label, templates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedMaskClass) return;
|
||||
if (activeTemplateId !== selectedMaskClass.template.id) {
|
||||
setActiveTemplateId(selectedMaskClass.template.id);
|
||||
}
|
||||
setActiveClass(selectedMaskClass.templateClass);
|
||||
const timer = window.setTimeout(() => {
|
||||
const node = classButtonRefs.current.get(selectedMaskClass.templateClass.id);
|
||||
node?.scrollIntoView?.({ block: 'nearest' });
|
||||
node?.focus?.({ preventScroll: true });
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [activeTemplateId, selectedMaskClass, setActiveClass, setActiveTemplateId]);
|
||||
|
||||
const handleSelectClass = (templateClass: TemplateClass) => {
|
||||
if (activeTemplate && !activeTemplateId) {
|
||||
@@ -134,12 +163,7 @@ export function OntologyInspector() {
|
||||
|
||||
return (
|
||||
<div className="w-60 bg-[#0d0d0d] flex flex-col border-l border-white/5 shrink-0 z-10 overflow-hidden">
|
||||
<div className="h-14 border-b border-white/5 flex items-center px-4 shrink-0 font-medium text-[10px] uppercase tracking-widest text-gray-500">
|
||||
<Layers size={14} className="mr-2 text-gray-400" />
|
||||
本体论与属性分类管理树
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-6">
|
||||
<div className="flex-1 overflow-y-auto seg-scrollbar p-4 flex flex-col gap-6">
|
||||
{/* Template Selector */}
|
||||
<div>
|
||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">当前激活模板</h3>
|
||||
@@ -166,6 +190,27 @@ export function OntologyInspector() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workspace Mask Opacity */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label htmlFor="workspace-mask-opacity" className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">
|
||||
遮罩透明度
|
||||
</label>
|
||||
<span className="text-[10px] font-mono text-cyan-400">{maskPreviewOpacity}%</span>
|
||||
</div>
|
||||
<input
|
||||
id="workspace-mask-opacity"
|
||||
aria-label="遮罩透明度"
|
||||
type="range"
|
||||
min={10}
|
||||
max={100}
|
||||
step={5}
|
||||
value={maskPreviewOpacity}
|
||||
onChange={(event) => setMaskPreviewOpacity(Number(event.target.value))}
|
||||
className="w-full accent-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Semantic Classification Tree */}
|
||||
<div>
|
||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3 flex justify-between items-center">
|
||||
@@ -176,7 +221,15 @@ export function OntologyInspector() {
|
||||
<div key={cls.id} className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
classButtonRefs.current.set(cls.id, node);
|
||||
} else {
|
||||
classButtonRefs.current.delete(cls.id);
|
||||
}
|
||||
}}
|
||||
onClick={() => handleSelectClass(cls)}
|
||||
aria-current={activeClassId === cls.id ? 'true' : undefined}
|
||||
className={cn(
|
||||
'flex items-center justify-between p-2 rounded bg-white/5 hover:bg-white/10 cursor-pointer group transition-colors text-left border',
|
||||
activeClassId === cls.id ? 'border-cyan-500/50 bg-cyan-500/10' : 'border-transparent',
|
||||
|
||||
@@ -47,7 +47,10 @@ describe('ToolsPalette', () => {
|
||||
const { container } = render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} />);
|
||||
const palette = container.firstElementChild;
|
||||
|
||||
expect(palette).toHaveClass('w-14');
|
||||
expect(palette).toHaveClass('overflow-y-auto');
|
||||
expect(palette).toHaveClass('seg-scrollbar');
|
||||
expect(palette?.firstElementChild).toHaveClass('w-12');
|
||||
expect(screen.getByTitle('创建多边形 (P)')).toHaveClass('h-9');
|
||||
expect(screen.getByTitle('打开 AI 智能分割')).toHaveClass('h-9');
|
||||
});
|
||||
|
||||
@@ -40,8 +40,8 @@ export function ToolsPalette({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full w-12 bg-[#0d0d0d] border-r border-white/5 flex flex-col items-center py-2 shrink-0 z-10 overflow-y-auto overflow-x-hidden overscroll-contain">
|
||||
<div className="flex flex-col gap-1.5 w-full px-1.5">
|
||||
<div className="h-full w-14 bg-[#0d0d0d] border-r border-white/5 flex flex-col items-start py-2 shrink-0 z-10 overflow-y-auto overflow-x-hidden overscroll-contain seg-scrollbar">
|
||||
<div className="flex flex-col gap-1.5 w-12 shrink-0 px-1.5">
|
||||
{tools.map(tool => {
|
||||
const Icon = tool.icon;
|
||||
const isActive = activeTool === tool.id;
|
||||
|
||||
@@ -22,6 +22,7 @@ const apiMock = vi.hoisted(() => ({
|
||||
annotationToMask: vi.fn(),
|
||||
buildAnnotationPayload: vi.fn(),
|
||||
getAiModelStatus: vi.fn(),
|
||||
analyzeMask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
@@ -42,6 +43,7 @@ vi.mock('../lib/api', () => ({
|
||||
annotationToMask: apiMock.annotationToMask,
|
||||
buildAnnotationPayload: apiMock.buildAnnotationPayload,
|
||||
getAiModelStatus: apiMock.getAiModelStatus,
|
||||
analyzeMask: apiMock.analyzeMask,
|
||||
}));
|
||||
|
||||
describe('VideoWorkspace', () => {
|
||||
@@ -76,6 +78,16 @@ describe('VideoWorkspace', () => {
|
||||
{ id: 'sam2.1_hiera_tiny', label: 'SAM 2.1 Tiny', available: true, loaded: false, device: 'cpu', supports: [], message: 'ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
|
||||
],
|
||||
});
|
||||
apiMock.analyzeMask.mockResolvedValue({
|
||||
confidence: 0.7,
|
||||
confidence_source: 'model_score',
|
||||
topology_anchor_count: 0,
|
||||
topology_anchors: [],
|
||||
area: 0.1,
|
||||
bbox: [0, 0, 0.1, 0.1],
|
||||
source: 'test',
|
||||
message: 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
it('loads project frames into the workspace store', async () => {
|
||||
@@ -357,6 +369,38 @@ describe('VideoWorkspace', () => {
|
||||
expect(useStore.getState().masks).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears masks across the selected frame range', async () => {
|
||||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
|
||||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||||
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
|
||||
]);
|
||||
apiMock.deleteAnnotation.mockResolvedValue(undefined);
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
|
||||
act(() => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{ id: 'annotation-99', annotationId: '99', frameId: '10', pathData: 'M 0 0 Z', label: 'Saved 1', color: '#06b6d4', saved: true, saveStatus: 'saved' },
|
||||
{ id: 'draft-1', frameId: '11', pathData: 'M 1 1 Z', label: 'Draft', color: '#ff0000' },
|
||||
{ id: 'annotation-100', annotationId: '100', frameId: '12', pathData: 'M 2 2 Z', label: 'Saved 2', color: '#00ff00', saved: true, saveStatus: 'saved' },
|
||||
],
|
||||
selectedMaskIds: ['draft-1', 'annotation-100'],
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '1' } });
|
||||
fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '2' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
|
||||
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('100');
|
||||
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['annotation-100']);
|
||||
expect(useStore.getState().selectedMaskIds).not.toContain('draft-1');
|
||||
expect(screen.getByText('已清空第 1-2 帧的 2 个遮罩,其中后端标注 1 个')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('auto-saves pending masks before exporting COCO', async () => {
|
||||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||||
@@ -451,7 +495,7 @@ describe('VideoWorkspace', () => {
|
||||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||||
]);
|
||||
apiMock.buildAnnotationPayload.mockReturnValueOnce({
|
||||
const seedPayload = {
|
||||
project_id: 1,
|
||||
frame_id: 10,
|
||||
template_id: 2,
|
||||
@@ -462,6 +506,23 @@ describe('VideoWorkspace', () => {
|
||||
class: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 },
|
||||
},
|
||||
bbox: [0.1, 0.1, 0.2, 0.2],
|
||||
};
|
||||
apiMock.getProjectAnnotations
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValue([{ id: 5, frame_id: 10 }]);
|
||||
apiMock.buildAnnotationPayload.mockReturnValue(seedPayload);
|
||||
apiMock.saveAnnotation.mockResolvedValueOnce({ id: 5 });
|
||||
apiMock.annotationToMask.mockReturnValue({
|
||||
id: 'annotation-5',
|
||||
annotationId: '5',
|
||||
frameId: '10',
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
segmentation: [[64, 36, 192, 36, 192, 108]],
|
||||
bbox: [64, 36, 128, 72],
|
||||
});
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
@@ -486,6 +547,7 @@ describe('VideoWorkspace', () => {
|
||||
expect(apiMock.queuePropagationTask).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.saveAnnotation).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledWith({
|
||||
project_id: 1,
|
||||
frame_id: 10,
|
||||
@@ -503,12 +565,13 @@ describe('VideoWorkspace', () => {
|
||||
color: '#ff0000',
|
||||
class_metadata: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 },
|
||||
template_id: 2,
|
||||
source_mask_id: 'mask-1',
|
||||
source_annotation_id: undefined,
|
||||
source_mask_id: 'annotation-5',
|
||||
source_annotation_id: 5,
|
||||
},
|
||||
}],
|
||||
}));
|
||||
await waitFor(() => expect(screen.getByText('已自动传播 1 个参考 mask,处理 3 帧次,删除旧区域 0 个,保存 2 个区域')).toBeInTheDocument());
|
||||
expect(screen.getByTestId('propagation-history-segment')).toHaveAttribute('title', 'SAM 2.1 Tiny 自动传播:第 1-2 帧');
|
||||
});
|
||||
|
||||
it('uses the separately selected propagation weight when queueing propagation', async () => {
|
||||
@@ -533,7 +596,8 @@ describe('VideoWorkspace', () => {
|
||||
useStore.setState({
|
||||
aiModel: 'sam2.1_hiera_tiny',
|
||||
masks: [{
|
||||
id: 'mask-propagation-model',
|
||||
id: 'annotation-6',
|
||||
annotationId: '6',
|
||||
frameId: '10',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: '胆囊',
|
||||
@@ -545,6 +609,8 @@ describe('VideoWorkspace', () => {
|
||||
});
|
||||
|
||||
const propagationWeightSelect = screen.getByLabelText('传播权重');
|
||||
expect(propagationWeightSelect).toHaveClass('bg-[#050809]');
|
||||
expect(within(propagationWeightSelect).getByRole('option', { name: 'tiny' })).toHaveClass('text-cyan-100');
|
||||
fireEvent.change(propagationWeightSelect, { target: { value: 'sam2.1_hiera_small' } });
|
||||
expect(propagationWeightSelect).toHaveValue('sam2.1_hiera_small');
|
||||
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
|
||||
@@ -585,7 +651,8 @@ describe('VideoWorkspace', () => {
|
||||
act(() => {
|
||||
useStore.setState({
|
||||
masks: [{
|
||||
id: 'mask-progress',
|
||||
id: 'annotation-7',
|
||||
annotationId: '7',
|
||||
frameId: 10 as unknown as string,
|
||||
pathData: 'M 0 0 Z',
|
||||
label: '胆囊',
|
||||
@@ -630,7 +697,8 @@ describe('VideoWorkspace', () => {
|
||||
act(() => {
|
||||
useStore.setState({
|
||||
masks: [{
|
||||
id: 'mask-timeline-range',
|
||||
id: 'annotation-8',
|
||||
annotationId: '8',
|
||||
frameId: '10',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: '胆囊',
|
||||
@@ -686,8 +754,19 @@ describe('VideoWorkspace', () => {
|
||||
completed_steps: 4,
|
||||
},
|
||||
});
|
||||
apiMock.buildAnnotationPayload
|
||||
.mockReturnValueOnce({
|
||||
apiMock.buildAnnotationPayload.mockImplementation((_projectId, mask) => (
|
||||
mask.id === 'annotation-10'
|
||||
? {
|
||||
project_id: 1,
|
||||
frame_id: 11,
|
||||
mask_data: {
|
||||
polygons: [[[0.4, 0.4], [0.5, 0.4], [0.5, 0.5]]],
|
||||
label: '肝脏',
|
||||
color: '#00ff00',
|
||||
},
|
||||
bbox: [0.4, 0.4, 0.1, 0.1],
|
||||
}
|
||||
: {
|
||||
project_id: 1,
|
||||
frame_id: 11,
|
||||
mask_data: {
|
||||
@@ -696,17 +775,8 @@ describe('VideoWorkspace', () => {
|
||||
color: '#ff0000',
|
||||
},
|
||||
bbox: [0.1, 0.1, 0.1, 0.1],
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
project_id: 1,
|
||||
frame_id: 11,
|
||||
mask_data: {
|
||||
polygons: [[[0.4, 0.4], [0.5, 0.4], [0.5, 0.5]]],
|
||||
label: '肝脏',
|
||||
color: '#00ff00',
|
||||
},
|
||||
bbox: [0.4, 0.4, 0.1, 0.1],
|
||||
});
|
||||
}
|
||||
));
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
|
||||
@@ -715,7 +785,8 @@ describe('VideoWorkspace', () => {
|
||||
currentFrameIndex: 1,
|
||||
masks: [
|
||||
{
|
||||
id: 'mask-a',
|
||||
id: 'annotation-9',
|
||||
annotationId: '9',
|
||||
frameId: '11',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: '胆囊',
|
||||
@@ -723,7 +794,8 @@ describe('VideoWorkspace', () => {
|
||||
segmentation: [[64, 36, 128, 36, 128, 72]],
|
||||
},
|
||||
{
|
||||
id: 'mask-b',
|
||||
id: 'annotation-10',
|
||||
annotationId: '10',
|
||||
frameId: '11',
|
||||
pathData: 'M 1 1 Z',
|
||||
label: '肝脏',
|
||||
|
||||
@@ -33,6 +33,13 @@ type PropagationProgress = {
|
||||
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;
|
||||
@@ -73,6 +80,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
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) {
|
||||
@@ -80,11 +88,14 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
}
|
||||
}, [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;
|
||||
@@ -304,6 +315,53 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
}
|
||||
}, [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 {
|
||||
@@ -442,12 +500,24 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
|
||||
const runAutoPropagate = async () => {
|
||||
if (!currentProject?.id || !currentFrame?.id) return;
|
||||
const seedMasks = masks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
|
||||
if (seedMasks.length === 0) {
|
||||
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;
|
||||
@@ -545,6 +615,21 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
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
|
||||
@@ -650,7 +735,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
重做
|
||||
</button>
|
||||
</div>
|
||||
<ModelStatusBadge />
|
||||
<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
|
||||
@@ -661,10 +746,10 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
setPropagationWeight(event.target.value as AiModelId);
|
||||
}}
|
||||
disabled={isPropagating || isSaving || isExporting || isImportingGt}
|
||||
className="h-6 w-24 rounded border border-white/10 bg-black/20 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40"
|
||||
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}>
|
||||
<option key={option.id} value={option.id} className="bg-[#050809] text-cyan-100">
|
||||
{option.shortLabel}
|
||||
</option>
|
||||
))}
|
||||
@@ -709,6 +794,14 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
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}
|
||||
@@ -789,6 +882,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
startFrame: propagationStartFrame,
|
||||
endFrame: propagationEndFrame,
|
||||
}}
|
||||
propagationHistory={propagationHistory}
|
||||
propagationRangeSelectionActive={isPropagationRangeSelecting}
|
||||
propagationRangeDisabled={isPropagating || isSaving || isExporting || isImportingGt}
|
||||
onPropagationRangeChange={handlePropagationRangeChange}
|
||||
|
||||
@@ -8,4 +8,39 @@
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.seg-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(113, 113, 122, 0.22) transparent;
|
||||
}
|
||||
|
||||
.seg-scrollbar:hover,
|
||||
.seg-scrollbar:focus-within {
|
||||
scrollbar-color: rgba(34, 211, 238, 0.42) transparent;
|
||||
}
|
||||
|
||||
.seg-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.seg-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.seg-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(113, 113, 122, 0.18);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 9999px;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.seg-scrollbar:hover::-webkit-scrollbar-thumb,
|
||||
.seg-scrollbar:focus-within::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(34, 211, 238, 0.42);
|
||||
}
|
||||
|
||||
.seg-scrollbar::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('useStore', () => {
|
||||
useStore.getState().setCurrentFrame(0);
|
||||
useStore.getState().addMask({ id: 'm1', frameId: 'f1', pathData: 'M 0 0 Z', label: 'mask', color: '#fff' });
|
||||
useStore.getState().setSelectedMaskIds(['m1']);
|
||||
useStore.getState().setMaskPreviewOpacity(35);
|
||||
useStore.getState().updateMask('m1', { label: 'updated mask', saveStatus: 'dirty' });
|
||||
useStore.getState().addAnnotation({ id: 'a1', frameId: 'f1', type: 'mask', points: [], label: 'ann', color: '#fff' });
|
||||
useStore.getState().addTemplate({ id: 't1', name: 'Template', classes: [], rules: [] });
|
||||
@@ -42,6 +43,7 @@ describe('useStore', () => {
|
||||
expect(useStore.getState().frames).toHaveLength(1);
|
||||
expect(useStore.getState().currentFrameIndex).toBe(0);
|
||||
expect(useStore.getState().selectedMaskIds).toEqual(['m1']);
|
||||
expect(useStore.getState().maskPreviewOpacity).toBe(35);
|
||||
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ label: 'updated mask', saveStatus: 'dirty' }));
|
||||
expect(useStore.getState().annotations).toHaveLength(1);
|
||||
expect(useStore.getState().templates[0].name).toBe('Template 2');
|
||||
|
||||
@@ -128,6 +128,7 @@ export interface AppState {
|
||||
annotations: Annotation[];
|
||||
masks: Mask[];
|
||||
selectedMaskIds: string[];
|
||||
maskPreviewOpacity: number;
|
||||
maskHistory: Mask[][];
|
||||
maskFuture: Mask[][];
|
||||
setActiveModule: (module: string) => void;
|
||||
@@ -140,6 +141,7 @@ export interface AppState {
|
||||
updateMask: (id: string, updates: Partial<Mask>) => void;
|
||||
setMasks: (masks: Mask[]) => void;
|
||||
setSelectedMaskIds: (ids: string[]) => void;
|
||||
setMaskPreviewOpacity: (opacity: number) => void;
|
||||
clearMasks: () => void;
|
||||
undoMasks: () => void;
|
||||
redoMasks: () => void;
|
||||
@@ -185,6 +187,7 @@ export const useStore = create<AppState>((set) => ({
|
||||
annotations: [],
|
||||
masks: [],
|
||||
selectedMaskIds: [],
|
||||
maskPreviewOpacity: 50,
|
||||
maskHistory: [],
|
||||
maskFuture: [],
|
||||
activeTemplateId: null,
|
||||
@@ -214,6 +217,7 @@ export const useStore = create<AppState>((set) => ({
|
||||
annotations: [],
|
||||
masks: [],
|
||||
selectedMaskIds: [],
|
||||
maskPreviewOpacity: 50,
|
||||
maskHistory: [],
|
||||
maskFuture: [],
|
||||
setActiveModule: (activeModule: string) => set({ activeModule }),
|
||||
@@ -247,6 +251,9 @@ export const useStore = create<AppState>((set) => ({
|
||||
};
|
||||
}),
|
||||
setSelectedMaskIds: (selectedMaskIds: string[]) => set({ selectedMaskIds }),
|
||||
setMaskPreviewOpacity: (maskPreviewOpacity: number) => set({
|
||||
maskPreviewOpacity: Math.min(Math.max(maskPreviewOpacity, 10), 100),
|
||||
}),
|
||||
clearMasks: () =>
|
||||
set((state) => ({
|
||||
masks: [],
|
||||
|
||||
@@ -64,12 +64,21 @@ vi.mock('react-konva', () => ({
|
||||
onMouseMove?.(makeStageEvent(point.x, point.y));
|
||||
}}
|
||||
onWheel={() => onWheel?.(makeStageEvent())}
|
||||
onDragEnd={(event) => onDragEnd?.({
|
||||
target: {
|
||||
onDragEnd={(event) => {
|
||||
const stageTarget: any = {
|
||||
x: () => event.clientX || 0,
|
||||
y: () => event.clientY || 0,
|
||||
},
|
||||
})}
|
||||
};
|
||||
stageTarget.getStage = () => stageTarget;
|
||||
const childTarget = {
|
||||
x: () => event.clientX || 0,
|
||||
y: () => event.clientY || 0,
|
||||
getStage: () => stageTarget,
|
||||
};
|
||||
onDragEnd?.({
|
||||
target: event.target === event.currentTarget ? stageTarget : childTarget,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ export function resetStore() {
|
||||
annotations: [],
|
||||
masks: [],
|
||||
selectedMaskIds: [],
|
||||
maskPreviewOpacity: 50,
|
||||
maskHistory: [],
|
||||
maskFuture: [],
|
||||
templates: [],
|
||||
|
||||
Reference in New Issue
Block a user