feat: 完善标注删除、AI 框选与视频传播交互
功能增加: - 在工作区增加按范围传播和传播全部可达入口,支持选中区域或当前帧全部 mask 作为 seed,并按前后帧范围调用 SAM2 传播后刷新已保存标注。 - 在 AI 智能分割中接入框选提示,支持 box prompt 以及 box + 正/反向点的 interactive prompt 细化流程。 - 在 AI 智能分割中增加提示点删除、最近锚点删除、清空锚点、选中 AI 候选删除和 Delete/Backspace 快捷删除。 - 在项目库删除项目后同步清理当前项目、帧、mask 与选区状态,避免删除后工作区残留旧数据。 - 将时间进度条上的已编辑帧提示改为覆盖在进度条上的琥珀色竖线,并保留已编辑帧计数。 - 将 AI 参数文案调整为局部专注模式(自动裁剪无锚区域)和严格除杂模式(自动清理干涉点),仅改善可读性,不改变内部字段。 Bugfix: - 修复 AI 框选工具无实际 prompt 输出的问题。 - 修复多次执行 AI 高精度语义分割时旧候选 mask 叠加显示的问题,改为替换本页 AI 候选。 - 修复删除 AI 候选后选区仍引用已删除 mask 的状态残留。 - 修复进度条当前帧提示与已编辑帧提示颜色/语义混淆的问题,当前帧继续由播放进度和缩略图高亮表达。 测试与文档: - 补充 AI 分割框选、候选替换、提示点删除和快捷删除相关测试。 - 补充工作区传播范围、传播全部可达、编辑区域删除和项目删除状态清理测试。 - 更新 README、AGENTS 和 doc 下需求冻结、设计冻结、接口契约、前端审计、实施计划、测试计划,记录当前真实功能和测试覆盖。
This commit is contained in:
@@ -46,6 +46,9 @@ describe('AISegmentation', () => {
|
||||
apiMock.predictMask.mockResolvedValueOnce({ masks: [] });
|
||||
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('局部专注模式(自动裁剪无锚区域)')).toBeInTheDocument();
|
||||
expect(screen.getByText('严格除杂模式(自动清理干涉点)')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('正向选点'));
|
||||
fireEvent.click(screen.getByTestId('konva-stage'));
|
||||
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||
@@ -107,7 +110,148 @@ describe('AISegmentation', () => {
|
||||
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||
|
||||
expect(apiMock.predictMask).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText('请先放置正/反向提示点。')).toBeInTheDocument();
|
||||
expect(await screen.findByText('请先放置正/反向提示点或框选区域。')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses a dragged box prompt for AI page inference without adding a point on click', async () => {
|
||||
apiMock.predictMask.mockResolvedValueOnce({ masks: [] });
|
||||
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('边界框选'));
|
||||
const stage = screen.getByTestId('konva-stage');
|
||||
fireEvent.mouseDown(stage, { clientX: 120, clientY: 80 });
|
||||
fireEvent.mouseMove(stage, { clientX: 260, clientY: 200 });
|
||||
fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 });
|
||||
|
||||
expect(screen.getByTestId('konva-rect')).toHaveAttribute('data-width', '140');
|
||||
expect(await screen.findByText('已框选区域,可执行分割,或继续添加正/反向点细化。')).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0);
|
||||
expect(apiMock.predictMask).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||
|
||||
expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
imageId: 'frame-1',
|
||||
imageWidth: 640,
|
||||
imageHeight: 360,
|
||||
model: 'sam2.1_hiera_tiny',
|
||||
points: undefined,
|
||||
box: { x1: 120, y1: 80, x2: 260, y2: 200 },
|
||||
options: {
|
||||
crop_to_prompt: false,
|
||||
auto_filter_background: true,
|
||||
min_score: 0.05,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('combines the AI page box prompt with later positive and negative refinement points', async () => {
|
||||
apiMock.predictMask.mockResolvedValueOnce({ masks: [] });
|
||||
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('边界框选'));
|
||||
const stage = screen.getByTestId('konva-stage');
|
||||
fireEvent.mouseDown(stage, { clientX: 100, clientY: 60 });
|
||||
fireEvent.mouseMove(stage, { clientX: 300, clientY: 180 });
|
||||
fireEvent.mouseUp(stage, { clientX: 300, clientY: 180 });
|
||||
|
||||
fireEvent.click(screen.getByText('正向选点'));
|
||||
fireEvent.click(stage, { clientX: 160, clientY: 100 });
|
||||
fireEvent.click(screen.getByText('反向选点'));
|
||||
fireEvent.click(stage, { clientX: 260, clientY: 150 });
|
||||
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||
|
||||
expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
points: [
|
||||
{ x: 160, y: 100, type: 'pos' },
|
||||
{ x: 260, y: 150, type: 'neg' },
|
||||
],
|
||||
box: { x1: 100, y1: 60, x2: 300, y2: 180 },
|
||||
}));
|
||||
});
|
||||
|
||||
it('replaces the previous AI page candidate when running the same box prompt again', async () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{
|
||||
id: 'workspace-mask',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 0 0 L 10 0 L 10 10 Z',
|
||||
label: 'Manual Mask',
|
||||
color: '#ff0000',
|
||||
segmentation: [[0, 0, 10, 0, 10, 10]],
|
||||
metadata: { source: 'manual' },
|
||||
},
|
||||
],
|
||||
});
|
||||
apiMock.predictMask
|
||||
.mockResolvedValueOnce({
|
||||
masks: [
|
||||
{
|
||||
id: 'sam2-first',
|
||||
pathData: 'M 10 10 L 40 10 L 40 40 Z',
|
||||
label: 'AI Mask',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[10, 10, 40, 10, 40, 40]],
|
||||
bbox: [10, 10, 30, 30],
|
||||
area: 900,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
masks: [
|
||||
{
|
||||
id: 'sam2-second',
|
||||
pathData: 'M 20 20 L 50 20 L 50 50 Z',
|
||||
label: 'AI Mask',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[20, 20, 50, 20, 50, 50]],
|
||||
bbox: [20, 20, 30, 30],
|
||||
area: 900,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('边界框选'));
|
||||
const stage = screen.getByTestId('konva-stage');
|
||||
fireEvent.mouseDown(stage, { clientX: 120, clientY: 80 });
|
||||
fireEvent.mouseMove(stage, { clientX: 260, clientY: 200 });
|
||||
fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 });
|
||||
|
||||
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||
await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask', 'sam2-first']));
|
||||
expect(useStore.getState().selectedMaskIds).toEqual(['sam2-first']);
|
||||
|
||||
fireEvent.click(screen.getByText('执行高精度语义分割'));
|
||||
|
||||
await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask', 'sam2-second']));
|
||||
expect(useStore.getState().selectedMaskIds).toEqual(['sam2-second']);
|
||||
expect(screen.getAllByTestId('konva-path')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('deletes prompt points individually and can remove the latest point', async () => {
|
||||
apiMock.predictMask.mockResolvedValueOnce({ masks: [] });
|
||||
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('正向选点'));
|
||||
fireEvent.click(screen.getByTestId('konva-stage'), { clientX: 120, clientY: 80 });
|
||||
fireEvent.click(screen.getByText('反向选点'));
|
||||
fireEvent.click(screen.getByTestId('konva-stage'), { clientX: 220, clientY: 140 });
|
||||
|
||||
await waitFor(() => expect(screen.getAllByTestId('konva-circle')).toHaveLength(4));
|
||||
fireEvent.click(screen.getAllByTestId('konva-circle')[0]);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByTestId('konva-circle')).toHaveLength(2));
|
||||
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||
|
||||
expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
points: [{ x: 220, y: 140, type: 'neg' }],
|
||||
}));
|
||||
|
||||
fireEvent.click(screen.getByLabelText('删除最近锚点'));
|
||||
|
||||
await waitFor(() => expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0));
|
||||
});
|
||||
|
||||
it('keeps only the best SAM2 candidate when the backend returns overlapping alternatives', async () => {
|
||||
@@ -141,7 +285,7 @@ describe('AISegmentation', () => {
|
||||
|
||||
await waitFor(() => expect(useStore.getState().masks).toHaveLength(1));
|
||||
expect(useStore.getState().masks[0].id).toBe('sam2-best');
|
||||
expect(useStore.getState().masks[0].metadata).toEqual({ source: 'ai_segmentation' });
|
||||
expect(useStore.getState().masks[0].metadata).toEqual(expect.objectContaining({ source: 'ai_segmentation' }));
|
||||
expect(useStore.getState().selectedMaskIds).toEqual(['sam2-best']);
|
||||
expect(await screen.findByText('SAM 2.1 Tiny 返回 2 个候选,已采用最高分区域。')).toBeInTheDocument();
|
||||
});
|
||||
@@ -253,6 +397,74 @@ describe('AISegmentation', () => {
|
||||
expect(useStore.getState().selectedMaskIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('deletes only the selected AI candidate and preserves workspace masks', async () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{
|
||||
id: 'workspace-mask',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 0 0 L 10 0 L 10 10 Z',
|
||||
label: 'Manual Mask',
|
||||
color: '#ff0000',
|
||||
segmentation: [[0, 0, 10, 0, 10, 10]],
|
||||
metadata: { source: 'manual' },
|
||||
},
|
||||
],
|
||||
});
|
||||
apiMock.predictMask.mockResolvedValueOnce({
|
||||
masks: [
|
||||
{
|
||||
id: 'sam2-mask',
|
||||
pathData: 'M 10 10 L 40 10 L 40 40 Z',
|
||||
label: 'AI Mask',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[10, 10, 40, 10, 40, 40]],
|
||||
bbox: [10, 10, 30, 30],
|
||||
area: 900,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('正向选点'));
|
||||
fireEvent.click(screen.getByTestId('konva-stage'));
|
||||
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask']));
|
||||
|
||||
fireEvent.click(screen.getByLabelText('删除选中候选'));
|
||||
|
||||
await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask']));
|
||||
expect(useStore.getState().selectedMaskIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('lets Delete remove the selected AI candidate after a mask click selects it', async () => {
|
||||
apiMock.predictMask.mockResolvedValueOnce({
|
||||
masks: [
|
||||
{
|
||||
id: 'sam2-mask',
|
||||
pathData: 'M 10 10 L 40 10 L 40 40 Z',
|
||||
label: 'AI Mask',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[10, 10, 40, 10, 40, 40]],
|
||||
bbox: [10, 10, 30, 30],
|
||||
area: 900,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('正向选点'));
|
||||
fireEvent.click(screen.getByTestId('konva-stage'));
|
||||
fireEvent.click(await screen.findByText('执行高精度语义分割'));
|
||||
await waitFor(() => expect(screen.getByTestId('konva-path')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByText('视口控制'));
|
||||
fireEvent.click(screen.getByTestId('konva-path'));
|
||||
fireEvent.keyDown(window, { key: 'Delete' });
|
||||
|
||||
await waitFor(() => expect(useStore.getState().masks).toEqual([]));
|
||||
});
|
||||
|
||||
it('lets a SAM2 result be selected and relabeled from the ontology panel', async () => {
|
||||
useStore.setState({
|
||||
templates: [
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Target, PlusCircle, MinusCircle, SquareDashed, Sparkles, SendToBack, Image as ImageIcon, Undo, Redo, Loader2 } from 'lucide-react';
|
||||
import { Target, PlusCircle, MinusCircle, SquareDashed, Sparkles, SendToBack, Image as ImageIcon, Undo, Redo, Loader2, XCircle, Trash2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Stage, Layer, Image as KonvaImage, Circle, Path, Group } from 'react-konva';
|
||||
import { Stage, Layer, Image as KonvaImage, Circle, Path, Group, Rect } from 'react-konva';
|
||||
import useImage from 'use-image';
|
||||
import { OntologyInspector } from './OntologyInspector';
|
||||
import { SAM2_MODEL_OPTIONS, useStore } from '../store/useStore';
|
||||
import { SAM2_MODEL_OPTIONS, useStore, type Mask } from '../store/useStore';
|
||||
import { getAiModelStatus, predictMask, type AiRuntimeStatus } from '../lib/api';
|
||||
|
||||
interface AISegmentationProps {
|
||||
onSendToWorkspace: () => void;
|
||||
}
|
||||
|
||||
type PromptPoint = { x: number; y: number; type: 'pos' | 'neg' };
|
||||
type PromptBox = { x1: number; y1: number; x2: number; y2: number };
|
||||
|
||||
export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
const storeActiveTool = useStore((state) => state.activeTool);
|
||||
const setActiveTool = useStore((state) => state.setActiveTool);
|
||||
const masks = useStore((state) => state.masks);
|
||||
const addMask = useStore((state) => state.addMask);
|
||||
const setMasks = useStore((state) => state.setMasks);
|
||||
const selectedMaskIds = useStore((state) => state.selectedMaskIds);
|
||||
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
|
||||
@@ -41,7 +43,10 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
// Canvas state
|
||||
const [scale, setScale] = useState(1);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [points, setPoints] = useState<{ x: number, y: number, type: 'pos'|'neg' }[]>([]);
|
||||
const [points, setPoints] = useState<PromptPoint[]>([]);
|
||||
const [promptBox, setPromptBox] = useState<PromptBox | null>(null);
|
||||
const [boxStart, setBoxStart] = useState<{ x: number; y: number } | null>(null);
|
||||
const [boxCurrent, setBoxCurrent] = useState<{ x: number; y: number } | null>(null);
|
||||
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
|
||||
const currentFrame = frames[currentFrameIndex] || null;
|
||||
const previewUrl = currentFrame?.url || 'https://images.unsplash.com/photo-1549317661-bd32c8ce0be2?q=80&w=2070&auto=format&fit=crop';
|
||||
@@ -55,6 +60,24 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
|
||||
const effectiveTool = storeActiveTool;
|
||||
|
||||
const boxRect = React.useMemo(() => {
|
||||
const activeBox = boxStart && boxCurrent
|
||||
? {
|
||||
x1: Math.min(boxStart.x, boxCurrent.x),
|
||||
y1: Math.min(boxStart.y, boxCurrent.y),
|
||||
x2: Math.max(boxStart.x, boxCurrent.x),
|
||||
y2: Math.max(boxStart.y, boxCurrent.y),
|
||||
}
|
||||
: promptBox;
|
||||
if (!activeBox) return null;
|
||||
return {
|
||||
x: activeBox.x1,
|
||||
y: activeBox.y1,
|
||||
width: activeBox.x2 - activeBox.x1,
|
||||
height: activeBox.y2 - activeBox.y1,
|
||||
};
|
||||
}, [boxCurrent, boxStart, promptBox]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAiModelStatus(aiModel)
|
||||
@@ -105,11 +128,17 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
const imageY = (pos.y - position.y) / scale;
|
||||
setCursorPos({ x: imageX, y: imageY });
|
||||
}
|
||||
if (effectiveTool === 'box_select' && boxStart) {
|
||||
const relPos = stage.getRelativePointerPosition?.();
|
||||
if (relPos) {
|
||||
setBoxCurrent({ x: relPos.x, y: relPos.y });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const runInference = useCallback(async () => {
|
||||
if (points.length === 0) {
|
||||
setInferenceMessage('请先放置正/反向提示点。');
|
||||
if (points.length === 0 && !promptBox) {
|
||||
setInferenceMessage('请先放置正/反向提示点或框选区域。');
|
||||
return;
|
||||
}
|
||||
if (!currentFrame?.id) {
|
||||
@@ -134,7 +163,8 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
model: aiModel,
|
||||
points: points.map((p) => ({ x: p.x, y: p.y, type: p.type })),
|
||||
points: points.length > 0 ? points.map((p) => ({ x: p.x, y: p.y, type: p.type })) : undefined,
|
||||
box: promptBox || undefined,
|
||||
options: {
|
||||
crop_to_prompt: cropMode,
|
||||
auto_filter_background: autoDeleteBg,
|
||||
@@ -151,12 +181,10 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
? `${selectedModelStatus?.label || 'SAM 2.1'} 返回 ${result.masks.length} 个候选,已采用最高分区域。`
|
||||
: `已生成 ${masksToApply.length} 个候选区域。`);
|
||||
}
|
||||
const generatedMaskIds: string[] = [];
|
||||
masksToApply.forEach((m) => {
|
||||
const generatedMasks: Mask[] = masksToApply.map((m) => {
|
||||
const label = activeClass?.name || m.label;
|
||||
const color = activeClass?.color || m.color;
|
||||
generatedMaskIds.push(m.id);
|
||||
addMask({
|
||||
return {
|
||||
id: m.id,
|
||||
frameId: currentFrame.id,
|
||||
templateId: activeTemplateId || undefined,
|
||||
@@ -171,12 +199,26 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
segmentation: m.segmentation,
|
||||
bbox: m.bbox,
|
||||
area: m.area,
|
||||
metadata: { source: 'ai_segmentation' },
|
||||
});
|
||||
metadata: {
|
||||
source: 'ai_segmentation',
|
||||
promptBox: promptBox || null,
|
||||
promptPointCount: points.length,
|
||||
promptNegativePointCount: points.filter((point) => point.type === 'neg').length,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const previousAiMaskIds = new Set(aiMaskIds);
|
||||
const generatedMaskIds = generatedMasks.map((mask) => mask.id);
|
||||
setMasks([
|
||||
...masks.filter((mask) => !previousAiMaskIds.has(mask.id)),
|
||||
...generatedMasks,
|
||||
]);
|
||||
setAiMaskIds(generatedMaskIds);
|
||||
if (generatedMaskIds.length > 0) {
|
||||
setAiMaskIds((existingIds) => [...existingIds, ...generatedMaskIds]);
|
||||
setSelectedMaskIds(generatedMaskIds);
|
||||
} else {
|
||||
setSelectedMaskIds(selectedMaskIds.filter((id) => !previousAiMaskIds.has(id)));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('AI inference failed:', err);
|
||||
@@ -185,10 +227,13 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
} finally {
|
||||
setIsInferencing(false);
|
||||
}
|
||||
}, [activeClass, activeTemplateId, addMask, aiModel, autoDeleteBg, cropMode, currentFrame?.height, currentFrame?.id, currentFrame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, points, selectedModelStatus?.label, setSelectedMaskIds]);
|
||||
}, [activeClass, activeTemplateId, aiMaskIds, aiModel, autoDeleteBg, cropMode, currentFrame?.height, currentFrame?.id, currentFrame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, masks, points, promptBox, selectedMaskIds, selectedModelStatus?.label, setMasks, setSelectedMaskIds]);
|
||||
|
||||
const clearAiLayer = useCallback(() => {
|
||||
setPoints([]);
|
||||
setPromptBox(null);
|
||||
setBoxStart(null);
|
||||
setBoxCurrent(null);
|
||||
if (aiMaskIds.length === 0) return;
|
||||
const idsToRemove = new Set(aiMaskIds);
|
||||
setMasks(masks.filter((mask) => !idsToRemove.has(mask.id)));
|
||||
@@ -196,6 +241,27 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
setAiMaskIds([]);
|
||||
}, [aiMaskIds, masks, selectedMaskIds, setMasks, setSelectedMaskIds]);
|
||||
|
||||
const deleteAiMasksById = useCallback((maskIds: string[]) => {
|
||||
const aiIds = new Set(aiMaskIds);
|
||||
const idsToRemove = new Set(maskIds.filter((id) => aiIds.has(id)));
|
||||
if (idsToRemove.size === 0) return;
|
||||
setMasks(masks.filter((mask) => !idsToRemove.has(mask.id)));
|
||||
setAiMaskIds((currentIds) => currentIds.filter((id) => !idsToRemove.has(id)));
|
||||
setSelectedMaskIds(selectedMaskIds.filter((id) => !idsToRemove.has(id)));
|
||||
}, [aiMaskIds, masks, selectedMaskIds, setMasks, setSelectedMaskIds]);
|
||||
|
||||
const deleteSelectedAiMasks = useCallback(() => {
|
||||
deleteAiMasksById(selectedMaskIds);
|
||||
}, [deleteAiMasksById, selectedMaskIds]);
|
||||
|
||||
const removePromptPoint = useCallback((pointIndex: number) => {
|
||||
setPoints((currentPoints) => currentPoints.filter((_, index) => index !== pointIndex));
|
||||
}, []);
|
||||
|
||||
const removeLastPromptPoint = useCallback(() => {
|
||||
setPoints((currentPoints) => currentPoints.slice(0, -1));
|
||||
}, []);
|
||||
|
||||
const addPromptPointFromEvent = useCallback((event: any) => {
|
||||
if (effectiveTool !== 'point_pos' && effectiveTool !== 'point_neg') return false;
|
||||
const stage = event.target?.getStage?.();
|
||||
@@ -208,8 +274,50 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
return true;
|
||||
}, [effectiveTool]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const tagName = target?.tagName?.toLowerCase();
|
||||
if (tagName === 'input' || tagName === 'textarea' || target?.isContentEditable) return;
|
||||
if (event.key !== 'Delete' && event.key !== 'Backspace') return;
|
||||
const selectedAiIds = selectedMaskIds.filter((id) => aiMaskIds.includes(id));
|
||||
if (selectedAiIds.length === 0) return;
|
||||
event.preventDefault();
|
||||
deleteAiMasksById(selectedAiIds);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [aiMaskIds, deleteAiMasksById, selectedMaskIds]);
|
||||
|
||||
const handleStageMouseDown = useCallback((event: any) => {
|
||||
if (effectiveTool !== 'box_select') return;
|
||||
const stage = event.target?.getStage?.();
|
||||
const pos = stage?.getRelativePointerPosition?.();
|
||||
if (!pos) return;
|
||||
setBoxStart({ x: pos.x, y: pos.y });
|
||||
setBoxCurrent({ x: pos.x, y: pos.y });
|
||||
setInferenceMessage('');
|
||||
}, [effectiveTool]);
|
||||
|
||||
const handleStageMouseUp = useCallback(() => {
|
||||
if (effectiveTool !== 'box_select' || !boxStart || !boxCurrent) return;
|
||||
const x1 = Math.min(boxStart.x, boxCurrent.x);
|
||||
const y1 = Math.min(boxStart.y, boxCurrent.y);
|
||||
const x2 = Math.max(boxStart.x, boxCurrent.x);
|
||||
const y2 = Math.max(boxStart.y, boxCurrent.y);
|
||||
if (Math.abs(x2 - x1) > 5 && Math.abs(y2 - y1) > 5) {
|
||||
setPromptBox({ x1, y1, x2, y2 });
|
||||
setPoints([]);
|
||||
setInferenceMessage('已框选区域,可执行分割,或继续添加正/反向点细化。');
|
||||
}
|
||||
setBoxStart(null);
|
||||
setBoxCurrent(null);
|
||||
}, [boxCurrent, boxStart, effectiveTool]);
|
||||
|
||||
const handleStageClick = (e: any) => {
|
||||
if (effectiveTool === 'move') return;
|
||||
if (effectiveTool === 'box_select') return;
|
||||
addPromptPointFromEvent(e);
|
||||
};
|
||||
|
||||
@@ -308,14 +416,14 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3 flex items-center gap-2">核心参数设定</h3>
|
||||
<div className="space-y-4 bg-[#111] rounded-lg p-5 border border-white/5">
|
||||
<div className="flex items-center justify-between cursor-pointer group" onClick={() => setCropMode(!cropMode)}>
|
||||
<span className="text-[11px] text-gray-400 uppercase tracking-wider font-medium group-hover:text-gray-200 transition-colors">自动裁剪无锚区域</span>
|
||||
<span className="text-[11px] text-gray-400 uppercase tracking-wider font-medium group-hover:text-gray-200 transition-colors">局部专注模式(自动裁剪无锚区域)</span>
|
||||
<button className={cn("w-8 h-4 rounded-full transition-colors relative", cropMode ? "bg-cyan-500" : "bg-white/20")}>
|
||||
<div className={cn("absolute top-0.5 left-0.5 w-3 h-3 bg-white rounded-full transition-transform shadow-sm", cropMode ? "translate-x-4" : "")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between cursor-pointer group" onClick={() => setAutoDeleteBg(!autoDeleteBg)}>
|
||||
<span className="text-[11px] text-gray-400 uppercase tracking-wider font-medium group-hover:text-gray-200 transition-colors">自动清理干涉点</span>
|
||||
<span className="text-[11px] text-gray-400 uppercase tracking-wider font-medium group-hover:text-gray-200 transition-colors">严格除杂模式(自动清理干涉点)</span>
|
||||
<button className={cn("w-8 h-4 rounded-full transition-colors relative", autoDeleteBg ? "bg-cyan-500" : "bg-white/20")}>
|
||||
<div className={cn("absolute top-0.5 left-0.5 w-3 h-3 bg-white rounded-full transition-transform shadow-sm", autoDeleteBg ? "translate-x-4" : "")} />
|
||||
</button>
|
||||
@@ -400,6 +508,24 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
<button className="flex items-center gap-2 text-xs text-gray-400 hover:text-white transition-colors bg-white/5 hover:bg-white/10 px-3 py-1.5 rounded-md border border-white/5">
|
||||
<ImageIcon size={14} /> 上传替换底图
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 text-xs text-gray-400 hover:text-white transition-colors bg-white/5 hover:bg-white/10 px-3 py-1.5 rounded-md border border-white/5 disabled:opacity-30 disabled:hover:bg-white/5 disabled:hover:text-gray-400 disabled:cursor-not-allowed"
|
||||
onClick={removeLastPromptPoint}
|
||||
disabled={points.length === 0}
|
||||
title="删除最近锚点"
|
||||
aria-label="删除最近锚点"
|
||||
>
|
||||
<XCircle size={14} /> 删除最近锚点
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 text-xs text-gray-400 hover:text-white transition-colors bg-white/5 hover:bg-white/10 px-3 py-1.5 rounded-md border border-white/5 disabled:opacity-30 disabled:hover:bg-white/5 disabled:hover:text-gray-400 disabled:cursor-not-allowed"
|
||||
onClick={deleteSelectedAiMasks}
|
||||
disabled={!selectedMaskIds.some((id) => aiMaskIds.includes(id))}
|
||||
title="删除选中候选"
|
||||
aria-label="删除选中候选"
|
||||
>
|
||||
<Trash2 size={14} /> 删除选中候选
|
||||
</button>
|
||||
<button className="text-xs text-gray-400 hover:text-white transition-colors px-3 py-1.5" onClick={clearAiLayer}>
|
||||
清空全体锚点
|
||||
</button>
|
||||
@@ -413,6 +539,8 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
height={window.innerHeight - 64 - 64}
|
||||
onWheel={handleWheel}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleStageMouseDown}
|
||||
onMouseUp={handleStageMouseUp}
|
||||
onClick={handleStageClick}
|
||||
scaleX={scale}
|
||||
scaleY={scale}
|
||||
@@ -430,6 +558,19 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
opacity={0.8}
|
||||
/>
|
||||
)}
|
||||
|
||||
{boxRect && (
|
||||
<Rect
|
||||
x={boxRect.x}
|
||||
y={boxRect.y}
|
||||
width={boxRect.width}
|
||||
height={boxRect.height}
|
||||
fill="rgba(59, 130, 246, 0.12)"
|
||||
stroke="#60a5fa"
|
||||
strokeWidth={2 / scale}
|
||||
dash={[5 / scale, 5 / scale]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI Returned Masks */}
|
||||
{frameMasks.map((mask) => {
|
||||
@@ -475,10 +616,26 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
strokeWidth={2 / scale}
|
||||
shadowColor="black"
|
||||
shadowBlur={4}
|
||||
onClick={(event: any) => {
|
||||
event.cancelBubble = true;
|
||||
removePromptPoint(i);
|
||||
}}
|
||||
onTap={(event: any) => {
|
||||
event.cancelBubble = true;
|
||||
removePromptPoint(i);
|
||||
}}
|
||||
/>
|
||||
<Circle
|
||||
radius={1.5 / scale}
|
||||
fill="#ffffff"
|
||||
onClick={(event: any) => {
|
||||
event.cancelBubble = true;
|
||||
removePromptPoint(i);
|
||||
}}
|
||||
onTap={(event: any) => {
|
||||
event.cancelBubble = true;
|
||||
removePromptPoint(i);
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
@@ -209,6 +209,75 @@ describe('CanvasArea', () => {
|
||||
expect(await screen.findByText(/反向点已排除当前候选区域/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deletes a workspace SAM2 prompt point before the stage can add another point', async () => {
|
||||
apiMock.predictMask
|
||||
.mockResolvedValueOnce({
|
||||
masks: [
|
||||
{
|
||||
id: 'mask-prompt',
|
||||
pathData: 'M 10 10 L 90 10 L 90 90 Z',
|
||||
label: 'AI Mask',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[10, 10, 90, 10, 90, 90]],
|
||||
bbox: [10, 10, 80, 80],
|
||||
area: 6400,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
masks: [
|
||||
{
|
||||
id: 'mask-refined',
|
||||
pathData: 'M 20 20 L 80 20 L 80 80 Z',
|
||||
label: 'AI Mask',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[20, 20, 80, 20, 80, 80]],
|
||||
bbox: [20, 20, 60, 60],
|
||||
area: 3600,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
masks: [
|
||||
{
|
||||
id: 'mask-after-delete',
|
||||
pathData: 'M 30 30 L 70 30 L 70 70 Z',
|
||||
label: 'AI Mask',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[30, 30, 70, 30, 70, 70]],
|
||||
bbox: [30, 30, 40, 40],
|
||||
area: 1600,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(<CanvasArea activeTool="point_pos" frame={frame} />);
|
||||
const stage = screen.getByTestId('konva-stage');
|
||||
fireEvent.click(stage, { clientX: 120, clientY: 80 });
|
||||
await waitFor(() => expect(apiMock.predictMask).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(<CanvasArea activeTool="point_neg" frame={frame} />);
|
||||
fireEvent.click(stage, { clientX: 220, clientY: 140 });
|
||||
await waitFor(() => expect(apiMock.predictMask).toHaveBeenCalledTimes(2));
|
||||
const promptOuterCircles = () => screen.getAllByTestId('konva-circle')
|
||||
.filter((element) => ['#22c55e', '#ef4444'].includes(element.getAttribute('data-fill') || ''));
|
||||
expect(promptOuterCircles()).toHaveLength(2);
|
||||
|
||||
fireEvent.click(promptOuterCircles()[0]);
|
||||
|
||||
await waitFor(() => expect(apiMock.predictMask).toHaveBeenCalledTimes(3));
|
||||
expect(apiMock.predictMask).toHaveBeenLastCalledWith({
|
||||
imageId: 'frame-1',
|
||||
imageWidth: 640,
|
||||
imageHeight: 360,
|
||||
model: 'sam2.1_hiera_tiny',
|
||||
points: [{ x: 220, y: 140, type: 'neg' }],
|
||||
box: undefined,
|
||||
options: { auto_filter_background: true, min_score: 0.05 },
|
||||
});
|
||||
expect(promptOuterCircles()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders only masks that belong to the current frame', () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
|
||||
@@ -706,6 +706,25 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
}
|
||||
};
|
||||
|
||||
const removePromptPoint = useCallback((pointIndex: number, event?: any) => {
|
||||
if (event) event.cancelBubble = true;
|
||||
const nextPoints = points.filter((_, index) => index !== pointIndex);
|
||||
setPoints(nextPoints);
|
||||
|
||||
if (nextPoints.length > 0 || samPromptBox) {
|
||||
runInference(nextPoints, samPromptBox || undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (samCandidateMaskId) {
|
||||
setMasks(masks.filter((mask) => mask.id !== samCandidateMaskId));
|
||||
setSamCandidateMaskId(null);
|
||||
setSelectedMaskId(null);
|
||||
setSelectedMaskIds([]);
|
||||
setInferenceMessage('已移除最后一个提示点和对应候选区域。');
|
||||
}
|
||||
}, [masks, points, runInference, samCandidateMaskId, samPromptBox, setMasks]);
|
||||
|
||||
const updatePolygonMask = useCallback((mask: Mask, nextPoints: CanvasPoint[], polygonIndex = 0) => {
|
||||
if (nextPoints.length < 3) return;
|
||||
const nextSegmentation = [...(mask.segmentation || [])];
|
||||
@@ -1128,10 +1147,14 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
strokeWidth={2 / scale}
|
||||
shadowColor="black"
|
||||
shadowBlur={4}
|
||||
onClick={(event: any) => removePromptPoint(i, event)}
|
||||
onTap={(event: any) => removePromptPoint(i, event)}
|
||||
/>
|
||||
<Circle
|
||||
radius={1.5 / scale}
|
||||
fill="#ffffff"
|
||||
onClick={(event: any) => removePromptPoint(i, event)}
|
||||
onTap={(event: any) => removePromptPoint(i, event)}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
@@ -51,8 +51,9 @@ describe('FrameTimeline', () => {
|
||||
expect(screen.getAllByText('00:00.20').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('marks edited frames between the time progress bar and frame navigator', () => {
|
||||
it('overlays edited frame markers as amber vertical lines on the time progress bar', () => {
|
||||
useStore.setState({
|
||||
currentFrameIndex: 1,
|
||||
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 },
|
||||
@@ -67,8 +68,11 @@ describe('FrameTimeline', () => {
|
||||
|
||||
render(<FrameTimeline />);
|
||||
|
||||
expect(screen.getByText('已编辑')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 帧')).toBeInTheDocument();
|
||||
expect(screen.getByText('已编辑 2 帧')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('current-frame-line')).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('跳转到已编辑帧 2').className).toContain('before:bg-amber-300');
|
||||
expect(screen.getByLabelText('跳转到已编辑帧 3').className).toContain('before:h-5');
|
||||
expect(screen.getByLabelText('跳转到已编辑帧 3').className).not.toContain('h-2 w-2');
|
||||
fireEvent.click(screen.getByLabelText('跳转到已编辑帧 3'));
|
||||
expect(useStore.getState().currentFrameIndex).toBe(2);
|
||||
});
|
||||
|
||||
@@ -95,8 +95,8 @@ export function FrameTimeline() {
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="h-36 bg-[#111] border-t border-white/5 flex flex-col shrink-0 z-20">
|
||||
<div className="h-4 bg-[#0d0d0d] flex items-center group relative">
|
||||
<div className="h-32 bg-[#111] border-t border-white/5 flex flex-col shrink-0 z-20">
|
||||
<div className="h-7 bg-[#0d0d0d] flex items-center group relative">
|
||||
<div className="absolute left-3 -top-5 text-[10px] font-mono text-gray-500 pointer-events-none">
|
||||
{formatTime(currentSeconds)}
|
||||
</div>
|
||||
@@ -117,25 +117,7 @@ export function FrameTimeline() {
|
||||
className="h-full bg-cyan-500 absolute left-0"
|
||||
style={{ width: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
|
||||
/>
|
||||
<div
|
||||
className="w-3 h-3 bg-white rounded-full absolute top-1/2 -translate-y-1/2 -ml-1.5 shadow-sm transform scale-0 group-hover:scale-100 transition-transform shadow-cyan-500/50"
|
||||
style={{ left: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-7 -translate-x-1/2 rounded bg-black/80 border border-white/10 px-2 py-0.5 text-[10px] font-mono text-cyan-300 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||
style={{ left: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
|
||||
>
|
||||
{formatTime(currentSeconds)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-5 bg-[#0f0f0f] border-y border-white/[0.03] px-4 flex items-center gap-3">
|
||||
<div className="w-20 text-[9px] font-mono uppercase tracking-widest text-gray-500 shrink-0">已编辑</div>
|
||||
<div className="relative h-3 flex-1">
|
||||
<div className="absolute left-0 right-0 top-1/2 h-px -translate-y-1/2 bg-white/5" />
|
||||
{editedFrameMarkers.map(({ frame, index }) => {
|
||||
const isCurrent = index === currentFrameIndex;
|
||||
const left = totalFrames > 0 ? ((index + 1) / totalFrames) * 100 : 0;
|
||||
return (
|
||||
<button
|
||||
@@ -145,17 +127,24 @@ export function FrameTimeline() {
|
||||
title={`已编辑帧 ${index + 1}`}
|
||||
onClick={() => setCurrentFrame(index)}
|
||||
className={cn(
|
||||
"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border transition-all",
|
||||
isCurrent
|
||||
? "h-3 w-3 bg-cyan-300 border-cyan-100 shadow-[0_0_12px_rgba(34,211,238,0.65)]"
|
||||
: "h-2 w-2 bg-amber-300 border-amber-100/80 hover:h-3 hover:w-3 hover:bg-cyan-300 hover:border-cyan-100"
|
||||
"absolute left-0 top-1/2 z-30 w-3 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-sm transition-all",
|
||||
"before:absolute before:left-1/2 before:top-1/2 before:w-px before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:content-['']",
|
||||
"before:h-5 before:bg-amber-300 before:shadow-[0_0_8px_rgba(251,191,36,0.5)] hover:before:h-7 hover:before:bg-amber-100"
|
||||
)}
|
||||
style={{ left: `${left}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className="absolute -top-7 -translate-x-1/2 rounded bg-black/80 border border-white/10 px-2 py-0.5 text-[10px] font-mono text-cyan-300 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||
style={{ left: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
|
||||
>
|
||||
{formatTime(currentSeconds)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 right-3 text-[9px] font-mono text-gray-500 pointer-events-none">
|
||||
已编辑 {editedFrameMarkers.length} 帧
|
||||
</div>
|
||||
<div className="w-20 text-right text-[9px] font-mono text-gray-500 shrink-0">{editedFrameMarkers.length} 帧</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center px-4 gap-6">
|
||||
|
||||
@@ -10,6 +10,7 @@ const apiMock = vi.hoisted(() => ({
|
||||
uploadMedia: vi.fn(),
|
||||
parseMedia: vi.fn(),
|
||||
uploadDicomBatch: vi.fn(),
|
||||
deleteProject: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
@@ -18,6 +19,7 @@ vi.mock('../lib/api', () => ({
|
||||
uploadMedia: apiMock.uploadMedia,
|
||||
parseMedia: apiMock.parseMedia,
|
||||
uploadDicomBatch: apiMock.uploadDicomBatch,
|
||||
deleteProject: apiMock.deleteProject,
|
||||
}));
|
||||
|
||||
describe('ProjectLibrary', () => {
|
||||
@@ -89,6 +91,32 @@ describe('ProjectLibrary', () => {
|
||||
await waitFor(() => expect(apiMock.parseMedia).toHaveBeenCalledWith('p4', { parseFps: 12 }));
|
||||
});
|
||||
|
||||
it('deletes a project from the project card without entering the workspace', async () => {
|
||||
const onProjectSelect = vi.fn();
|
||||
apiMock.getProjects.mockResolvedValueOnce([
|
||||
{ id: 'p5', name: 'Delete Me', status: 'ready', frames: 3, fps: '30FPS' },
|
||||
{ id: 'p6', name: 'Keep Me', status: 'ready', frames: 1, fps: '30FPS' },
|
||||
]);
|
||||
apiMock.deleteProject.mockResolvedValueOnce(undefined);
|
||||
useStore.setState({
|
||||
currentProject: { id: 'p5', name: 'Delete Me', status: 'ready' },
|
||||
frames: [{ id: 'f1', projectId: 'p5', index: 0, url: '/1.jpg', width: 640, height: 360 }],
|
||||
masks: [{ id: 'm1', frameId: 'f1', pathData: 'M 0 0 Z', label: 'Mask', color: '#06b6d4' }],
|
||||
selectedMaskIds: ['m1'],
|
||||
});
|
||||
|
||||
render(<ProjectLibrary onProjectSelect={onProjectSelect} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '删除项目 Delete Me' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.deleteProject).toHaveBeenCalledWith('p5'));
|
||||
expect(onProjectSelect).not.toHaveBeenCalled();
|
||||
expect(useStore.getState().projects.map((project) => project.id)).toEqual(['p6']);
|
||||
expect(useStore.getState().currentProject).toBeNull();
|
||||
expect(useStore.getState().frames).toEqual([]);
|
||||
expect(useStore.getState().masks).toEqual([]);
|
||||
expect(useStore.getState().selectedMaskIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('imports only valid DICOM files and parses the returned project', async () => {
|
||||
apiMock.uploadDicomBatch.mockResolvedValueOnce({ project_id: 77, uploaded_count: 1, message: 'ok' });
|
||||
apiMock.parseMedia.mockResolvedValueOnce({ frames_extracted: 1 });
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { UploadCloud, Film, Settings2, MoreHorizontal, Plus, Loader2, Activity, Images } from 'lucide-react';
|
||||
import { UploadCloud, Film, Settings2, Plus, Loader2, Activity, Images, Trash2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { getProjects, createProject, uploadMedia, parseMedia, uploadDicomBatch } from '../lib/api';
|
||||
import { getProjects, createProject, uploadMedia, parseMedia, uploadDicomBatch, deleteProject } from '../lib/api';
|
||||
import type { Project } from '../store/useStore';
|
||||
|
||||
interface ProjectLibraryProps {
|
||||
@@ -12,8 +12,12 @@ interface ProjectLibraryProps {
|
||||
export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
const projects = useStore((state) => state.projects);
|
||||
const setProjects = useStore((state) => state.setProjects);
|
||||
const currentProject = useStore((state) => state.currentProject);
|
||||
const setCurrentProject = useStore((state) => state.setCurrentProject);
|
||||
const addProject = useStore((state) => state.addProject);
|
||||
const setFrames = useStore((state) => state.setFrames);
|
||||
const setMasks = useStore((state) => state.setMasks);
|
||||
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -26,6 +30,7 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
const [showFrameConfig, setShowFrameConfig] = useState(false);
|
||||
const [frameParseFps, setFrameParseFps] = useState(30);
|
||||
const [isGeneratingFrames, setIsGeneratingFrames] = useState(false);
|
||||
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
|
||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||
const dicomInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -58,6 +63,30 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
onProjectSelect();
|
||||
};
|
||||
|
||||
const handleDeleteProject = async (project: Project, event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (deletingProjectId) return;
|
||||
const confirmed = window.confirm(`确认删除项目“${project.name}”?\n该操作会删除项目帧、标注、任务记录和相关 mask 元数据,无法撤销。`);
|
||||
if (!confirmed) return;
|
||||
|
||||
setDeletingProjectId(project.id);
|
||||
try {
|
||||
await deleteProject(project.id);
|
||||
setProjects(projects.filter((item) => item.id !== project.id));
|
||||
if (currentProject?.id === project.id) {
|
||||
setCurrentProject(null);
|
||||
setFrames([]);
|
||||
setMasks([]);
|
||||
setSelectedMaskIds([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete project failed:', err);
|
||||
alert('删除项目失败,请检查后端服务');
|
||||
} finally {
|
||||
setDeletingProjectId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoSelect = (file: File) => {
|
||||
setPendingFile(file);
|
||||
setShowVideoConfig(true);
|
||||
@@ -252,7 +281,16 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
<div className="p-4 flex flex-col gap-1">
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="text-sm font-medium text-gray-200 truncate pr-4" title={proj.name}>{proj.name}</h3>
|
||||
<button className="text-gray-500 hover:text-gray-300"><MoreHorizontal size={16} /></button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除项目 ${proj.name}`}
|
||||
title="删除项目"
|
||||
disabled={deletingProjectId === proj.id}
|
||||
onClick={(event) => handleDeleteProject(proj, event)}
|
||||
className="text-gray-500 hover:text-red-400 disabled:opacity-50 disabled:cursor-wait transition-colors"
|
||||
>
|
||||
{deletingProjectId === proj.id ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 font-mono mt-2">
|
||||
<span className="flex items-center gap-1.5"><Settings2 size={12} /> {proj.frames ?? 0} 帧节点</span>
|
||||
|
||||
@@ -380,7 +380,7 @@ describe('VideoWorkspace', () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it('propagates the selected current-frame mask through the backend video tracker', async () => {
|
||||
it('propagates the selected current-frame mask through the configured frame range', async () => {
|
||||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||||
{ 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 },
|
||||
@@ -417,14 +417,14 @@ describe('VideoWorkspace', () => {
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '传播片段' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '按范围传播' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.propagateMasks).toHaveBeenCalledWith({
|
||||
project_id: 1,
|
||||
frame_id: 10,
|
||||
model: 'sam2.1_hiera_tiny',
|
||||
direction: 'forward',
|
||||
max_frames: 30,
|
||||
max_frames: 2,
|
||||
include_source: false,
|
||||
save_annotations: true,
|
||||
seed: {
|
||||
@@ -437,6 +437,95 @@ describe('VideoWorkspace', () => {
|
||||
template_id: 2,
|
||||
},
|
||||
}));
|
||||
await waitFor(() => expect(screen.getByText('已传播并保存 2 个区域')).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('已传播 1 个 seed,处理 3 帧次,保存 2 个区域')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('propagates all current-frame masks to all reachable frames in both directions', 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.propagateMasks.mockResolvedValue({
|
||||
model: 'sam2.1_hiera_tiny',
|
||||
direction: 'forward',
|
||||
source_frame_id: 11,
|
||||
processed_frame_count: 2,
|
||||
created_annotation_count: 1,
|
||||
annotations: [],
|
||||
});
|
||||
apiMock.buildAnnotationPayload
|
||||
.mockReturnValueOnce({
|
||||
project_id: 1,
|
||||
frame_id: 11,
|
||||
mask_data: {
|
||||
polygons: [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2]]],
|
||||
label: '胆囊',
|
||||
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));
|
||||
act(() => {
|
||||
useStore.setState({
|
||||
currentFrameIndex: 1,
|
||||
masks: [
|
||||
{
|
||||
id: 'mask-a',
|
||||
frameId: '11',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
segmentation: [[64, 36, 128, 36, 128, 72]],
|
||||
},
|
||||
{
|
||||
id: 'mask-b',
|
||||
frameId: '11',
|
||||
pathData: 'M 1 1 Z',
|
||||
label: '肝脏',
|
||||
color: '#00ff00',
|
||||
segmentation: [[256, 144, 320, 144, 320, 180]],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('传播对象'), { target: { value: 'all' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '传播全部可达' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.propagateMasks).toHaveBeenCalledTimes(4));
|
||||
expect(apiMock.propagateMasks).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
direction: 'backward',
|
||||
max_frames: 2,
|
||||
seed: expect.objectContaining({ label: '胆囊' }),
|
||||
}));
|
||||
expect(apiMock.propagateMasks).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
direction: 'forward',
|
||||
max_frames: 2,
|
||||
seed: expect.objectContaining({ label: '胆囊' }),
|
||||
}));
|
||||
expect(apiMock.propagateMasks).toHaveBeenNthCalledWith(3, expect.objectContaining({
|
||||
direction: 'backward',
|
||||
max_frames: 2,
|
||||
seed: expect.objectContaining({ label: '肝脏' }),
|
||||
}));
|
||||
expect(apiMock.propagateMasks).toHaveBeenNthCalledWith(4, expect.objectContaining({
|
||||
direction: 'forward',
|
||||
max_frames: 2,
|
||||
seed: expect.objectContaining({ label: '肝脏' }),
|
||||
}));
|
||||
await waitFor(() => expect(screen.getByText('已传播 2 个 seed,处理 8 帧次,保存 4 个区域')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,10 @@ import { ToolsPalette } from './ToolsPalette';
|
||||
import { OntologyInspector } from './OntologyInspector';
|
||||
import { FrameTimeline } from './FrameTimeline';
|
||||
import { ModelStatusBadge } from './ModelStatusBadge';
|
||||
import type { Frame } from '../store/useStore';
|
||||
import type { Frame, Mask } from '../store/useStore';
|
||||
|
||||
type PropagationTarget = 'selected' | 'all';
|
||||
type PropagationDirection = 'forward' | 'backward';
|
||||
|
||||
export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }) {
|
||||
const gtMaskInputRef = React.useRef<HTMLInputElement>(null);
|
||||
@@ -49,6 +52,9 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
const [isImportingGt, setIsImportingGt] = useState(false);
|
||||
const [isPropagating, setIsPropagating] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [propagationTarget, setPropagationTarget] = useState<PropagationTarget>('selected');
|
||||
const [propagationStartFrame, setPropagationStartFrame] = useState(1);
|
||||
const [propagationEndFrame, setPropagationEndFrame] = useState(1);
|
||||
|
||||
const hydrateSavedAnnotations = useCallback(async (
|
||||
projectId: string,
|
||||
@@ -135,8 +141,20 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
}, [templates.length, setTemplates]);
|
||||
|
||||
const currentFrame = frames[currentFrameIndex] || null;
|
||||
const totalFrames = frames.length;
|
||||
const frameById = useMemo(() => new Map(frames.map((frame) => [frame.id, frame])), [frames]);
|
||||
const projectFrameIds = useMemo(() => new Set(frames.map((frame) => frame.id)), [frames]);
|
||||
const currentFrameNumber = currentFrameIndex + 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (totalFrames === 0) {
|
||||
setPropagationStartFrame(1);
|
||||
setPropagationEndFrame(1);
|
||||
return;
|
||||
}
|
||||
setPropagationStartFrame(currentFrameNumber);
|
||||
setPropagationEndFrame(Math.min(totalFrames, currentFrameNumber + 29));
|
||||
}, [currentFrameNumber, totalFrames]);
|
||||
|
||||
const savePendingAnnotations = useCallback(async ({ silent = false } = {}) => {
|
||||
if (!currentProject?.id) return 0;
|
||||
@@ -314,47 +332,93 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
}
|
||||
};
|
||||
|
||||
const handlePropagateSegment = async () => {
|
||||
const clampFrameNumber = useCallback((value: number) => {
|
||||
if (totalFrames <= 0) return 1;
|
||||
return Math.min(Math.max(value, 1), totalFrames);
|
||||
}, [totalFrames]);
|
||||
|
||||
const buildSeedPayload = useCallback((seedMask: Mask) => {
|
||||
if (!currentProject?.id || !currentFrame) return null;
|
||||
const seedPayload = buildAnnotationPayload(currentProject.id, seedMask, currentFrame, activeTemplateId);
|
||||
if (!seedPayload?.mask_data?.polygons?.length && !seedPayload?.bbox) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
polygons: seedPayload.mask_data?.polygons,
|
||||
bbox: seedPayload.bbox,
|
||||
points: seedPayload.points,
|
||||
label: seedPayload.mask_data?.label,
|
||||
color: seedPayload.mask_data?.color,
|
||||
class_metadata: seedPayload.mask_data?.class,
|
||||
template_id: seedPayload.template_id,
|
||||
};
|
||||
}, [activeTemplateId, currentFrame, currentProject?.id]);
|
||||
|
||||
const handlePropagateSegment = async (rangeOverride?: { startFrameNumber: number; endFrameNumber: number }) => {
|
||||
if (!currentProject?.id || !currentFrame?.id) return;
|
||||
const currentFrameMasks = masks.filter((mask) => mask.frameId === currentFrame.id);
|
||||
const selectedMask = selectedMaskIds
|
||||
const selectedMasks = selectedMaskIds
|
||||
.map((id) => currentFrameMasks.find((mask) => mask.id === id))
|
||||
.find((mask): mask is NonNullable<typeof mask> => Boolean(mask));
|
||||
const seedMask = selectedMask || currentFrameMasks[0];
|
||||
if (!seedMask) {
|
||||
setStatusMessage('请先选择或创建一个当前帧区域');
|
||||
.filter((mask): mask is Mask => Boolean(mask));
|
||||
const seedMasks = propagationTarget === 'all' ? currentFrameMasks : selectedMasks;
|
||||
if (seedMasks.length === 0) {
|
||||
setStatusMessage(propagationTarget === 'all' ? '当前帧没有可传播区域' : '请先选择一个或多个当前帧区域');
|
||||
return;
|
||||
}
|
||||
|
||||
const seedPayload = buildAnnotationPayload(currentProject.id, seedMask, currentFrame, activeTemplateId);
|
||||
if (!seedPayload?.mask_data?.polygons?.length && !seedPayload?.bbox) {
|
||||
setStatusMessage('当前区域缺少可传播的 polygon 或 bbox');
|
||||
const startFrameNumber = clampFrameNumber(rangeOverride?.startFrameNumber ?? propagationStartFrame);
|
||||
const endFrameNumber = clampFrameNumber(rangeOverride?.endFrameNumber ?? propagationEndFrame);
|
||||
const rangeStartIndex = Math.min(startFrameNumber, endFrameNumber) - 1;
|
||||
const rangeEndIndex = Math.max(startFrameNumber, endFrameNumber) - 1;
|
||||
const propagationDirections: Array<{ direction: PropagationDirection; maxFrames: number }> = [];
|
||||
if (rangeStartIndex < currentFrameIndex) {
|
||||
propagationDirections.push({
|
||||
direction: 'backward',
|
||||
maxFrames: currentFrameIndex - rangeStartIndex + 1,
|
||||
});
|
||||
}
|
||||
if (rangeEndIndex > currentFrameIndex) {
|
||||
propagationDirections.push({
|
||||
direction: 'forward',
|
||||
maxFrames: rangeEndIndex - currentFrameIndex + 1,
|
||||
});
|
||||
}
|
||||
if (propagationDirections.length === 0) {
|
||||
setStatusMessage('传播范围只包含当前帧,请选择前后至少一帧');
|
||||
return;
|
||||
}
|
||||
|
||||
const seeds = seedMasks
|
||||
.map((mask) => ({ mask, seed: buildSeedPayload(mask) }))
|
||||
.filter((item): item is { mask: Mask; seed: NonNullable<ReturnType<typeof buildSeedPayload>> } => Boolean(item.seed));
|
||||
if (seeds.length === 0) {
|
||||
setStatusMessage('所选区域缺少可传播的 polygon 或 bbox');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPropagating(true);
|
||||
setStatusMessage(`${aiModel.toUpperCase()} 正在传播当前区域...`);
|
||||
setStatusMessage(`${aiModel.toUpperCase()} 正在传播 ${seeds.length} 个区域到第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧...`);
|
||||
try {
|
||||
const result = await propagateMasks({
|
||||
project_id: Number(currentProject.id),
|
||||
frame_id: Number(currentFrame.id),
|
||||
model: aiModel,
|
||||
direction: 'forward',
|
||||
max_frames: 30,
|
||||
include_source: false,
|
||||
save_annotations: true,
|
||||
seed: {
|
||||
polygons: seedPayload.mask_data?.polygons,
|
||||
bbox: seedPayload.bbox,
|
||||
points: seedPayload.points,
|
||||
label: seedPayload.mask_data?.label,
|
||||
color: seedPayload.mask_data?.color,
|
||||
class_metadata: seedPayload.mask_data?.class,
|
||||
template_id: seedPayload.template_id,
|
||||
},
|
||||
});
|
||||
let createdCount = 0;
|
||||
let processedCount = 0;
|
||||
for (const { seed } of seeds) {
|
||||
for (const { direction, maxFrames } of propagationDirections) {
|
||||
const result = await propagateMasks({
|
||||
project_id: Number(currentProject.id),
|
||||
frame_id: Number(currentFrame.id),
|
||||
model: aiModel,
|
||||
direction,
|
||||
max_frames: maxFrames,
|
||||
include_source: false,
|
||||
save_annotations: true,
|
||||
seed,
|
||||
});
|
||||
createdCount += result.created_annotation_count;
|
||||
processedCount += result.processed_frame_count;
|
||||
}
|
||||
}
|
||||
await hydrateSavedAnnotations(currentProject.id, frames);
|
||||
setStatusMessage(`已传播并保存 ${result.created_annotation_count} 个区域`);
|
||||
setStatusMessage(`已传播 ${seeds.length} 个 seed,处理 ${processedCount} 帧次,保存 ${createdCount} 个区域`);
|
||||
} catch (err) {
|
||||
console.error('Propagation failed:', err);
|
||||
setStatusMessage('传播失败,请检查模型状态或后端日志');
|
||||
@@ -363,6 +427,16 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
}
|
||||
};
|
||||
|
||||
const handlePropagateAllReachable = () => {
|
||||
if (totalFrames <= 1) {
|
||||
setStatusMessage('当前项目没有可传播的前后帧');
|
||||
return;
|
||||
}
|
||||
setPropagationStartFrame(1);
|
||||
setPropagationEndFrame(totalFrames);
|
||||
void handlePropagateSegment({ startFrameNumber: 1, endFrameNumber: totalFrames });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col bg-[#0a0a0a]">
|
||||
{/* Top Header / Status bar */}
|
||||
@@ -393,12 +467,53 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
>
|
||||
{isImportingGt ? '导入中...' : '导入 GT Mask'}
|
||||
</button>
|
||||
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-2 py-1">
|
||||
<select
|
||||
aria-label="传播对象"
|
||||
value={propagationTarget}
|
||||
onChange={(event) => setPropagationTarget(event.target.value as PropagationTarget)}
|
||||
disabled={isPropagating || isSaving || isExporting || isImportingGt}
|
||||
className="h-6 bg-transparent text-[10px] text-gray-300 outline-none disabled:opacity-40"
|
||||
>
|
||||
<option value="selected">选中区域</option>
|
||||
<option value="all">当前帧全部</option>
|
||||
</select>
|
||||
<span className="text-[10px] text-gray-600">帧</span>
|
||||
<input
|
||||
aria-label="传播起始帧"
|
||||
type="number"
|
||||
min={1}
|
||||
max={Math.max(totalFrames, 1)}
|
||||
value={propagationStartFrame}
|
||||
onChange={(event) => setPropagationStartFrame(clampFrameNumber(Number(event.target.value) || 1))}
|
||||
disabled={isPropagating || isSaving || isExporting || isImportingGt || totalFrames === 0}
|
||||
className="h-6 w-14 rounded bg-black/20 border border-white/10 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40"
|
||||
/>
|
||||
<span className="text-[10px] text-gray-600">-</span>
|
||||
<input
|
||||
aria-label="传播结束帧"
|
||||
type="number"
|
||||
min={1}
|
||||
max={Math.max(totalFrames, 1)}
|
||||
value={propagationEndFrame}
|
||||
onChange={(event) => setPropagationEndFrame(clampFrameNumber(Number(event.target.value) || 1))}
|
||||
disabled={isPropagating || isSaving || isExporting || isImportingGt || totalFrames === 0}
|
||||
className="h-6 w-14 rounded bg-black/20 border border-white/10 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handlePropagateSegment}
|
||||
onClick={() => handlePropagateSegment()}
|
||||
disabled={!currentProject?.id || !currentFrame?.id || isSaving || isExporting || isImportingGt || isPropagating}
|
||||
className="px-4 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-md text-xs transition-colors text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isPropagating ? '传播中...' : '传播片段'}
|
||||
{isPropagating ? '传播中...' : '按范围传播'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePropagateAllReachable}
|
||||
disabled={!currentProject?.id || !currentFrame?.id || totalFrames <= 1 || isSaving || isExporting || isImportingGt || isPropagating}
|
||||
className="px-4 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-md text-xs transition-colors text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
传播全部可达
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportMasks}
|
||||
|
||||
@@ -73,6 +73,15 @@ describe('api client contracts', () => {
|
||||
expect(axiosMock.client.patch).toHaveBeenCalledWith('/api/projects/3', { name: 'Renamed' });
|
||||
});
|
||||
|
||||
it('deletes projects through DELETE', async () => {
|
||||
const { deleteProject } = await import('./api');
|
||||
axiosMock.client.delete.mockResolvedValueOnce({ data: null });
|
||||
|
||||
await expect(deleteProject('3')).resolves.toBeUndefined();
|
||||
|
||||
expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/projects/3');
|
||||
});
|
||||
|
||||
it('normalizes legacy project status values returned by existing databases', async () => {
|
||||
const { getProjects } = await import('./api');
|
||||
axiosMock.client.get.mockResolvedValueOnce({
|
||||
|
||||
Reference in New Issue
Block a user