From 3dc6c3402edab58905070d7a5001f20570c95842 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Mon, 4 May 2026 01:38:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=B8=85=E7=A9=BA=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E4=BA=BA=E5=B7=A5=E5=B8=A7=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按帧范围清空时检查完整用户选择帧段,确保人工/AI 标注帧会触发二次确认 - 选择删除人工帧时删除这些帧中的全部 mask,选择保留时整帧保留 - 补充人工-only 帧触发弹窗、保留人工帧和删除人工帧的回归测试 - 更新需求冻结和设计冻结文档中的人工帧清空语义 --- doc/07-current-requirements-freeze.md | 4 +- doc/08-current-design-freeze.md | 2 +- src/components/VideoWorkspace.test.tsx | 113 ++++++++++++++++++++++++- src/components/VideoWorkspace.tsx | 22 +++-- 4 files changed, 130 insertions(+), 11 deletions(-) diff --git a/doc/07-current-requirements-freeze.md b/doc/07-current-requirements-freeze.md index 48ae246..33b363a 100644 --- a/doc/07-current-requirements-freeze.md +++ b/doc/07-current-requirements-freeze.md @@ -61,7 +61,7 @@ - Canvas 支持滚轮缩放、移动工具拖拽、鼠标坐标显示。 - Canvas 未选中特定 mask 时,mask 显示顺序必须遵循右侧“语义分类树”拖拽得到的内部覆盖优先级:低优先级先渲染,高优先级后渲染并显示在上层;选中 mask 后可以为了编辑交互临时置顶。 - 时间轴支持缩略图点击切帧、range 拖动切帧、视频处理进度条点击切帧、人工/AI 标注帧和自动传播帧标识点击切帧、键盘左右方向键切帧、播放/暂停顺序推进帧。 -- 顶栏旧“清空片段遮罩”入口已移除;当前清空/DEL 只在目标 mask 存在传播链结果时进入范围选择。用户选择按帧范围清空后,必须复用时间轴范围选择并最终确认;范围内只清空同一传播链自动传播结果,不能清空无关人工绘制或独立 AI 智能分割 mask。按范围清空或清空所有传播帧时,如果目标帧范围内包含人工绘制或独立 AI 智能分割 mask,必须二次询问是否删除人工/AI 标注帧;用户选择否时,这些帧整帧保留,只清空其它自动传播帧。用户取消确认时不能删除本地 mask、后端标注或传播历史条。 +- 顶栏旧“清空片段遮罩”入口已移除;当前清空/DEL 只在目标 mask 存在传播链结果时进入范围选择。用户选择按帧范围清空后,必须复用时间轴范围选择并最终确认;范围内只清空同一传播链自动传播结果,不能清空无关人工绘制或独立 AI 智能分割 mask。按范围清空或清空所有传播帧时,如果目标帧范围内包含人工绘制或独立 AI 智能分割 mask,必须二次询问是否删除人工/AI 标注帧;用户选择是时删除这些人工/AI 标注帧中的全部 mask,用户选择否时这些帧整帧保留,只清空其它自动传播帧。用户取消确认时不能删除本地 mask、后端标注或传播历史条。 - 用户在某帧选中 mask 后,如果切换到同一自动传播结果覆盖的其他帧,工作区应自动识别并选中目标帧中对应的传播 mask;匹配依据为传播结果回显到 mask metadata 的 seed 来源和传播链字段,而不是仅凭标签或颜色。 - 播放帧率使用项目 `parse_fps` 或 `original_fps`,限制在 1 到 30 FPS。 - 时间轴显示当前帧时间和总时长,时间基准使用项目 `parse_fps` 或 `original_fps`,格式为 `mm:ss.cc`。 @@ -152,7 +152,7 @@ - 当前前端保存状态按钮会保存当前项目未保存 mask,并会更新已标记为 dirty 的已保存 mask。 - 如果 dirty mask 携带的本地旧 `annotationId` 在后端已经不存在,前端保存链路必须先用当前后端标注列表做存在性预检,已知缺失的 id 直接用同一几何和 metadata 重新 `POST` 创建标注;如果预检后发生并发删除导致 `PATCH` 返回 404,也必须降级为重新创建,并重新拉取后端标注替换本地旧 id;点击“开始传播”前的参考帧保存也必须复用该容错逻辑,不能因陈旧 id 中断传播。 - 保存成功后,前端会重新拉取后端标注,并用后端 saved annotation 替换本次提交的 draft mask;未提交的其他 draft mask 仍保留。 -- 工作区“清空遮罩”只从左侧工具栏触发;当前帧有选中 mask 时以选中 mask 为对象,没有选中时以当前帧全部 mask 为对象。若目标 mask 没有关联其它传播帧,则直接删除当前帧已保存标注并清空当前帧未保存 mask,不弹确认;若目标 mask 存在传播链上的其它帧结果,则弹出范围确认,用户可在同一行选择“取消”、“只清当前帧”、“按帧范围选择”或“清空所有传播帧”;按帧范围选择进入和自动传播/布尔操作一致的时间轴范围选择模式,并在顶栏“确认清空”后最终确认。清空所有传播帧或范围帧时若目标帧范围包含人工/AI 标注帧,会二次询问是否删除;选择否会保留这些人工/AI 标注帧整帧,只同步清空其它同传播链自动传播结果,不能删除其它帧独立 AI 推理或人工标注 mask。 +- 工作区“清空遮罩”只从左侧工具栏触发;当前帧有选中 mask 时以选中 mask 为对象,没有选中时以当前帧全部 mask 为对象。若目标 mask 没有关联其它传播帧,则直接删除当前帧已保存标注并清空当前帧未保存 mask,不弹确认;若目标 mask 存在传播链上的其它帧结果,则弹出范围确认,用户可在同一行选择“取消”、“只清当前帧”、“按帧范围选择”或“清空所有传播帧”;按帧范围选择进入和自动传播/布尔操作一致的时间轴范围选择模式,并在顶栏“确认清空”后最终确认。清空所有传播帧或范围帧时若目标帧范围包含人工/AI 标注帧,会二次询问是否删除;选择是会删除这些人工/AI 标注帧中的全部 mask,选择否会保留这些人工/AI 标注帧整帧,只同步清空其它同传播链自动传播结果,不能删除其它帧独立 AI 推理或人工标注 mask。 - 工作区加载项目帧后会查询已保存标注并回显。 - 工作区支持导入 GT mask 图片,前端调用 `POST /api/ai/import-gt-mask`。 - 导入 GT Mask 时,前端必须让用户选择未知 maskid 处理策略:舍弃未知类别,或导入为“未定义类别”等待后续重新命名。 diff --git a/doc/08-current-design-freeze.md b/doc/08-current-design-freeze.md index d956f3e..6b319d5 100644 --- a/doc/08-current-design-freeze.md +++ b/doc/08-current-design-freeze.md @@ -158,7 +158,7 @@ 21. 新 mask 会带上当前选择的模板分类元数据,包括 `classId`、`className`、`classZIndex`、`metadata.source=ai_segmentation` 和保存状态 `draft`。 20. 顶栏保存状态按钮按当前项目待保存数量显示为“保存 X 个改动”或“已全部保存”;用户点击保存后,前端将像素 `segmentation` 转成 normalized `mask_data.polygons`;未保存 mask 调用 `POST /api/ai/annotate`,dirty mask 会先读取当前后端标注 id 列表,已知存在的 id 调用 `PATCH /api/ai/annotations/{annotation_id}`,已知缺失的本地旧 id 直接保留同一 `mask_data`、几何、分类和传播 lineage metadata 改用 `POST /api/ai/annotate` 重新创建;如果预检后发生并发删除导致 `PATCH` 返回 404,也会降级为重新创建,并在随后回显时排除本地旧 mask id;保存成功后本次提交的 draft mask id 会从本地保留列表中排除,并由后端 saved annotation 回显替换。 21. 工作区加载项目帧后通过 `GET /api/ai/annotations` 取回已保存标注并转成前端 mask。 -22. 工作区“清空遮罩”只从左侧工具栏触发;如果当前帧存在选中 mask,则以当前帧选中 mask 为清空对象,否则以当前帧全部 mask 为清空对象。如果清空对象没有关联其它传播帧,直接删除当前帧已保存标注并清除当前帧本地 mask,不弹确认;如果存在传播链结果,`VideoWorkspace` 弹出范围选择,用户可在同一行选择取消、只清当前帧、按帧范围选择或清空当前帧及同传播链所有自动传播帧;按帧范围选择复用时间轴范围选择并在顶栏“确认清空”后最终确认。按范围清空或清空所有传播帧时,如果目标帧范围包含人工/AI 标注帧,会二次询问是否删除;选择否时这些帧整帧保留,只清其它自动传播帧。本操作不删除其它帧独立 AI 推理或人工 mask。左侧工具栏的 `DEL` 按钮和键盘 Delete/Backspace 删除整块 mask 时复用同一传播链范围确认;删除已保存标注前会通过 `GET /api/ai/annotations` 预检当前项目仍存在的 annotation id,只对存在的 id 发送 `DELETE`。 +22. 工作区“清空遮罩”只从左侧工具栏触发;如果当前帧存在选中 mask,则以当前帧选中 mask 为清空对象,否则以当前帧全部 mask 为清空对象。如果清空对象没有关联其它传播帧,直接删除当前帧已保存标注并清除当前帧本地 mask,不弹确认;如果存在传播链结果,`VideoWorkspace` 弹出范围选择,用户可在同一行选择取消、只清当前帧、按帧范围选择或清空当前帧及同传播链所有自动传播帧;按帧范围选择复用时间轴范围选择并在顶栏“确认清空”后最终确认。按范围清空或清空所有传播帧时,如果目标帧范围包含人工/AI 标注帧,会二次询问是否删除;选择是会删除这些人工/AI 标注帧中的全部 mask,选择否时这些帧整帧保留,只清其它自动传播帧。本操作不删除其它帧独立 AI 推理或人工 mask。左侧工具栏的 `DEL` 按钮和键盘 Delete/Backspace 删除整块 mask 时复用同一传播链范围确认;删除已保存标注前会通过 `GET /api/ai/annotations` 预检当前项目仍存在的 annotation id,只对存在的 id 发送 `DELETE`。 ### 视频片段传播 diff --git a/src/components/VideoWorkspace.test.tsx b/src/components/VideoWorkspace.test.tsx index a627069..0829c23 100644 --- a/src/components/VideoWorkspace.test.tsx +++ b/src/components/VideoWorkspace.test.tsx @@ -1035,11 +1035,122 @@ describe('VideoWorkspace', () => { expect(screen.getByText('是否删除人工/AI 标注帧')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '否,保留人工帧' })); - await waitFor(() => expect(screen.getByText('已保留人工/AI 标注帧,没有可清空的自动传播遮罩')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('已保留人工/AI 标注帧,本次没有其它可清空的自动传播遮罩')).toBeInTheDocument()); expect(apiMock.deleteAnnotation).not.toHaveBeenCalled(); expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10', 'annotation-11', 'annotation-20']); }); + it('asks about manual-only frames inside the selected clear range before clearing propagated masks', 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(); + await waitFor(() => expect(useStore.getState().frames).toHaveLength(3)); + act(() => { + useStore.setState({ + masks: [ + { id: 'annotation-1', annotationId: '1', frameId: '10', pathData: 'M 0 0 Z', label: 'Seed', color: '#06b6d4', saved: true, saveStatus: 'saved' }, + { + id: 'annotation-11', + annotationId: '11', + frameId: '11', + pathData: 'M 4 4 Z', + label: 'Manual edit', + color: '#f97316', + saved: true, + saveStatus: 'saved', + }, + { + id: 'annotation-20', + annotationId: '20', + frameId: '12', + pathData: 'M 2 2 Z', + label: 'Propagated', + color: '#06b6d4', + saved: true, + saveStatus: 'saved', + metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' }, + }, + ], + }); + }); + + fireEvent.click(screen.getByTitle('清空遮罩')); + fireEvent.click(screen.getByRole('button', { name: '按帧范围选择' })); + fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '2' } }); + fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '3' } }); + fireEvent.click(screen.getByRole('button', { name: '确认清空' })); + const confirmButtons = screen.getAllByRole('button', { name: '确认清空' }); + fireEvent.click(confirmButtons[confirmButtons.length - 1]); + + expect(screen.getByText('是否删除人工/AI 标注帧')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '否,保留人工帧' })); + + await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('20')); + expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('11'); + expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-11']); + }); + + it('deletes all masks on manual frames when the user confirms manual-frame clearing', 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(); + await waitFor(() => expect(useStore.getState().frames).toHaveLength(3)); + act(() => { + useStore.setState({ + masks: [ + { id: 'annotation-1', annotationId: '1', frameId: '10', pathData: 'M 0 0 Z', label: 'Seed', color: '#06b6d4', saved: true, saveStatus: 'saved' }, + { + id: 'annotation-10', + annotationId: '10', + frameId: '11', + pathData: 'M 1 1 Z', + label: 'Propagated', + color: '#06b6d4', + saved: true, + saveStatus: 'saved', + metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' }, + }, + { id: 'annotation-11', annotationId: '11', frameId: '11', pathData: 'M 4 4 Z', label: 'Manual edit', color: '#f97316', saved: true, saveStatus: 'saved' }, + { + id: 'annotation-20', + annotationId: '20', + frameId: '12', + pathData: 'M 2 2 Z', + label: 'Propagated', + color: '#06b6d4', + saved: true, + saveStatus: 'saved', + metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' }, + }, + ], + }); + }); + + fireEvent.click(screen.getByTitle('清空遮罩')); + fireEvent.click(screen.getByRole('button', { name: '按帧范围选择' })); + fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '2' } }); + fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '3' } }); + fireEvent.click(screen.getByRole('button', { name: '确认清空' })); + const confirmButtons = screen.getAllByRole('button', { name: '确认清空' }); + fireEvent.click(confirmButtons[confirmButtons.length - 1]); + fireEvent.click(screen.getByRole('button', { name: '是,删除人工帧' })); + + await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10')); + expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('11'); + expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('20'); + expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1']); + }); + it('auto-saves pending masks before exporting segmentation results', async () => { apiMock.getProjectFrames.mockResolvedValueOnce([ { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 }, diff --git a/src/components/VideoWorkspace.tsx b/src/components/VideoWorkspace.tsx index f98f5a2..7cfdfa0 100644 --- a/src/components/VideoWorkspace.tsx +++ b/src/components/VideoWorkspace.tsx @@ -943,15 +943,16 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void const requestClearMasksWithManualFrameConfirm = useCallback(( maskIdsToClear: string[], messageScope: string, - options?: { resetRangeAfterClear?: boolean }, + options?: { manualFrameScopeIds?: string[]; resetRangeAfterClear?: boolean }, ) => { const latestMasks = useStore.getState().masks; const targetMaskIdSet = new Set(maskIdsToClear); const targetMasks = latestMasks.filter((mask) => targetMaskIdSet.has(mask.id)); const targetFrameIds = new Set(targetMasks.map((mask) => String(mask.frameId))); + const manualScopeFrameIds = new Set((options?.manualFrameScopeIds || Array.from(targetFrameIds)).map(String)); const manualFrameIds = new Set( latestMasks - .filter((mask) => targetFrameIds.has(String(mask.frameId)) && !isPropagatedMask(mask)) + .filter((mask) => manualScopeFrameIds.has(String(mask.frameId)) && !isPropagatedMask(mask)) .map((mask) => String(mask.frameId)), ); @@ -966,13 +967,17 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void .map((frameId) => frameNumberById.get(frameId)) .filter((frameNumber): frameNumber is number => Boolean(frameNumber)) .sort((a, b) => a - b); + const manualFrameMaskIds = latestMasks + .filter((mask) => manualFrameIds.has(String(mask.frameId))) + .map((mask) => mask.id); const autoOnlyMaskIds = targetMasks .filter((mask) => !manualFrameIds.has(String(mask.frameId))) .map((mask) => mask.id); + const allMaskIds = Array.from(new Set([...autoOnlyMaskIds, ...manualFrameMaskIds])); setPendingCurrentClearConfirm(null); setPendingClearManualFrameConfirm({ - allMaskIds: maskIdsToClear, + allMaskIds, autoOnlyMaskIds, manualFrameNumbers, messageScope, @@ -989,7 +994,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void if (targetMaskIds.length === 0) { setPendingClearManualFrameConfirm(null); if (pendingClearManualFrameConfirm.resetRangeAfterClear) resetClearPropagationRangeSelection(); - setStatusMessage('已保留人工/AI 标注帧,没有可清空的自动传播遮罩'); + setStatusMessage('已保留人工/AI 标注帧,本次没有其它可清空的自动传播遮罩'); return; } @@ -1067,13 +1072,16 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }, [frames, pendingClearPropagationRangeRequest, propagationEndFrame, propagationStartFrame, totalFrames]); const executeClearPropagationFrameRange = useCallback(async (confirmState: ClearPropagationRangeConfirmState) => { + const manualFrameScopeIds = frames + .slice(confirmState.rangeStartIndex, confirmState.rangeEndIndex + 1) + .map((frame) => String(frame.id)); requestClearMasksWithManualFrameConfirm( confirmState.targetMaskIds, `第 ${confirmState.rangeStartIndex + 1}-${confirmState.rangeEndIndex + 1} 帧传播链`, - { resetRangeAfterClear: true }, + { manualFrameScopeIds, resetRangeAfterClear: true }, ); setPendingClearPropagationRangeConfirm(null); - }, [requestClearMasksWithManualFrameConfirm]); + }, [frames, requestClearMasksWithManualFrameConfirm]); const handleBooleanFrameRangeRequest = useCallback((request: BooleanFrameRangeRequest) => { const candidateFrameNumbers = request.candidateFrameIds @@ -2272,7 +2280,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void 是否同时删除这些帧中的遮罩?

- 选择保留时,这些人工/AI 标注帧会整帧保留,只清空其它自动传播帧。 + 选择删除时会删除这些帧中的全部遮罩;选择保留时,这些人工/AI 标注帧会整帧保留,只清空其它自动传播帧。