From 45839a2e4c2a4310dd931578109fd63317215ce7 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Mon, 4 May 2026 01:26:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B8=85=E7=A9=BA=E4=BC=A0?= =?UTF-8?q?=E6=92=AD=E5=B8=A7=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 - DEL 和清空遮罩在清空所有传播帧时检测人工/AI 标注帧并二次确认 - 按帧范围清空传播链时检测范围内人工/AI 标注帧,支持选择否后整帧保留 - 保留人工帧时只清其它自动传播帧,避免人工帧被局部掏空 - 补充清空所有传播帧和范围清空的人工帧保留回归测试 - 更新项目指南、实现地图、前端审计、需求冻结、设计冻结和测试计划文档 --- AGENTS.md | 2 +- doc/02-current-implementation-map.md | 2 +- doc/03-frontend-element-audit.md | 2 +- doc/07-current-requirements-freeze.md | 4 +- doc/08-current-design-freeze.md | 2 +- doc/09-test-plan.md | 4 +- src/components/VideoWorkspace.test.tsx | 114 +++++++++++++++++++++ src/components/VideoWorkspace.tsx | 132 +++++++++++++++++++++++-- 8 files changed, 247 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 95bdd14..214c447 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -267,7 +267,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload - 右侧实例属性面板“边缘平滑强度/应用边缘平滑”已接入 `POST /api/ai/smooth-mask`;滑杆会即时更新数值,但后端预览请求有短防抖,避免拖动时连续请求卡顿;预览不写入撤销历史也不标 dirty;点击应用后会把返回 polygon 作为新的实际 mask 几何写入当前 mask 和同传播链前后对应 mask,整次应用作为一个撤销/重做历史步骤,相关 mask 标记为 dirty/draft,平滑强度重置为 0,用户可继续用 polygon 编辑工具调整新多边形。 - 工作区“自动传播”按钮位于左侧工具栏橡皮擦下方,并已接入 `POST /api/ai/propagate/task`;若用户尚未显式设置范围,第一次点击会进入时间轴范围选择模式,顶栏才显示传播权重和向前/向后帧数,第二次点击“开始传播”才提交后台任务;当前启用所选 SAM 2.1 变体的视频 predictor 后台任务,运行中轮询任务进度,完成后刷新后端已保存标注;同一参考帧多个同类别 seed 会按来源 id 分开传播,不会因 label/color 相同互相覆盖;中空 seed 会把内洞传给后端,SAM 2 seed mask 栅格化时扣除内洞,传播结果保存时也会保留 `holes`;GPU/CPU 模型状态只在左侧 Sidebar 底部用紧凑徽标展示,工作区顶栏不再重复显示,具体 SAM 2.1 传播权重由顶栏下拉选择;同步 `POST /api/ai/propagate` 仍作为单 seed 兼容接口保留。 - 工作区顶栏短状态会自动消失;保存、导出、导入 GT、传播进行中和无帧项目提示会保留到状态变化。 -- 工作区“清空遮罩”和左侧 `DEL` 删除只从左侧工具栏或键盘触发,会在删除已保存标注前预检当前项目仍存在的 annotation id,只对存在的 id 调用 `DELETE /api/ai/annotations/{id}`;如果当前帧有选中 mask 则优先清/删选中 mask,没有选中时清当前帧全部 mask;如果对象没关联其它传播帧则直接处理当前帧,如果存在传播链结果则弹窗在同一行选择取消、只处理当前帧、按帧范围选择或清空所有传播帧;按帧范围选择复用工作区时间轴范围选择和最终确认弹窗;工作区顶栏不再提供重复的“清空片段遮罩”;不会删除其它帧独立 AI 推理或人工标注 mask。 +- 工作区“清空遮罩”和左侧 `DEL` 删除只从左侧工具栏或键盘触发,会在删除已保存标注前预检当前项目仍存在的 annotation id,只对存在的 id 调用 `DELETE /api/ai/annotations/{id}`;如果当前帧有选中 mask 则优先清/删选中 mask,没有选中时清当前帧全部 mask;如果对象没关联其它传播帧则直接处理当前帧,如果存在传播链结果则弹窗在同一行选择取消、只处理当前帧、按帧范围选择或清空所有传播帧;按帧范围选择复用工作区时间轴范围选择和最终确认弹窗;按范围清空或清空所有传播帧时,如果目标帧范围内包含人工绘制或独立 AI 标注帧,会再提示是否删除人工/AI 标注帧,选择否时整帧保留,只清其它自动传播帧;工作区顶栏不再提供重复的“清空片段遮罩”。 - 项目状态已统一为 `pending`、`parsing`、`ready`、`error`;前端 `src/lib/api.ts` 会兼容归一化旧库中可能存在的 `Ready`、`Parsing`、`Error`。 - 项目库的视频导入与生成帧是两个独立动作:导入视频只上传源文件,并通过 Axios `onUploadProgress` 在项目库显示导入进度;生成帧按钮才会带 `parse_fps` 调用 `/api/media/parse`;DICOM 批量导入也会显示上传进度和文件数量,上传完成后创建解析任务并轮询显示解析进度。工作区不会再因“有视频但无帧”自动创建拆帧任务。 - `server.ts` 不再提供旧版 `/api/login`、`/api/projects`、`/api/templates` mock;当前前端真实 API 调用走 FastAPI 的 `/api/auth/*`、`/api/projects`、`/api/templates` 等接口。 diff --git a/doc/02-current-implementation-map.md b/doc/02-current-implementation-map.md index 0edd502..af7b73a 100644 --- a/doc/02-current-implementation-map.md +++ b/doc/02-current-implementation-map.md @@ -111,6 +111,6 @@ - 前端 API/WS 地址虽然已支持环境变量和 hostname 推导,但部署时仍需要确认浏览器可访问 `:8000` 后端。 - AI 当前启用 SAM 2.1 tiny/small/base+/large 点/框/interactive 路径;语义文本提示和 SAM 3 产品入口已禁用,`model=sam3` 会被后端拒绝。SAM 3 源码保留但不计入当前可用功能。 -- 工作区顶部“分割结果导出”和保存状态按钮、左侧工具栏“导入 GT Mask”已接入统一导出、GT 多类别导入、标注新增和 dirty 标注更新;导入 GT Mask 仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,未知 maskid 可由用户选择舍弃或导入为未定义类别,16-bit/uint16 GT_label 和普通彩色类别图会被拒绝,尺寸不同会自动最近邻拉伸到当前帧;GT 连通域会生成高精度 polygon,导入后和普通 mask 一样不显示黄色 seed point,并与普通 mask 共用拓扑统计、边缘平滑、编辑和保存链路。保存状态按钮会按待保存数量显示“保存 X 个改动”或“已全部保存”;统一导出可选择整体视频、特定范围帧或当前图片,并勾选分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;特定范围帧导出支持直接输入起止帧,也支持在播放进度条或视频处理进度条上点击/拖拽选择范围;Mix_label 支持默认 0.3 的透明度调节和首帧预览;后端统一导出 ZIP 固定包含 maskid/GT 像素值映射 JSON 与原始图片文件夹,GT_label 固定输出 8-bit uint8 PNG,像素值使用类别真实 maskid,其中 `maskid:0` 的“待分类”和背景同为 0,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出,并按客户命名规则输出分开 Mask、GT_label、Pro_label 和 Mix_label 文件夹;清空当前帧遮罩会删除对应后端标注,存在传播链时同一弹窗提供取消/当前帧/按帧范围选择/所有传播帧,按范围清空复用时间轴范围选择和最终确认。手工绘制、polygon 顶点拖动/删除、区域合并/去除和撤销重做已经落到前端 mask 数据结构;无选中分类的多边形/矩形/圆会默认归入 `maskid:0` 的“待分类”。 +- 工作区顶部“分割结果导出”和保存状态按钮、左侧工具栏“导入 GT Mask”已接入统一导出、GT 多类别导入、标注新增和 dirty 标注更新;导入 GT Mask 仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,未知 maskid 可由用户选择舍弃或导入为未定义类别,16-bit/uint16 GT_label 和普通彩色类别图会被拒绝,尺寸不同会自动最近邻拉伸到当前帧;GT 连通域会生成高精度 polygon,导入后和普通 mask 一样不显示黄色 seed point,并与普通 mask 共用拓扑统计、边缘平滑、编辑和保存链路。保存状态按钮会按待保存数量显示“保存 X 个改动”或“已全部保存”;统一导出可选择整体视频、特定范围帧或当前图片,并勾选分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;特定范围帧导出支持直接输入起止帧,也支持在播放进度条或视频处理进度条上点击/拖拽选择范围;Mix_label 支持默认 0.3 的透明度调节和首帧预览;后端统一导出 ZIP 固定包含 maskid/GT 像素值映射 JSON 与原始图片文件夹,GT_label 固定输出 8-bit uint8 PNG,像素值使用类别真实 maskid,其中 `maskid:0` 的“待分类”和背景同为 0,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出,并按客户命名规则输出分开 Mask、GT_label、Pro_label 和 Mix_label 文件夹;清空当前帧遮罩会删除对应后端标注,存在传播链时同一弹窗提供取消/当前帧/按帧范围选择/所有传播帧,按范围清空复用时间轴范围选择和最终确认;按范围或全部清空遇到人工/AI 标注帧时会二次确认,选择保留则整帧保留。手工绘制、polygon 顶点拖动/删除、区域合并/去除和撤销重做已经落到前端 mask 数据结构;无选中分类的多边形/矩形/圆会默认归入 `maskid:0` 的“待分类”。 - Dashboard 初始统计、队列和活动日志来自后端聚合接口;解析队列来自 `processing_tasks`,worker 进度通过 Redis `seg:progress` 转发到 WebSocket。任务取消、重试和失败详情已接入前后端。 - 后端已接入 Bearer JWT 鉴权、当前用户项目隔离和角色权限;写入类业务接口要求 `admin/annotator`,管理员用户后台要求 `admin`。当前审计覆盖登录和用户管理操作,全业务级审计仍可继续扩展。 diff --git a/doc/03-frontend-element-audit.md b/doc/03-frontend-element-audit.md index b399458..115f91c 100644 --- a/doc/03-frontend-element-audit.md +++ b/doc/03-frontend-element-audit.md @@ -102,7 +102,7 @@ | 传播链跨帧选区跟随 | 真实可用 | 用户选中某个 mask 后切到同一自动传播结果覆盖的其他帧时,`CanvasArea` 会根据 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key` 查找目标帧对应传播 mask 并自动选中;找不到同链结果时才清空选区 | | Polygon 逐点编辑 / 删除 | 真实可用 | 点击 mask 后显示 polygon 顶点;多 polygon 或分离区域组成的同一个 mask 会显示所有子区域顶点,不再只显示主区域;按住顶点即可直接拖动并实时重算 `pathData/segmentation/bbox/area`,不需要先单击选中顶点,已保存 mask 标为 dirty;顶点拖拽结束不会触发 Stage 平移,Canvas 当前缩放和位置保持不变;选中顶点后 Delete/Backspace 可删点但保留至少三点;选中 mask 但未选中顶点时 Delete/Backspace 删除整个 mask,左侧 DEL 按钮复用同一链路;已保存 mask 删除前会预检当前后端 annotation id 并只删除仍存在的 id,避免陈旧本地 id 产生 DELETE 404;若删除对象是传播 seed 或传播结果,前端会按 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key` 同步删除同链自动传播 mask,但不删除其他帧独立 AI 推理/人工 mask | | 应用分类 | 真实可用 | Canvas 右下角不再提供“应用分类”快捷按钮,避免没选区时误改整帧;右侧语义分类树点击分类时会优先改当前已选 mask,并通过 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key` 同步更新同一传播链上的前后传播 mask,同时把已选 mask 移到前端渲染最上层方便继续编辑;已保存 mask 会标为 dirty,归档保存时更新后端 | -| 清空遮罩 | 真实可用 | 工作区只通过左侧工具栏触发清空;当前帧有选中 mask 时清选中 mask,没有选中时清当前帧全部 mask;无传播链结果时直接执行,存在传播链结果时弹窗选择取消、只清当前帧、按帧范围选择或清空所有传播帧;按帧范围选择复用时间轴范围选择和最终确认;不会删除其它帧独立 AI 推理或人工 mask | +| 清空遮罩 | 真实可用 | 工作区只通过左侧工具栏触发清空;当前帧有选中 mask 时清选中 mask,没有选中时清当前帧全部 mask;无传播链结果时直接执行,存在传播链结果时弹窗选择取消、只清当前帧、按帧范围选择或清空所有传播帧;按帧范围选择复用时间轴范围选择和最终确认;按范围清空或清空所有传播帧时若目标范围包含人工/AI 标注帧,会二次确认是否删除,选择否会整帧保留 | | 保存状态计数 | 真实可用 | 底部显示已保存、未保存、待更新数量 | | 当前图层信息 | 真实可用 | 根据当前选中 mask 显示真实标签/后端 annotation id;未保存 mask 显示“未保存”,未选中时显示“未选择” | diff --git a/doc/07-current-requirements-freeze.md b/doc/07-current-requirements-freeze.md index 593f7eb..48ae246 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。用户取消确认时不能删除本地 mask、后端标注或传播历史条。 +- 顶栏旧“清空片段遮罩”入口已移除;当前清空/DEL 只在目标 mask 存在传播链结果时进入范围选择。用户选择按帧范围清空后,必须复用时间轴范围选择并最终确认;范围内只清空同一传播链自动传播结果,不能清空无关人工绘制或独立 AI 智能分割 mask。按范围清空或清空所有传播帧时,如果目标帧范围内包含人工绘制或独立 AI 智能分割 mask,必须二次询问是否删除人工/AI 标注帧;用户选择否时,这些帧整帧保留,只清空其它自动传播帧。用户取消确认时不能删除本地 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 推理或人工标注 mask。 +- 工作区“清空遮罩”只从左侧工具栏触发;当前帧有选中 mask 时以选中 mask 为对象,没有选中时以当前帧全部 mask 为对象。若目标 mask 没有关联其它传播帧,则直接删除当前帧已保存标注并清空当前帧未保存 mask,不弹确认;若目标 mask 存在传播链上的其它帧结果,则弹出范围确认,用户可在同一行选择“取消”、“只清当前帧”、“按帧范围选择”或“清空所有传播帧”;按帧范围选择进入和自动传播/布尔操作一致的时间轴范围选择模式,并在顶栏“确认清空”后最终确认。清空所有传播帧或范围帧时若目标帧范围包含人工/AI 标注帧,会二次询问是否删除;选择否会保留这些人工/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 925913c..f2053a9 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 推理或人工 mask。左侧工具栏的 `DEL` 按钮和键盘 Delete/Backspace 删除整块 mask 时复用同一传播链范围确认;删除已保存标注前会通过 `GET /api/ai/annotations` 预检当前项目仍存在的 annotation id,只对存在的 id 发送 `DELETE`。 +22. 工作区“清空遮罩”只从左侧工具栏触发;如果当前帧存在选中 mask,则以当前帧选中 mask 为清空对象,否则以当前帧全部 mask 为清空对象。如果清空对象没有关联其它传播帧,直接删除当前帧已保存标注并清除当前帧本地 mask,不弹确认;如果存在传播链结果,`VideoWorkspace` 弹出范围选择,用户可在同一行选择取消、只清当前帧、按帧范围选择或清空当前帧及同传播链所有自动传播帧;按帧范围选择复用时间轴范围选择并在顶栏“确认清空”后最终确认。按范围清空或清空所有传播帧时,如果目标帧范围包含人工/AI 标注帧,会二次询问是否删除;选择否时这些帧整帧保留,只清其它自动传播帧。本操作不删除其它帧独立 AI 推理或人工 mask。左侧工具栏的 `DEL` 按钮和键盘 Delete/Backspace 删除整块 mask 时复用同一传播链范围确认;删除已保存标注前会通过 `GET /api/ai/annotations` 预检当前项目仍存在的 annotation id,只对存在的 id 发送 `DELETE`。 ### 视频片段传播 diff --git a/doc/09-test-plan.md b/doc/09-test-plan.md index b4bbbcf..c195fa0 100644 --- a/doc/09-test-plan.md +++ b/doc/09-test-plan.md @@ -17,7 +17,7 @@ | R1 登录与会话 | `src/components/Login.test.tsx`, `src/components/Sidebar.test.tsx`, `src/components/UserAdmin.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_auth.py`, `backend/tests/test_admin.py` | 成功登录、JWT/token 写入、当前用户写入、刷新恢复基础状态、失败提示、登录输入 autocomplete、后端 401、`/api/auth/me`、管理员入口、用户 CRUD、角色权限、审计日志、viewer 读写权限边界、改密码/删除用户站内确认、演示出厂设置站内二次确认和重置结果 | | R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目卡片复制/删除、修改项目名称时隐藏生成帧、DICOM 项目不显示生成帧、复制项目 reset/full 契约、DELETE 契约、后端 CRUD、删除级联、帧列表、项目按当前 JWT 用户隔离 | | R3 媒体上传与拆帧 | `src/components/ProjectLibrary.test.tsx`, `src/components/TransientNotice.test.tsx`, `backend/tests/test_media.py`, `backend/tests/test_tasks.py` | 视频导入不自动拆帧、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式生成帧 FPS 选择、项目卡片显示目标 parse_fps 而非原视频 FPS、扩展名校验、自动建项目、关联项目、创建异步任务、非阻塞自动消失操作提示、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 | -| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/AI 画布底图默认居中且保留边距、工作区 mask 透明度、回显已保存标注时保留本地未保存 draft mask、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏清空遮罩优先作用于当前帧选中 mask/无选中时作用于当前帧全部 mask、无传播链时直接执行、有传播链时可选取消/只清当前帧/按帧范围选择/清空所有传播帧且按范围清空需最终确认、顶栏不显示重复的清空片段遮罩、传播链布尔操作按帧范围选择并二次确认、清空/删除前预检后端 annotation id 并跳过本地陈旧 id、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、自动传播范围选择时显示传播权重和向前/向后帧数、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧通过 source/lineage metadata 识别为蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进纯色显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播/布尔/清空范围边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条和视频处理进度条选择传播/布尔/清空范围、左右方向键切帧、播放、按项目 FPS 显示当前/总时长 | +| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/AI 画布底图默认居中且保留边距、工作区 mask 透明度、回显已保存标注时保留本地未保存 draft mask、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏清空遮罩优先作用于当前帧选中 mask/无选中时作用于当前帧全部 mask、无传播链时直接执行、有传播链时可选取消/只清当前帧/按帧范围选择/清空所有传播帧且按范围清空需最终确认、按范围清空或清空所有传播帧遇到人工/AI 标注帧时二次询问并支持保留人工帧、顶栏不显示重复的清空片段遮罩、传播链布尔操作按帧范围选择并二次确认、清空/删除前预检后端 annotation id 并跳过本地陈旧 id、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、自动传播范围选择时显示传播权重和向前/向后帧数、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧通过 source/lineage metadata 识别为蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进纯色显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播/布尔/清空范围边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条和视频处理进度条选择传播/布尔/清空范围、左右方向键切帧、播放、按项目 FPS 显示当前/总时长 | | R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/lib/keyboardShortcuts.test.ts`, `src/store/useStore.test.ts` | 工具切换、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、清空遮罩唯一左侧工具栏入口、清空遮罩上方 DEL 删除按钮、橡皮擦下方彩色 AI 自动传播入口、Canvas 右下角不再重复显示清空遮罩或应用分类按钮、GT Mask 导入位于清空遮罩分隔线之后且使用紫色底色、工具栏分隔线位于创建圆后、自动传播后和清空遮罩后、GT Mask 未知类别导入策略选择、工作区工具栏不展示 AI 正/反点和框选、左侧工具栏不重复撤销/重做、左侧工具栏不展示创建点/创建线段、矩形/圆/多边形手工 mask 绘制且未选分类时默认待分类、普通/导入 polygon mask 不显示黄色 seed point、画笔/橡皮擦尺寸控制、画笔新建当前类别 mask、画笔与选中 mask 连通时自动合并、橡皮擦从选中 mask 扣除、未选中 mask 时画布按语义分类树内部优先级渲染、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合且数秒后自动隐藏、polygon 顶点直接拖动/删除、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、多 polygon/分离区域全部显示编辑顶点、中空 mask 内洞 ring 顶点和插点可编辑、整块 mask 删除、DEL 和 Delete/Backspace 删除共用传播链范围确认、同帧传播链分散 mask 点选联动高亮、传播链自动传播 mask 随 seed/传播结果删除、独立 AI 推理 mask 不被误删、区域合并/去除存在传播帧时弹窗选择当前帧/所有传播帧/按帧范围选择、范围确认前重新开始当前帧布尔操作会取消旧顶栏范围请求、区域合并/去除按帧范围同步到对应传播帧且保留传播 metadata、布尔选择主区域/扣除区域视觉区分和选择顺序提示、内含去除 hole 渲染和 ring 分组保存、合并模式隐藏编辑手柄、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键 Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z、Ctrl/Cmd+Y、物理键码 fallback 和输入框快捷键跳过、撤销/重做历史栈 | | R6 AI 推理 | `src/lib/api.test.ts`, `src/components/CanvasArea.test.tsx`, `src/components/AISegmentation.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/components/ModelStatusBadge.test.tsx`, `backend/tests/test_ai.py`, `backend/tests/test_sam2_engine.py` | SAM 2.1 变体选择、点/框/interactive 契约、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、SAM 2.1 框选后正负点细化同一候选 mask、AI 页框选发送 box prompt、AI 页框选后加点发送 interactive prompt、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、SAM 2.1 反向点启用背景过滤且空结果移除旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可单点删除提示点并删除最近锚点、AI 页可删除选中候选且不删除工作区 mask、AI 页清空只移除本页候选、AI 页参数开关可读性文案且 options 字段不变、AI 页/右侧共享遮罩透明度只改预览 opacity、AI 页生成 mask 自动选中并可通过分类树换标签、AI 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频以当前参考帧全部 mask 和起止帧范围自动传播、同类多实例按来源 id 分开传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播创建 Celery 任务、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、中空传播 seed 扣除 holes 后注入 SAM 2 且传播结果保留 holes、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、传播中轮询任务进度、传播任务取消/重试、传播来源 metadata 回显、空提示/空结果反馈、GPU/SAM2.1 状态、AI 参数 options、局部裁剪推理、背景过滤、状态徽标、坐标归一化、正负点 labels、polygons 转 path、后端 fake registry | | R7 标注保存 | `src/components/VideoWorkspace.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_ai.py` | 保存状态按钮“保存 X 个改动/已全部保存”、保存标注、保存后用后端 saved annotation 替换已提交 draft、加载回显、更新 dirty 标注、dirty 本地旧 annotationId 预检缺失时直接重新 POST 创建、预检后 PATCH 404 时重新 POST 创建并回显替换、中空 mask 保存为 `polygons` + `holes` 并可回显为 ring 分组、清空删除已保存标注、GT mask 多类别导入、高精度 GT contour、导入 mask 可直接拓扑统计和边缘平滑、后端 seed point 归一化兼容但前端不显示或拖动、缺失 seed point 的普通 polygon 保存时自动写入代表点、项目不存在、帧不存在 | @@ -64,7 +64,7 @@ - R6:补充传播去重回归测试,验证前端传播前会先保存 draft seed mask 并用稳定 `source_annotation_id` 入队;后端在 seed 来源由前端临时 id 迁移到后端 annotation id、用户换用其他 SAM 2.1 权重、未编辑传播结果再次作为 seed、已编辑传播结果重新作为 seed、中间帧人工新增替代 seed 时,会分别跳过或清理旧传播标注再保存新结果。 - R5/R6/R7:补充中空 mask 回归测试,验证保存时拆分 `polygons`/`holes` 并回显为 ring 分组,调整多边形时内洞显示可编辑顶点,以及 SAM 2 seed mask 会扣除 holes、传播结果轮廓提取会保留 holes。 - R7:补充 dirty 本地旧 annotationId 回归测试,验证后端标注 id 预检已缺失时会跳过失败 PATCH、直接 `POST /api/ai/annotate` 重新创建;同时验证预检后 `PATCH /api/ai/annotations/{id}` 返回 404 时,保存链路也会改用 `POST` 重新创建并用回显标注替换本地旧 mask。 -- R4/R5/R8/R9:补充模板切换、工具栏清空入口和传播链布尔操作回归测试,验证已有 mask 切换模板需确认清空,模板详情按钮改为“编辑模板”,当前帧清空会在传播链存在时同一行提供取消/只清当前帧/按帧范围选择/清空所有传播帧,区域合并/去除会在存在传播帧时同一行选择取消/按帧范围选择/当前帧/所有传播帧并保留传播 metadata。 +- R4/R5/R8/R9:补充模板切换、工具栏清空入口和传播链布尔操作回归测试,验证已有 mask 切换模板需确认清空,模板详情按钮改为“编辑模板”,当前帧清空会在传播链存在时同一行提供取消/只清当前帧/按帧范围选择/清空所有传播帧,且按范围/全部清空遇到人工/AI 标注帧时可选择保留人工帧,区域合并/去除会在存在传播帧时同一行选择取消/按帧范围选择/当前帧/所有传播帧并保留传播 metadata。 - R6:`backend/tests/test_sam3_engine.py` 已标记跳过,仅作为历史保留实现的参考测试,不计入当前产品功能覆盖。 - R3:补充 `parseMedia()` 查询参数和后端拆帧任务 payload 测试,验证 `parse_fps`、`max_frames`、`target_width` 会进入任务。 - R3:补充 `ProjectLibrary.test.tsx` 和 `api.test.ts` 中上传进度测试,验证视频/DICOM 上传通过 Axios `onUploadProgress` 回调更新项目库导入进度条,并显示 DICOM 文件数量和解析任务轮询进度。 diff --git a/src/components/VideoWorkspace.test.tsx b/src/components/VideoWorkspace.test.tsx index 3835969..a627069 100644 --- a/src/components/VideoWorkspace.test.tsx +++ b/src/components/VideoWorkspace.test.tsx @@ -679,6 +679,8 @@ describe('VideoWorkspace', () => { fireEvent.click(screen.getByTitle('清空遮罩')); expect(screen.getByText('选择清空范围')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '清空所有传播帧' })); + expect(screen.getByText('是否删除人工/AI 标注帧')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '是,删除人工帧' })); await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('1')); expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10'); @@ -686,6 +688,53 @@ describe('VideoWorkspace', () => { expect(useStore.getState().selectedMaskIds).toEqual([]); }); + it('can keep manual frames when clearing all propagated masks', 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 }, + ]); + apiMock.deleteAnnotation.mockResolvedValue(undefined); + + render(); + await waitFor(() => expect(useStore.getState().frames).toHaveLength(2)); + 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' }, + }, + ], + }); + }); + + fireEvent.click(screen.getByTitle('清空遮罩')); + fireEvent.click(screen.getByRole('button', { name: '清空所有传播帧' })); + expect(screen.getByText('是否删除人工/AI 标注帧')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '否,保留人工帧' })); + + await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10')); + expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('1'); + expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1']); + }); + it('can clear only the current frame when current masks have propagated results', async () => { apiMock.getProjectFrames.mockResolvedValueOnce([ { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 }, @@ -926,6 +975,71 @@ describe('VideoWorkspace', () => { expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-20']); }); + it('can keep frames with manual masks when clearing a propagated 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(); + 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: '2' } }); + 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(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('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 7955897..b08215d 100644 --- a/src/components/VideoWorkspace.tsx +++ b/src/components/VideoWorkspace.tsx @@ -60,6 +60,13 @@ type ClearPropagationRangeConfirmState = { rangeStartIndex: number; rangeEndIndex: number; }; +type ClearManualFrameConfirmState = { + allMaskIds: string[]; + autoOnlyMaskIds: string[]; + manualFrameNumbers: number[]; + messageScope: string; + resetRangeAfterClear: boolean; +}; type BooleanRangeConfirmState = { request: BooleanFrameRangeRequest; targetFrameIds: string[]; @@ -488,6 +495,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void const [pendingCurrentClearConfirm, setPendingCurrentClearConfirm] = useState(null); const [pendingClearPropagationRangeRequest, setPendingClearPropagationRangeRequest] = useState(null); const [pendingClearPropagationRangeConfirm, setPendingClearPropagationRangeConfirm] = useState(null); + const [pendingClearManualFrameConfirm, setPendingClearManualFrameConfirm] = useState(null); const [pendingBooleanRangeRequest, setPendingBooleanRangeRequest] = useState(null); const [pendingBooleanRangeConfirm, setPendingBooleanRangeConfirm] = useState(null); const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false); @@ -819,6 +827,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void setPendingCurrentClearConfirm(null); setPendingClearPropagationRangeRequest(null); setPendingClearPropagationRangeConfirm(null); + setPendingClearManualFrameConfirm(null); return; } const annotationIds = Array.from(new Set( @@ -840,6 +849,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void setPendingCurrentClearConfirm(null); setPendingClearPropagationRangeRequest(null); setPendingClearPropagationRangeConfirm(null); + setPendingClearManualFrameConfirm(null); } catch (err) { console.error('Delete annotations failed:', err); setStatusMessage('删除失败,请检查后端服务'); @@ -919,6 +929,76 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }); }, [currentFrame, currentFrameNumber, executeClearCurrentMasks]); + const resetClearPropagationRangeSelection = useCallback(() => { + setPendingClearPropagationRangeConfirm(null); + setPendingClearPropagationRangeRequest(null); + setIsPropagationRangeSelecting(false); + setRangeSelectionMode((currentMode) => (currentMode === 'clear' ? null : currentMode)); + setHasExplicitPropagationRange(false); + }, []); + + const requestClearMasksWithManualFrameConfirm = useCallback(( + maskIdsToClear: string[], + messageScope: string, + options?: { 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 manualFrameIds = new Set( + latestMasks + .filter((mask) => targetFrameIds.has(String(mask.frameId)) && !isPropagatedMask(mask)) + .map((mask) => String(mask.frameId)), + ); + + if (manualFrameIds.size === 0) { + void executeClearCurrentMasks(maskIdsToClear, messageScope).then(() => { + if (options?.resetRangeAfterClear) resetClearPropagationRangeSelection(); + }); + return; + } + + const manualFrameNumbers = Array.from(manualFrameIds) + .map((frameId) => frameNumberById.get(frameId)) + .filter((frameNumber): frameNumber is number => Boolean(frameNumber)) + .sort((a, b) => a - b); + const autoOnlyMaskIds = targetMasks + .filter((mask) => !manualFrameIds.has(String(mask.frameId))) + .map((mask) => mask.id); + + setPendingCurrentClearConfirm(null); + setPendingClearManualFrameConfirm({ + allMaskIds: maskIdsToClear, + autoOnlyMaskIds, + manualFrameNumbers, + messageScope, + resetRangeAfterClear: Boolean(options?.resetRangeAfterClear), + }); + }, [executeClearCurrentMasks, frameNumberById, resetClearPropagationRangeSelection]); + + const handleResolveClearManualFrameConfirm = useCallback(async (includeManualFrames: boolean) => { + if (!pendingClearManualFrameConfirm) return; + const targetMaskIds = includeManualFrames + ? pendingClearManualFrameConfirm.allMaskIds + : pendingClearManualFrameConfirm.autoOnlyMaskIds; + + if (targetMaskIds.length === 0) { + setPendingClearManualFrameConfirm(null); + if (pendingClearManualFrameConfirm.resetRangeAfterClear) resetClearPropagationRangeSelection(); + setStatusMessage('已保留人工/AI 标注帧,没有可清空的自动传播遮罩'); + return; + } + + await executeClearCurrentMasks( + targetMaskIds, + includeManualFrames + ? pendingClearManualFrameConfirm.messageScope + : `${pendingClearManualFrameConfirm.messageScope}中的自动传播帧`, + ); + if (pendingClearManualFrameConfirm.resetRangeAfterClear) resetClearPropagationRangeSelection(); + }, [executeClearCurrentMasks, pendingClearManualFrameConfirm, resetClearPropagationRangeSelection]); + const handleStartClearPropagationRange = useCallback((request: CurrentClearConfirmState) => { const latestMasks = useStore.getState().masks; const propagatedIdSet = new Set(request.propagatedMaskIds); @@ -984,16 +1064,13 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }, [frames, pendingClearPropagationRangeRequest, propagationEndFrame, propagationStartFrame, totalFrames]); const executeClearPropagationFrameRange = useCallback(async (confirmState: ClearPropagationRangeConfirmState) => { - await executeClearCurrentMasks( + requestClearMasksWithManualFrameConfirm( confirmState.targetMaskIds, `第 ${confirmState.rangeStartIndex + 1}-${confirmState.rangeEndIndex + 1} 帧传播链`, + { resetRangeAfterClear: true }, ); setPendingClearPropagationRangeConfirm(null); - setPendingClearPropagationRangeRequest(null); - setIsPropagationRangeSelecting(false); - setRangeSelectionMode(null); - setHasExplicitPropagationRange(false); - }, [executeClearCurrentMasks]); + }, [requestClearMasksWithManualFrameConfirm]); const handleBooleanFrameRangeRequest = useCallback((request: BooleanFrameRangeRequest) => { const candidateFrameNumbers = request.candidateFrameIds @@ -2172,7 +2249,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void void executeClearCurrentMasks(pendingCurrentClearConfirm.propagatedMaskIds, '当前帧及传播链')} + onClick={() => requestClearMasksWithManualFrameConfirm(pendingCurrentClearConfirm.propagatedMaskIds, '当前帧及传播链')} className="rounded bg-red-500 px-2 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:opacity-60" disabled={isSaving} > @@ -2183,6 +2260,47 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void )} + {pendingClearManualFrameConfirm && ( + + + 是否删除人工/AI 标注帧 + + 本次清空范围包含第 {pendingClearManualFrameConfirm.manualFrameNumbers.join('、') || '-'} 帧等人工/AI 标注帧。 + 是否同时删除这些帧中的遮罩? + + + 选择保留时,这些人工/AI 标注帧会整帧保留,只清空其它自动传播帧。 + + + setPendingClearManualFrameConfirm(null)} + disabled={isSaving} + className="rounded border border-white/10 px-2 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50" + > + 取消 + + void handleResolveClearManualFrameConfirm(false)} + disabled={isSaving} + className="rounded border border-amber-400/30 bg-amber-500/10 px-2 py-2 text-xs font-semibold text-amber-100 hover:bg-amber-500/20 disabled:opacity-60" + > + 否,保留人工帧 + + void handleResolveClearManualFrameConfirm(true)} + disabled={isSaving} + className="rounded bg-red-500 px-2 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:cursor-wait disabled:opacity-50" + > + 是,删除人工帧 + + + + + )} + {pendingClearPropagationRangeConfirm && (
+ 本次清空范围包含第 {pendingClearManualFrameConfirm.manualFrameNumbers.join('、') || '-'} 帧等人工/AI 标注帧。 + 是否同时删除这些帧中的遮罩? +
+ 选择保留时,这些人工/AI 标注帧会整帧保留,只清空其它自动传播帧。 +