diff --git a/README.md b/README.md index 1b0774c..c7a05f7 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,7 @@ celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency nohup celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency=1 > /tmp/celery.log 2>&1 & ``` -视频导入只创建项目并把源视频保存到 MinIO,不会自动拆帧;项目库导入面板会用 Axios 上传回调显示上传进度、百分比和字节数。用户在项目库点击“生成帧”后,再选择目标 FPS 并调用 `POST /api/media/parse`。DICOM 批量导入会在前端选择、后端上传、worker 下载和 pydicom 读取四个环节按文件名自然顺序排序,保证 `1.dcm、2.dcm、10.dcm` 这种序列按可见数字顺序转成项目帧;上传阶段同样显示进度条和本次有效 `.dcm` 文件数量,上传完成后项目库会轮询解析任务进度直到完成、失败或取消。项目卡片支持复制项目:`新项目重置` 会复制项目媒体字段和已生成帧序列但不复制标注,`全内容复制` 会额外复制标注和关联 mask 元数据,任务运行历史不复制。项目库和模板库的成功/失败反馈使用非阻塞短提示,会自动消失,不再用浏览器 `alert()` 阻塞后续操作;项目删除、模板删除、用户改密码/删除和演示出厂重置等高风险操作使用站内确认弹窗。该接口只创建 `processing_tasks` 记录并把任务投递给 Celery;真正的 FFmpeg/OpenCV/pydicom 拆帧由 worker 执行。接口支持 `parse_fps`、`max_frames` 和 `target_width`,用于生成后续 SAM 2 视频处理可复用的标准帧序列;视频/DICOM 解析后都按 `frame_%06d.jpg` 连续生成项目帧,帧表会记录 `timestamp_ms` 和 `source_frame_number`,任务完成结果会返回 `frame_sequence` 元数据。worker 每次更新任务状态后会发布到 Redis `seg:progress` 频道,FastAPI 订阅后转发到 `/ws/progress`,前端 Dashboard 可实时更新。Dashboard 的任务进度区展示 queued/running/success/failed/cancelled 最近任务,处理中统计只计算 queued/running;WebSocket 状态由浏览器 `onopen/onclose/onerror` 驱动,客户端会定时发送 `ping` 心跳,服务端返回 `status` 确认连接。Dashboard 也可调用 `/api/tasks/{id}/cancel`、`/api/tasks/{id}/retry` 和 `/api/tasks/{id}` 完成任务取消、重试与失败详情查看。 +视频导入只创建项目并把源视频保存到 MinIO,不会自动拆帧;项目库导入面板会用 Axios 上传回调显示上传进度、百分比和字节数。用户在项目库点击“生成帧”或“重新生成帧”后,再选择目标 FPS 并调用 `POST /api/media/parse`;已有帧的视频重新生成时,worker 会先清空旧帧、旧标注和旧 mask,避免同一项目出现重复帧序列。DICOM 批量导入会在前端选择、后端上传、worker 下载和 pydicom 读取四个环节按文件名自然顺序排序,保证 `1.dcm、2.dcm、10.dcm` 这种序列按可见数字顺序转成项目帧;上传阶段同样显示进度条和本次有效 `.dcm` 文件数量,上传完成后项目库会轮询解析任务进度直到完成、失败或取消。项目卡片支持复制项目:`新项目重置` 会复制项目媒体字段和已生成帧序列但不复制标注,`全内容复制` 会额外复制标注和关联 mask 元数据,任务运行历史不复制。项目库和模板库的成功/失败反馈使用非阻塞短提示,会自动消失,不再用浏览器 `alert()` 阻塞后续操作;项目删除、模板删除、用户改密码/删除和演示出厂重置等高风险操作使用站内确认弹窗。该接口只创建 `processing_tasks` 记录并把任务投递给 Celery;真正的 FFmpeg/OpenCV/pydicom 拆帧由 worker 执行。接口支持 `parse_fps`、`max_frames` 和 `target_width`,用于生成后续 SAM 2 视频处理可复用的标准帧序列;视频/DICOM 解析后都按 `frame_%06d.jpg` 连续生成项目帧,帧表会记录 `timestamp_ms` 和 `source_frame_number`,任务完成结果会返回 `frame_sequence` 元数据。`GET /api/projects/{id}/frames` 默认返回完整帧序列,不会把工作区总帧数截断到 1000。worker 每次更新任务状态后会发布到 Redis `seg:progress` 频道,FastAPI 订阅后转发到 `/ws/progress`,前端 Dashboard 可实时更新。Dashboard 的任务进度区展示 queued/running/success/failed/cancelled 最近任务,处理中统计只计算 queued/running;WebSocket 状态由浏览器 `onopen/onclose/onerror` 驱动,客户端会定时发送 `ping` 心跳,服务端返回 `status` 确认连接。Dashboard 也可调用 `/api/tasks/{id}/cancel`、`/api/tasks/{id}/retry` 和 `/api/tasks/{id}` 完成任务取消、重试与失败详情查看。 ### 步骤 7: 安装前端依赖并构建 diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 08e4589..5659bcd 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -1,7 +1,7 @@ """Project and Frame CRUD endpoints.""" import logging -from typing import List +from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session @@ -261,7 +261,7 @@ def create_frame( def list_frames( project_id: int, skip: int = 0, - limit: int = 1000, + limit: Optional[int] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> List[Frame]: @@ -270,14 +270,15 @@ def list_frames( if not project: raise HTTPException(status_code=404, detail="Project not found") - frames = ( + query = ( db.query(Frame) .filter(Frame.project_id == project_id) .order_by(Frame.frame_index) .offset(skip) - .limit(limit) - .all() ) + if limit is not None: + query = query.limit(limit) + frames = query.all() for frame in frames: frame.image_url = get_presigned_url(frame.image_url, expires=3600) return frames diff --git a/backend/services/media_task_runner.py b/backend/services/media_task_runner.py index c9a7e11..33351ab 100644 --- a/backend/services/media_task_runner.py +++ b/backend/services/media_task_runner.py @@ -11,7 +11,7 @@ from typing import Any from sqlalchemy.orm import Session from minio_client import BUCKET_NAME, download_file, get_minio_client, upload_file -from models import Frame, ProcessingTask, Project +from models import Annotation, Frame, Mask, ProcessingTask, Project from progress_events import publish_task_progress_event from services.frame_parser import ( extract_thumbnail, @@ -109,6 +109,16 @@ def _frame_sequence_metadata( } +def _clear_existing_project_outputs(db: Session, project: Project) -> None: + """Remove stale frame sequence and annotations before regenerating frames.""" + annotation_ids = db.query(Annotation.id).filter(Annotation.project_id == project.id) + db.query(Mask).filter(Mask.annotation_id.in_(annotation_ids)).delete(synchronize_session=False) + db.query(Annotation).filter(Annotation.project_id == project.id).delete(synchronize_session=False) + db.query(Frame).filter(Frame.project_id == project.id).delete(synchronize_session=False) + project.thumbnail_url = None + db.commit() + + def _ensure_not_cancelled(db: Session, task: ProcessingTask) -> None: db.refresh(task) if task.status == TASK_STATUS_CANCELLED: @@ -169,6 +179,7 @@ def run_parse_media_task(db: Session, task_id: int) -> dict[str, Any]: _ensure_not_cancelled(db, task) project.status = PROJECT_STATUS_PARSING + _clear_existing_project_outputs(db, project) _set_task_state(db, task, status=TASK_STATUS_RUNNING, progress=5, message="后台解析已启动", started=True) payload = task.payload or {} diff --git a/backend/tests/test_media.py b/backend/tests/test_media.py index c4d171a..737b714 100644 --- a/backend/tests/test_media.py +++ b/backend/tests/test_media.py @@ -216,6 +216,60 @@ def test_parse_task_runner_registers_frames(client, db_session, monkeypatch, tmp assert frames[0]["source_frame_number"] == 0 +def test_parse_task_runner_replaces_existing_frames_and_annotations(client, db_session, monkeypatch, tmp_path): + from models import Annotation, Frame, Mask, ProcessingTask + from services.media_task_runner import run_parse_media_task + + project = client.post("/api/projects", json={ + "name": "Reparse Me", + "video_path": "uploads/1/clip.mp4", + "source_type": "video", + "status": "ready", + "parse_fps": 30, + "thumbnail_url": "projects/old/thumbnail.jpg", + }).json() + old_frame = Frame(project_id=project["id"], frame_index=0, image_url="projects/old/frame.jpg") + db_session.add(old_frame) + db_session.commit() + db_session.refresh(old_frame) + old_annotation = Annotation(project_id=project["id"], frame_id=old_frame.id, mask_data={"label": "old"}) + db_session.add(old_annotation) + db_session.commit() + db_session.refresh(old_annotation) + db_session.add(Mask(annotation_id=old_annotation.id, mask_url="masks/old.png")) + task = ProcessingTask( + task_type="parse_video", + status="queued", + progress=0, + project_id=project["id"], + payload={"source_type": "video", "parse_fps": 10}, + ) + db_session.add(task) + db_session.commit() + db_session.refresh(task) + + frame_file = tmp_path / "frame_000000.jpg" + frame_file.write_bytes(b"new frame") + monkeypatch.setattr("services.media_task_runner.download_file", lambda object_name: b"video") + monkeypatch.setattr( + "services.media_task_runner.parse_video", + lambda local_path, output_dir, fps, max_frames=None, target_width=640: ([str(frame_file)], 25.0), + ) + monkeypatch.setattr("services.media_task_runner.extract_thumbnail", lambda local_path, thumbnail_path: open(thumbnail_path, "wb").write(b"thumb")) + monkeypatch.setattr("services.media_task_runner.upload_file", lambda *args, **kwargs: None) + monkeypatch.setattr("services.media_task_runner.upload_frames_to_minio", lambda frame_files, project_id: [f"projects/{project_id}/frames/frame_000000.jpg"]) + monkeypatch.setattr("services.media_task_runner.publish_task_progress_event", lambda task: None) + + result = run_parse_media_task(db_session, task.id) + + assert result["frames_extracted"] == 1 + frames = db_session.query(Frame).filter(Frame.project_id == project["id"]).all() + assert len(frames) == 1 + assert frames[0].image_url == f"projects/{project['id']}/frames/frame_000000.jpg" + assert db_session.query(Annotation).filter(Annotation.project_id == project["id"]).count() == 0 + assert db_session.query(Mask).count() == 0 + + def test_parse_dicom_reads_files_in_natural_filename_order(monkeypatch, tmp_path): from pathlib import Path diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index ee53010..0805f10 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -50,6 +50,29 @@ def test_project_crud_and_frames(client, monkeypatch): assert client.get(f"/api/projects/{project_id}").status_code == 404 +def test_list_project_frames_returns_more_than_default_timeline_window(client, db_session, monkeypatch): + monkeypatch.setattr("routers.projects.get_presigned_url", lambda key, expires=3600: f"http://storage/{key}") + created = client.post("/api/projects", json={"name": "Long Sequence", "status": "ready"}) + assert created.status_code == 201 + project_id = created.json()["id"] + + db_session.add_all([ + Frame(project_id=project_id, frame_index=index, image_url=f"frames/{index}.jpg") + for index in range(1001) + ]) + db_session.commit() + + frames = client.get(f"/api/projects/{project_id}/frames") + assert frames.status_code == 200 + body = frames.json() + assert len(body) == 1001 + assert body[-1]["frame_index"] == 1000 + + limited = client.get(f"/api/projects/{project_id}/frames?limit=2") + assert limited.status_code == 200 + assert [frame["frame_index"] for frame in limited.json()] == [0, 1] + + def test_delete_project_cascades_related_records(client, db_session): created = client.post("/api/projects", json={"name": "Cascade Delete"}) assert created.status_code == 201 diff --git a/doc/02-current-implementation-map.md b/doc/02-current-implementation-map.md index 3ffa32d..c8963f0 100644 --- a/doc/02-current-implementation-map.md +++ b/doc/02-current-implementation-map.md @@ -79,10 +79,10 @@ 1. `ProjectLibrary.tsx` 调用 `getProjects()` 获取项目。 2. 上传视频时先 `createProject()`,再 `uploadMedia()`;导入视频不自动调用 `parseMedia()`。 3. 后端 `media.py` 把原始文件上传到 MinIO。 -4. 用户在项目库点击“生成帧”并选择 FPS 后,`parseMedia()` 创建 `processing_tasks` 记录并投递 Celery worker。 +4. 用户在项目库点击“生成帧”或“重新生成帧”并选择 FPS 后,`parseMedia()` 创建 `processing_tasks` 记录并投递 Celery worker;已有帧的视频重新生成时,worker 会先删除旧帧、旧标注和旧 mask,再写入新的帧序列。 5. Celery worker 下载 MinIO 文件,调用 `frame_parser.py` 拆帧。 6. worker 把拆出的帧重新上传 MinIO,写入 `frames` 表,并更新任务状态。 -7. 工作区只通过 `GET /api/projects/{id}/frames` 获取预签名图片 URL;若项目有源视频但无帧,会提示先回项目库生成帧。 +7. 工作区只通过 `GET /api/projects/{id}/frames` 获取完整预签名图片 URL 列表;若项目有源视频但无帧,会提示先回项目库生成帧。 8. Dashboard 可通过 `POST /api/tasks/{id}/cancel` 取消 queued/running 任务,通过 `POST /api/tasks/{id}/retry` 重试 failed/cancelled 任务,并用 `GET /api/tasks/{id}` 查看失败详情。 ### 工作区浏览 diff --git a/doc/03-frontend-element-audit.md b/doc/03-frontend-element-audit.md index 51e0ea0..cb28089 100644 --- a/doc/03-frontend-element-audit.md +++ b/doc/03-frontend-element-audit.md @@ -58,9 +58,9 @@ | 项目列表 | 真实可用 | 调用 `GET /api/projects` | | 项目卡片缩略图 | 真实可用 | 后端返回 MinIO 预签名 `thumbnail_url` 时显示 | | 点击项目进入工作区 | 真实可用 | 设置 `currentProject` 后切到 `workspace` | -| 新建项目 | 真实可用 | 调用 `POST /api/projects` | +| 新建项目 | 已移除入口 | 项目库不再展示独立“新建项目”按钮;导入视频/DICOM 时自动创建项目,后端 `POST /api/projects` 保留给导入流程和兼容调用 | | 导入视频文件 | 真实可用 | 创建项目、上传源视频、刷新项目列表;不会自动拆帧;上传期间显示项目库导入进度条、百分比和已上传字节 | -| 生成帧按钮 | 真实可用 | 仅对已导入源视频且尚无帧、非 parsing 状态的项目显示,调用 `parseMedia(projectId, { parseFps })`;任务入队后项目库继续轮询 `GET /api/tasks/{task_id}`,解析成功后立即重新拉取项目列表,使后端新写入的 `thumbnail_url` 自动刷新到项目封面 | +| 生成帧/重新生成帧按钮 | 真实可用 | 对已导入源视频且非 parsing 状态的项目显示,调用 `parseMedia(projectId, { parseFps })`;已有帧时显示“重新生成帧”,后端会先清空旧帧、标注和 mask;任务入队后项目库继续轮询 `GET /api/tasks/{task_id}`,解析成功后立即重新拉取项目列表,使后端新写入的 `thumbnail_url` 自动刷新到项目封面 | | 生成帧 FPS 滑块 | 真实可用 | 值传入 `/api/media/parse?parse_fps=...`,决定后台拆帧目标 FPS | | 项目卡片 FPS 徽标 | 真实可用 | 右上角显示关键帧序列目标 `parse_fps`;原始视频帧率只在卡片底部以“原 xx fps”显示 | | 导入 DICOM 序列 | 真实可用 | 可上传 `.dcm` 并触发解析;上传前按文件名自然顺序排序,后端解析也保持同一顺序;上传期间显示导入进度条、有效 DICOM 文件数量和已上传字节,上传完成后继续显示解析任务进度直到完成、失败或取消 | @@ -185,6 +185,6 @@ ## 总体结论 -当前前端真实可用的主链路是:JWT 登录、刷新恢复用户、退出登录、Dashboard 当前用户概览、当前用户项目列表、新建项目、上传视频/DICOM、显式生成帧、浏览帧、播放帧、工作区手工绘制、点/框 AI 推理、视频片段传播、GT mask 导入、标注保存/回显、统一分割结果 ZIP 导出、兼容 COCO/PNG mask ZIP 导出、模板 CRUD。 +当前前端真实可用的主链路是:JWT 登录、刷新恢复用户、退出登录、Dashboard 当前用户概览、当前用户项目列表、上传视频/DICOM、显式生成帧/重新生成帧、浏览帧、播放帧、工作区手工绘制、点/框 AI 推理、视频片段传播、GT mask 导入、标注保存/回显、统一分割结果 ZIP 导出、兼容 COCO/PNG mask ZIP 导出、模板 CRUD。 当前最主要的 Mock 或未打通链路是:真正的文本语义分割已因无文本提示入口而暂时禁用;复杂洞结构编辑、骨架/HDBSCAN 级别的 mask 降维增强、任务历史筛选、项目更多菜单、全业务操作审计和 mapping rules 运行时映射执行引擎仍未落地。登录页“端到端加密”等安全文案仍只是 UI 文案;登录和用户管理操作审计已落库并可在管理员后台查看。 diff --git a/doc/04-api-contracts.md b/doc/04-api-contracts.md index 52e12aa..4ca6c37 100644 --- a/doc/04-api-contracts.md +++ b/doc/04-api-contracts.md @@ -33,11 +33,11 @@ Authorization: Bearer | `deleteTemplate(id)` | `DELETE /api/templates/{id}` | 对齐 | 模板编辑页使用 | | `uploadMedia(file, projectId, options?)` | `POST /api/media/upload` | 对齐 | multipart form-data;`options.onProgress` 用于项目库上传进度 | | `uploadDicomBatch(files, projectId, options?)` | `POST /api/media/upload/dicom` | 对齐 | multipart form-data;`options.onProgress` 用于项目库上传进度,上传完成后项目库轮询解析任务进度 | -| `parseMedia(projectId, options?)` | `POST /api/media/parse?project_id=...` | 对齐 | 创建异步拆帧任务并返回 task;由项目库“生成帧”显式调用,支持 `parse_fps`、`max_frames`、`target_width` | +| `parseMedia(projectId, options?)` | `POST /api/media/parse?project_id=...` | 对齐 | 创建异步拆帧任务并返回 task;由项目库“生成帧/重新生成帧”显式调用,已有帧时 worker 会先清空旧帧、标注和 mask;支持 `parse_fps`、`max_frames`、`target_width` | | `getTask(taskId)` | `GET /api/tasks/{task_id}` | 对齐 | 查询异步任务状态 | | `cancelTask(taskId)` | `POST /api/tasks/{task_id}/cancel` | 对齐 | 取消 queued/running 任务,后端写 cancelled 并尝试 revoke Celery | | `retryTask(taskId)` | `POST /api/tasks/{task_id}/retry` | 对齐 | 对 failed/cancelled 任务创建新的 queued 重试任务 | -| `getProjectFrames(projectId)` | `GET /api/projects/{id}/frames` | 对齐 | 后端返回预签名 image_url,以及 `timestamp_ms`、`source_frame_number` | +| `getProjectFrames(projectId)` | `GET /api/projects/{id}/frames` | 对齐 | 默认返回完整帧列表和预签名 image_url,以及 `timestamp_ms`、`source_frame_number`;可选 `skip/limit` 仅用于显式分页 | | `predictMask(payload)` | `POST /api/ai/predict` | 对齐 | 前端发送 `image_id/prompt_type/prompt_data/model`,并把后端 `polygons` 转为 `masks[].pathData` | | `propagateMasks(payload)` | `POST /api/ai/propagate` | 对齐 | 单 seed 同步传播接口,供后端兼容和测试使用 | | `queuePropagationTask(payload)` | `POST /api/ai/propagate/task` | 对齐 | 工作区“AI自动推理”入口;创建 Celery 后台任务并由任务表/进度流追踪 | diff --git a/doc/07-current-requirements-freeze.md b/doc/07-current-requirements-freeze.md index 42874c1..12bd687 100644 --- a/doc/07-current-requirements-freeze.md +++ b/doc/07-current-requirements-freeze.md @@ -22,10 +22,10 @@ ## R2 项目管理 - 前端展示项目库,并从 `GET /api/projects` 获取项目列表。 -- 用户可以新建项目,前端调用 `POST /api/projects`;后端把项目归属到当前登录用户。 +- 项目库不提供独立“新建项目”按钮;导入视频或 DICOM 时由前端/后端自动创建项目,后端仍保留 `POST /api/projects` 供导入流程和兼容接口使用。 - 用户可以选择项目,进入工作区。 - 用户可以导入视频文件,前端创建项目、上传文件并刷新项目列表;导入视频不自动拆帧。 -- 用户可以对已导入且尚未生成帧的视频项目点击“生成帧”,在弹窗中选择目标 FPS 后创建拆帧任务;项目名称编辑状态下不能显示/触发生成帧入口,DICOM 项目不能显示生成帧入口。 +- 用户可以对已导入源视频的视频项目点击“生成帧”或“重新生成帧”,在弹窗中选择目标 FPS 后创建拆帧任务;如果项目已有帧,重新生成前必须清空该项目现有帧、标注和 mask,避免重复帧序列;项目名称编辑状态下不能显示/触发生成帧入口,DICOM 项目不能显示生成帧入口。 - 用户可以导入 DICOM 序列,前端上传 DICOM、触发拆帧、刷新项目列表。 - 用户可以在项目库项目卡片上修改项目名称,名称不能为空。 - 用户可以在项目卡片删除按钮旁复制项目;复制时可选择“新项目重置”或“全内容复制”。新项目重置必须复制项目媒体字段和已生成帧序列,但不复制标注或 mask 元数据;全内容复制必须额外复制标注和关联 mask 元数据,并将复制标注重新指向新项目中的对应帧。任务运行历史不复制。 @@ -57,6 +57,7 @@ - 工作区根据当前项目加载帧列表。 - 若项目有媒体但无帧,工作区只提示需要先在项目库生成帧,不再自动触发拆帧。 +- 工作区加载帧时必须获取项目完整帧序列,不能被接口默认分页截断到 1000 帧。 - Canvas 显示当前帧图片。 - Canvas 支持滚轮缩放、移动工具拖拽、鼠标坐标显示。 - Canvas 未选中特定 mask 时,mask 显示顺序必须遵循右侧“语义分类树”拖拽得到的内部覆盖优先级:低优先级先渲染,高优先级后渲染并显示在上层;选中 mask 后可以为了编辑交互临时置顶。 diff --git a/doc/08-current-design-freeze.md b/doc/08-current-design-freeze.md index 8898195..2055e41 100644 --- a/doc/08-current-design-freeze.md +++ b/doc/08-current-design-freeze.md @@ -26,7 +26,7 @@ | 模型状态 | `src/components/ModelStatusBadge.tsx` | 展示 GPU 与当前 SAM 模型真实可用状态;左侧 Sidebar 底部使用 compact 形态显示 GPU/CPU 状态,工作区顶栏不再重复显示,具体传播权重只在进入自动传播后由顶栏下拉负责 | | 登录页 | `src/components/Login.tsx` | 使用 `public/logo.png` 和系统标题文案,调用登录 API,写入 store | | Dashboard | `src/components/Dashboard.tsx` | 展示统计、任务控制、失败详情和 WebSocket 进度消息 | -| 项目库 | `src/components/ProjectLibrary.tsx` | 项目列表、新建、重命名、删除、导入视频/DICOM、显式生成帧 | +| 项目库 | `src/components/ProjectLibrary.tsx` | 项目列表、重命名、删除、复制、导入视频/DICOM、显式生成帧/重新生成帧 | | 工作区 | `src/components/VideoWorkspace.tsx` | 加载帧和模板,组织工具栏、Canvas、本体面板、时间轴 | | Canvas | `src/components/CanvasArea.tsx` | 显示帧、缩放平移、点/框提示、渲染 mask | | 工具栏 | `src/components/ToolsPalette.tsx` | 切换工作区编辑工具、提供等同 `Esc` 的“取消选中”实体按钮、在“重叠区域去除”后触发当前帧/传播链清空、GT Mask 导入和 AI 页面跳转;AI 跳转入口复用 Bot + Sparkles 组合图标以明确表达 AI 智能分割;不再放置 AI 正/反点和框选工具,也不重复放置撤销/重做;拖拽/选择/取消选中到创建圆、画笔/橡皮擦/区域合并/重叠区域去除、清空遮罩/导入 GT Mask/AI 智能分割三类工具之间用浅灰横线分隔;紧凑垂直布局,高度不足时自身滚动;外层宽 56px,按钮列固定 48px,滚动条使用右侧外扩空间和低对比 `seg-scrollbar` | @@ -96,7 +96,7 @@ 1. `ProjectLibrary` 创建项目。 2. 导入视频时上传源视频到 `/api/media/upload` 并关联项目;该步骤不调用 `/api/media/parse`。上传期间项目库显示导入进度条、百分比和已上传字节,完成后短暂显示“视频导入完成”。 -3. 用户在项目卡片点击“生成帧”,在弹窗中选择目标 FPS。任务入队后项目库会继续轮询任务进度,解析成功后自动重新拉取项目列表和当前项目对象,使后端生成的 `thumbnail_url` 立即显示为项目封面,无需刷新页面或重新进入项目库。 +3. 用户在视频项目卡片点击“生成帧”或“重新生成帧”,在弹窗中选择目标 FPS。已有帧的视频重新生成时会提示该操作会清空现有帧序列、标注和 mask;后端任务开始时删除旧帧和旧标注,再写入新的标准帧序列。任务入队后项目库会继续轮询任务进度,解析成功后自动重新拉取项目列表和当前项目对象,使后端生成的 `thumbnail_url` 立即显示为项目封面,无需刷新页面或重新进入项目库。 4. 前端调用 `/api/media/parse` 创建异步拆帧任务;可通过 `parse_fps`、`max_frames` 和 `target_width` 指定标准帧序列参数。 5. Celery worker 执行 FFmpeg/OpenCV/pydicom 拆帧;DICOM 在前端选择、后端上传、worker 下载和 pydicom 读取时都按文件名自然顺序排序;视频/DICOM 解析结果都按 `frame_%06d.jpg` 从 `frame_000000.jpg` 连续命名,视频帧按目标宽度缩放。 6. worker 写入 `frames.timestamp_ms` 和 `frames.source_frame_number`,并在任务 `result.frame_sequence` 中记录 FPS、帧数、时长、尺寸和对象存储前缀。 @@ -117,7 +117,7 @@ ### 工作区加载 1. `VideoWorkspace` 根据 `currentProject.id` 调用 `getProjectFrames()`。 -2. 若无帧但项目有 `video_path`,显示“尚未生成帧”的状态提示,不自动触发 `parseMedia()`。 +2. 若无帧但项目有 `video_path`,显示“尚未生成帧”的状态提示,不自动触发 `parseMedia()`;帧列表接口默认返回完整帧序列,工作区右下角总帧数以实际项目帧数为准。 3. 帧数据映射为 store `Frame[]`,包含 `timestampMs` 和 `sourceFrameNumber`,供时间轴和后续视频传播使用。 4. 工作区调用 `GET /api/ai/annotations` 回显已保存标注时,会替换当前项目帧中的已保存 mask,但保留没有 `annotationId` 的未保存 draft mask;这保证 AI 页推送到工作区的候选 mask 不会被异步回显覆盖,并会在合并完成后恢复仍然存在的已选 mask id。 5. `VideoWorkspace` 加载项目帧时会优先按当前选中 mask 的 `frameId` 和当前打开帧 id 恢复 `currentFrameIndex`;只有没有可恢复帧时才回到第一帧,避免 AI 页在非第一帧推送回工作区时视角被重置。 diff --git a/doc/09-test-plan.md b/doc/09-test-plan.md index 0af5567..3d9a693 100644 --- a/doc/09-test-plan.md +++ b/doc/09-test-plan.md @@ -15,8 +15,8 @@ | 需求 | 测试文件 | 覆盖点 | |------|----------|--------| | 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` | 登录页 logo 和系统标题文案、成功登录、JWT/token 写入、当前用户写入、刷新恢复基础状态、失败提示、登录输入 autocomplete、后端 401、`/api/auth/me`、管理员入口用户图标、底部退出图标和非交互 tooltip、用户 CRUD、唯一 admin/标注员角色权限、审计日志、旧 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 停止 | +| R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目库不展示独立新建项目按钮、项目卡片复制/删除、修改项目名称时隐藏生成帧、DICOM 项目不显示生成帧、复制项目 reset/full 契约、DELETE 契约、后端 CRUD、删除级联、完整帧列表不默认截断到 1000、项目按当前 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 选择、重新生成前清空旧帧旧标注旧 mask、视频生成帧入队后轮询解析任务并在成功后自动刷新项目封面、项目卡片显示目标 parse_fps 而非原视频 FPS、扩展名校验、自动建项目、关联项目、创建异步任务、非阻塞自动消失操作提示、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 | | R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/AI 画布底图默认居中且保留边距、工作区 mask 透明度、回显已保存标注时保留本地未保存 draft mask、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏清空遮罩优先作用于当前帧选中 mask/无选中时作用于当前帧全部 mask、无传播链时直接执行、有传播链时可选取消/只清当前帧/按帧范围选择/清空所有传播帧且按范围清空需最终确认、按范围清空或清空所有传播帧遇到人工/AI 标注帧时二次询问并支持保留人工帧、顶栏不显示重复的清空片段遮罩、传播进度存在时任务 message 只显示在蓝色进度面板内且不重复出现在灰色状态文字里、传播链布尔操作按帧范围选择并二次确认、清空/删除前预检后端 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` | 工具切换、切换到多边形/矩形/圆会保留旧 mask 选区、有选中 mask 时多边形/矩形/圆/画笔新几何会并入选中 mask 且不要求重叠、无选中 mask 时手工新建 mask 后自动选中新 mask 并显示创建后边界点、Esc 和左侧“取消选中”按钮清空当前 mask 选区和临时绘制状态、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、清空遮罩唯一左侧工具栏入口、清空遮罩上方 DEL 删除按钮、橡皮擦下方彩色 AI自动推理入口、Canvas 右下角不再重复显示清空遮罩或应用分类按钮、GT Mask 导入位于清空遮罩分隔线之后且使用紫色底色、工具栏分隔线位于创建圆后、AI自动推理后和清空遮罩后、GT Mask 未知类别导入策略选择、工作区工具栏不展示 AI 正/反点和框选、左侧工具栏不重复撤销/重做、左侧工具栏不展示创建点/创建线段、矩形/圆/多边形手工 mask 绘制且未选分类时默认待分类、普通/导入 polygon mask 不显示黄色 seed point、画笔/橡皮擦尺寸控制、画笔无选中时新建当前类别 mask、画笔/橡皮擦模式下保留当前选中 mask 顶点提示且只读、画笔从图外落笔不创建 mask、靠边画笔生成几何裁剪到当前帧边界内、橡皮擦从选中 mask 扣除、未选中 mask 时画布按语义分类树内部优先级渲染、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合且数秒后自动隐藏、polygon 顶点直接拖动/删除、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、多 polygon/分离区域全部显示编辑顶点、中空 mask 与中空画笔 mask 内洞 ring 顶点和插点可编辑、整块 mask 删除、DEL 和 Delete/Backspace 删除共用传播链范围确认、同帧传播链分散 mask 点选联动高亮、传播链自动传播 mask 随 seed/传播结果删除、独立 AI 推理 mask 不被误删、区域合并/去除存在传播帧时弹窗选择当前帧/所有传播帧/按帧范围选择、范围确认前重新开始当前帧布尔操作会取消旧顶栏范围请求、区域合并/去除按帧范围同步到对应传播帧且保留传播 metadata、旧传播缺可靠 lineage 时布尔同步只选每个已选 mask 的空间最近对应实例而不批量处理同类其它实例、布尔选择主区域/扣除区域视觉区分和选择顺序提示、内含去除 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 | @@ -33,8 +33,8 @@ | 需求 | 功能点 | 对应测试 | 当前状态 | |------|--------|----------|----------| | R1 | 登录页 logo 和系统标题文案、唯一默认管理员、JWT 写入、当前用户写入、刷新恢复基础状态、失败提示、后端 401、`/api/auth/me`、管理员用户管理入口图标、底部退出入口图标和 tooltip 命中范围、角色权限、审计日志、演示出厂设置二次确认、重置后只保留 admin、名为“演视LC视频序列”的已生成帧演示视频项目和名为“演视DICOM序列”的已生成帧自然排序演示 DICOM 项目 | `Login.test.tsx`, `Sidebar.test.tsx`, `UserAdmin.test.tsx`, `useStore.test.ts`, `test_auth.py`, `test_admin.py` | 已覆盖 | -| R2 | 项目列表/创建/选择/重命名/复制、重命名时不触发生成帧、DICOM 不显示生成帧、项目复制 reset/full、项目按用户隔离、视频导入、DICOM 导入、DICOM 前端选择自然排序、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 | -| R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式 FPS 生成帧、视频生成帧完成后自动刷新项目封面、项目卡片 FPS 徽标显示 `parse_fps`、视频/DICOM 拆帧任务、DICOM 上传/下载/读取自然排序、非阻塞自动消失操作提示、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_media.py`, `test_tasks.py` | 已覆盖 | +| R2 | 项目列表/无独立新建项目按钮/选择/重命名/复制、重命名时不触发生成帧、DICOM 不显示生成帧、完整帧列表不默认截断到 1000、项目复制 reset/full、项目按用户隔离、视频导入、DICOM 导入、DICOM 前端选择自然排序、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 | +| R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式 FPS 生成帧/重新生成帧、重新生成清理旧帧旧标注旧 mask、视频生成帧完成后自动刷新项目封面、项目卡片 FPS 徽标显示 `parse_fps`、视频/DICOM 拆帧任务、DICOM 上传/下载/读取自然排序、非阻塞自动消失操作提示、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_media.py`, `test_tasks.py` | 已覆盖 | | R4 | 工作区加载帧、无帧项目不自动解析、工作区短状态自动消失、后端标注回显保留本地未保存 draft mask、Canvas/AI 底图居中适配且保留边距、工作区 mask 透明度、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏当前帧清空优先作用于选中 mask、无传播链时直接执行、有传播链时可选当前帧/传播所有帧/取消、清空人工/AI 标注帧前二次确认、取消确认不删除、仅自动传播帧不确认、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播范围洋红/黄绿色边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条/视频处理进度条拖拽选择传播范围、Canvas/AI 画布拖拽平移回写 position state、左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx` | 已覆盖 | | R5 | 工具切换、工具栏紧凑滚动布局、低对比滚动条、外扩滚动条槽位、调整多边形入口、清空遮罩唯一左侧入口、Canvas 右下角旧清空/应用分类按钮移除、GT Mask 导入入口位置和紫色底色、工作区工具栏隐藏 AI 正/反点和框选、左侧工具栏不重复撤销/重做、AI 跳转、矩形/圆/线/点/多边形绘制、已有 mask 上继续绘制、多边形和布尔工具上下文提示、Canvas 上下文提示数秒后自动隐藏 | `ToolsPalette.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 | | R5 | 顶点直接拖动编辑、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、中空 mask 与中空画笔 mask 内洞 ring 顶点和插点可编辑、顶点删除、整块删除、删除传播链自动传播 mask 且保留独立 AI 推理 mask、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键 Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z、Ctrl/Cmd+Y 和 KeyZ/KeyY fallback、区域合并、区域去除、布尔选择主区域黄色实线/扣除区域红色虚线、布尔选择顺序提示、hole even-odd 渲染 | `CanvasArea.test.tsx`, `VideoWorkspace.test.tsx`, `keyboardShortcuts.test.ts`, `useStore.test.ts` | 已覆盖 | diff --git a/doc/10-installation.md b/doc/10-installation.md index b7f427c..332b36e 100644 --- a/doc/10-installation.md +++ b/doc/10-installation.md @@ -154,6 +154,26 @@ pip install -r requirements.txt cd .. ``` +### Docker 使用本机 GPU + +Docker 显示 GPU 的前提不是前端开关,而是宿主机、Docker runtime 和容器依赖都可用: + +1. 宿主机 `nvidia-smi` 必须能正常看到 NVIDIA GPU。 +2. 安装并配置 NVIDIA Container Toolkit。 +3. Docker compose 需要给 `backend` 和 `worker` 透传 GPU,例如在部署包中使用 `docker-compose.gpu.yml` 覆盖文件。 +4. 后端镜像内还必须安装 CUDA 版 PyTorch、`sam2` Python 包,并挂载对应 `models/sam2.1_*.pt` 权重;最小部署镜像为了体积默认不安装这些 AI 依赖,因此只加 GPU 透传仍会显示 CPU/模型不可用。 + +示例启动: + +```bash +docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build +docker compose exec backend python - <<'PY' +import torch +print(torch.cuda.is_available()) +PY +curl http://localhost:8000/api/ai/models/status +``` + 确认关键包: ```bash diff --git a/doc/11-frontend-interaction-state-machines.md b/doc/11-frontend-interaction-state-machines.md index 1405014..3b03d1f 100644 --- a/doc/11-frontend-interaction-state-machines.md +++ b/doc/11-frontend-interaction-state-machines.md @@ -96,7 +96,7 @@ | 交互 | 状态机 | 测试 | |------|--------|------| | 视频/DICOM 上传 | 选择文件后显示上传进度;DICOM 显示有效文件数量;上传后继续轮询解析任务进度 | `ProjectLibrary.test.tsx` | -| 显式生成帧 | 只对视频项目显示;项目名称编辑状态不显示;DICOM 项目不显示;入队后轮询解析任务,成功后刷新项目列表并立即显示新封面 | `ProjectLibrary.test.tsx` | +| 显式生成帧/重新生成帧 | 只对有源视频的视频项目显示;项目名称编辑状态不显示;DICOM 项目不显示;已有帧时显示“重新生成帧”并提示会清空旧帧、标注和 mask;入队后轮询解析任务,成功后刷新项目列表并立即显示新封面 | `ProjectLibrary.test.tsx` | | GT Mask 导入 | 选择文件后预览并选择未知 maskid 策略;非法格式返回错误;尺寸不一致最近邻拉伸;导入结果与普通 mask 同体验 | `VideoWorkspace.test.tsx`、后端 AI 测试 | | 分割结果导出 | 默认当前帧;可选整体/范围;范围可用时间轴;导出前保存待归档 mask;按钮带导出图标和绿色强调背景 | `VideoWorkspace.test.tsx`、`api.test.ts`、后端导出测试 | diff --git a/docker-compose.gpu.yml b/docker-compose.gpu.yml new file mode 100644 index 0000000..2f36836 --- /dev/null +++ b/docker-compose.gpu.yml @@ -0,0 +1,12 @@ +services: + backend: + gpus: all + environment: + NVIDIA_VISIBLE_DEVICES: all + NVIDIA_DRIVER_CAPABILITIES: compute,utility + + worker: + gpus: all + environment: + NVIDIA_VISIBLE_DEVICES: all + NVIDIA_DRIVER_CAPABILITIES: compute,utility diff --git a/src/components/ProjectLibrary.test.tsx b/src/components/ProjectLibrary.test.tsx index 41208a8..9590cfa 100644 --- a/src/components/ProjectLibrary.test.tsx +++ b/src/components/ProjectLibrary.test.tsx @@ -70,20 +70,11 @@ describe('ProjectLibrary', () => { expect(screen.queryByText('30FPS')).not.toBeInTheDocument(); }); - it('creates a new project from the modal', async () => { - apiMock.createProject.mockResolvedValueOnce({ id: 'p2', name: 'New Project', status: 'pending' }); - + it('does not expose manual project creation from the project library header', async () => { render(); - fireEvent.click(screen.getByText('新建项目')); - fireEvent.change(screen.getByPlaceholderText('输入项目名称'), { target: { value: 'New Project' } }); - fireEvent.change(screen.getByPlaceholderText('输入项目描述'), { target: { value: 'desc' } }); - fireEvent.click(screen.getByRole('button', { name: '创建' })); - await waitFor(() => expect(apiMock.createProject).toHaveBeenCalledWith({ - name: 'New Project', - description: 'desc', - })); - expect(useStore.getState().projects[0]).toEqual(expect.objectContaining({ id: 'p2' })); + await waitFor(() => expect(apiMock.getProjects).toHaveBeenCalled()); + expect(screen.queryByRole('button', { name: '新建项目' })).not.toBeInTheDocument(); }); it('imports video by creating a project and uploading media without parsing frames', async () => { @@ -156,6 +147,30 @@ describe('ProjectLibrary', () => { expect(await screen.findByRole('status')).toHaveTextContent('视频帧生成完成,项目封面已自动更新'); }); + it('allows regenerating an already parsed video and clears the current workspace cache', async () => { + apiMock.getProjects + .mockResolvedValueOnce([{ id: 'p-ready', name: 'ready.mp4', status: 'ready', frames: 1500, video_path: 'uploads/ready.mp4', parse_fps: 30, source_type: 'video' }]) + .mockResolvedValueOnce([{ id: 'p-ready', name: 'ready.mp4', status: 'parsing', frames: 0, video_path: 'uploads/ready.mp4', parse_fps: 24, source_type: 'video' }]); + apiMock.parseMedia.mockResolvedValueOnce({ status: 'queued', progress: 0 }); + useStore.setState({ + currentProject: { id: 'p-ready', name: 'ready.mp4', status: 'ready' }, + frames: [{ id: 'old-frame', projectId: 'p-ready', index: 0, url: '/old.jpg', width: 640, height: 360 }], + masks: [{ id: 'old-mask', frameId: 'old-frame', pathData: 'M 0 0 Z', label: 'old', color: '#fff' }], + selectedMaskIds: ['old-mask'], + }); + + render(); + + fireEvent.click(await screen.findByRole('button', { name: '重新生成帧' })); + expect(screen.getByText('重新生成会清空该项目现有帧序列、标注和 mask,再按新的 FPS 从源视频生成帧。')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '开始生成帧' })); + + await waitFor(() => expect(apiMock.parseMedia).toHaveBeenCalledWith('p-ready', { parseFps: 30 })); + expect(useStore.getState().frames).toEqual([]); + expect(useStore.getState().masks).toEqual([]); + expect(useStore.getState().selectedMaskIds).toEqual([]); + }); + it('hides frame generation while editing a project name', async () => { apiMock.getProjects.mockResolvedValueOnce([ { id: 'p-edit', name: 'Editable Clip', status: 'pending', frames: 0, video_path: 'uploads/editable.mp4', parse_fps: 30, source_type: 'video' }, diff --git a/src/components/ProjectLibrary.tsx b/src/components/ProjectLibrary.tsx index 1159980..cbf0169 100644 --- a/src/components/ProjectLibrary.tsx +++ b/src/components/ProjectLibrary.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; -import { UploadCloud, Film, Settings2, Plus, Loader2, Activity, Images, Trash2, Pencil, Check, X, Copy } from 'lucide-react'; +import { UploadCloud, Film, Settings2, Loader2, Activity, Images, Trash2, Pencil, Check, X, Copy } from 'lucide-react'; import { cn } from '../lib/utils'; import { useStore } from '../store/useStore'; import { getProjects, createProject, updateProject, copyProject, uploadMedia, parseMedia, uploadDicomBatch, deleteProject, getTask } from '../lib/api'; @@ -31,15 +31,10 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) { 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); - const [newName, setNewName] = useState(''); - const [newDesc, setNewDesc] = useState(''); const [showImportMenu, setShowImportMenu] = useState(false); const [showVideoConfig, setShowVideoConfig] = useState(false); const [pendingFile, setPendingFile] = useState(null); @@ -187,11 +182,14 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) { const canGenerateFrames = (project: Project) => ( project.source_type !== 'dicom' && Boolean(project.video_path) - && (project.frames ?? 0) === 0 && project.status !== 'parsing' && editingProjectId !== project.id ); + const frameGenerationLabel = (project: Project) => ( + (project.frames ?? 0) > 0 ? '重新生成帧' : '生成帧' + ); + useEffect(() => { setIsLoading(true); getProjects() @@ -200,22 +198,6 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) { .finally(() => setIsLoading(false)); }, [setProjects]); - const handleCreate = async () => { - if (!newName.trim()) return; - setIsCreating(true); - try { - const project = await createProject({ name: newName.trim(), description: newDesc.trim() || undefined }); - addProject(project); - setShowModal(false); - setNewName(''); - setNewDesc(''); - } catch (err) { - console.error('Failed to create project:', err); - } finally { - setIsCreating(false); - } - }; - const handleSelect = (project: Project) => { setCurrentProject(project); onProjectSelect(); @@ -395,6 +377,11 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) { try { const task = await parseMedia(targetProject.id, { parseFps: frameParseFps }); showNotice(`生成帧任务已入队 #${task.id}\n帧率: ${frameParseFps} FPS\n可在 Dashboard 查看进度。`, 'success'); + if (currentProject?.id === targetProject.id) { + setFrames([]); + setMasks([]); + setSelectedMaskIds([]); + } await refreshProjects(); setShowFrameConfig(false); setFrameProject(null); @@ -579,13 +566,6 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {

支持导入视频文件、DICOM序列文件

-
)}
@@ -888,9 +868,14 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) { {showFrameConfig && frameProject && (
-

生成帧

+

{frameGenerationLabel(frameProject)}

项目: {frameProject.name}
+ {(frameProject.frames ?? 0) > 0 && ( +
+ 重新生成会清空该项目现有帧序列、标注和 mask,再按新的 FPS 从源视频生成帧。 +
+ )}
@@ -926,58 +911,6 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
)} - - {/* New project modal */} - {showModal && ( -
-
-

新建项目

-
-
- - setNewName(e.target.value)} - className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all" - placeholder="输入项目名称" - /> -
-
- - setNewDesc(e.target.value)} - className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all" - placeholder="输入项目描述" - /> -
-
-
- - -
-
-
- )}
); } diff --git a/src/components/UserAdmin.test.tsx b/src/components/UserAdmin.test.tsx index ac16886..58396cd 100644 --- a/src/components/UserAdmin.test.tsx +++ b/src/components/UserAdmin.test.tsx @@ -114,8 +114,8 @@ describe('UserAdmin', () => { { id: '7', name: '演视LC视频序列', - status: 'pending', - frames: 0, + status: 'ready', + frames: 750, fps: '30FPS', source_type: 'video', video_path: 'uploads/7/演视LC视频序列.mp4', diff --git a/src/components/UserAdmin.tsx b/src/components/UserAdmin.tsx index 28f5de8..0dddcb4 100644 --- a/src/components/UserAdmin.tsx +++ b/src/components/UserAdmin.tsx @@ -337,7 +337,7 @@ export function UserAdmin() {
演示环境出厂设置

- 清空演示过程产生的用户、项目帧、标注、任务和私有模板,只保留默认 admin、“演视LC视频序列”和已按文件名顺序生成帧的“演视DICOM序列”。 + 清空演示过程产生的用户、项目帧、标注、任务和私有模板,只保留默认 admin、已生成帧的“演视LC视频序列”和已按文件名顺序生成帧的“演视DICOM序列”。