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:
2026-05-02 06:45:47 +08:00
parent c8c59f7ede
commit 4899c8a08a
24 changed files with 711 additions and 109 deletions

View File

@@ -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',

View File

@@ -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}`}

View File

@@ -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' },
},
],
});

View File

@@ -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"
)}
>

View File

@@ -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 />);

View File

@@ -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',

View File

@@ -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');
});

View File

@@ -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;

View File

@@ -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: '肝脏',

View File

@@ -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}

View File

@@ -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;
}
}

View File

@@ -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');

View File

@@ -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: [],

View File

@@ -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>

View File

@@ -14,6 +14,7 @@ export function resetStore() {
annotations: [],
masks: [],
selectedMaskIds: [],
maskPreviewOpacity: 50,
maskHistory: [],
maskFuture: [],
templates: [],