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:
2026-05-02 00:56:13 +08:00
parent 29a1a87e52
commit 4c21de02f8
20 changed files with 929 additions and 132 deletions

View File

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