From 35d6e1503c1b12722695b47b676c84301302d832 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Wed, 29 Apr 2026 23:42:18 +0800 Subject: [PATCH] =?UTF-8?q?20260429=5F232813-fix:=20video=20frame=20displa?= =?UTF-8?q?y=20pipeline=20=E2=80=94=20default=20project=20seed,=20presigne?= =?UTF-8?q?d=20URLs,=20Canvas/FrameTimeline=20real=20frames,=20upload-pars?= =?UTF-8?q?e=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config.py | 2 +- backend/main.py | 84 +++++++++++++++++++++++++++- backend/routers/media.py | 19 +++++++ backend/routers/projects.py | 12 +++- backend/schemas.py | 1 + backend/services/frame_parser.py | 14 ++--- src/components/CanvasArea.tsx | 9 +-- src/components/FrameTimeline.tsx | 81 +++++++++++++++++---------- src/components/ProjectLibrary.tsx | 14 +++-- src/components/VideoWorkspace.tsx | 62 +++++++++++++++++++- src/lib/api.ts | 49 +++++++++++++++- src/store/useStore.ts | 1 + 工程分析/实现方案-20260429_232813.md | 66 ++++++++++++++++++++++ 工程分析/测试方案-20260429_232813.md | 37 ++++++++++++ 工程分析/经验记录.md | 33 +++++++++++ 工程分析/需求分析-20260429_232813.md | 26 +++++++++ 16 files changed, 454 insertions(+), 56 deletions(-) create mode 100644 工程分析/实现方案-20260429_232813.md create mode 100644 工程分析/测试方案-20260429_232813.md create mode 100644 工程分析/需求分析-20260429_232813.md diff --git a/backend/config.py b/backend/config.py index f258153..1e543f6 100644 --- a/backend/config.py +++ b/backend/config.py @@ -13,7 +13,7 @@ class Settings(BaseSettings): redis_url: str = "redis://localhost:6379/0" # MinIO - minio_endpoint: str = "localhost:9000" + minio_endpoint: str = "192.168.3.11:9000" minio_access_key: str = "minioadmin" minio_secret_key: str = "minioadmin" minio_secure: bool = False diff --git a/backend/main.py b/backend/main.py index a7fbf09..4a4dc4c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,14 +1,18 @@ """FastAPI application entrypoint.""" +import asyncio import logging +import os +import shutil +import tempfile from contextlib import asynccontextmanager from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from config import settings -from database import Base, engine -from minio_client import ensure_bucket_exists +from database import Base, engine, SessionLocal +from minio_client import ensure_bucket_exists, upload_file from redis_client import ping as redis_ping from routers import projects, templates, media, ai, export, auth @@ -19,6 +23,76 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) +DEFAULT_VIDEO_PATH = "/home/wkmgc/Desktop/Seg_Server/Data_MyVideo_1.mp4" + + +def _seed_default_project_sync() -> None: + """Synchronously seed the default video project on first startup.""" + import cv2 + from models import Project, Frame + from services.frame_parser import parse_video, upload_frames_to_minio + + db = SessionLocal() + try: + existing = db.query(Project).filter(Project.name == "Data_MyVideo_1").first() + if existing is not None: + return + + if not os.path.exists(DEFAULT_VIDEO_PATH): + logger.warning("Default video not found at %s", DEFAULT_VIDEO_PATH) + return + + project = Project( + name="Data_MyVideo_1", + description="默认演示视频", + status="pending", + ) + db.add(project) + db.commit() + db.refresh(project) + + with open(DEFAULT_VIDEO_PATH, "rb") as f: + data = f.read() + object_name = f"uploads/{project.id}/Data_MyVideo_1.mp4" + upload_file(object_name, data, content_type="video/mp4", length=len(data)) + + project.video_path = object_name + db.commit() + + # Parse frames + tmp_dir = tempfile.mkdtemp(prefix=f"seg_seed_{project.id}_") + try: + local_path = os.path.join(tmp_dir, "video.mp4") + with open(local_path, "wb") as f: + f.write(data) + output_dir = os.path.join(tmp_dir, "frames") + os.makedirs(output_dir, exist_ok=True) + frame_files = parse_video(local_path, output_dir, fps=30, max_frames=100) + + object_names = upload_frames_to_minio(frame_files, project.id) + + for idx, obj_name in enumerate(object_names): + img = cv2.imread(frame_files[idx]) + h, w = img.shape[:2] if img is not None else (None, None) + frame = Frame( + project_id=project.id, + frame_index=idx, + image_url=obj_name, + width=w, + height=h, + ) + db.add(frame) + + project.status = "ready" + db.commit() + logger.info("Seeded default project id=%s with %d frames", project.id, len(object_names)) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + except Exception as exc: + logger.error("Failed to seed default project: %s", exc) + finally: + db.close() + @asynccontextmanager async def lifespan(app: FastAPI): @@ -45,6 +119,12 @@ async def lifespan(app: FastAPI): else: logger.warning("Redis connection failed.") + # Seed default project in background thread so it doesn't block startup + try: + asyncio.create_task(asyncio.to_thread(_seed_default_project_sync)) + except Exception as exc: # noqa: BLE001 + logger.error("Failed to start default project seeding: %s", exc) + yield # Shutdown diff --git a/backend/routers/media.py b/backend/routers/media.py index f282756..0fc7f73 100644 --- a/backend/routers/media.py +++ b/backend/routers/media.py @@ -71,6 +71,24 @@ async def upload_media( logger.info("Linked upload to project_id=%s", project_id) else: logger.warning("Project id=%s not found for upload linkage", project_id) + else: + # Auto-create a project named after the file + project = Project( + name=file.filename, + description="Auto-created from upload", + status="pending", + video_path=object_name, + ) + db.add(project) + db.commit() + db.refresh(project) + project_id = project.id + object_name = f"uploads/{project_id}/{file.filename}" + # Re-upload with corrected path + upload_file(object_name, data, content_type=file.content_type or "application/octet-stream", length=len(data)) + project.video_path = object_name + db.commit() + logger.info("Auto-created project id=%s for upload %s", project_id, file.filename) # TODO: enqueue async parsing job (Celery / background task) logger.info("Upload complete: %s (size=%d bytes). Async parsing queued.", object_name, len(data)) @@ -79,6 +97,7 @@ async def upload_media( "object_name": object_name, "file_url": file_url, "size": len(data), + "project_id": project_id, "message": "Upload successful. Parsing job queued.", } diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 3d8e150..256b312 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session from database import get_db from models import Project, Frame from schemas import ProjectCreate, ProjectOut, ProjectUpdate, FrameCreate, FrameOut +from minio_client import get_presigned_url logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["Projects"]) @@ -40,7 +41,10 @@ def create_project(payload: ProjectCreate, db: Session = Depends(get_db)) -> Pro ) def list_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)) -> List[Project]: """Retrieve a paginated list of projects.""" - return db.query(Project).offset(skip).limit(limit).all() + projects = db.query(Project).offset(skip).limit(limit).all() + for p in projects: + p.frame_count = len(p.frames) + return projects @router.get( @@ -53,6 +57,7 @@ def get_project(project_id: int, db: Session = Depends(get_db)) -> Project: project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") + project.frame_count = len(project.frames) return project @@ -138,7 +143,7 @@ def list_frames( if not project: raise HTTPException(status_code=404, detail="Project not found") - return ( + frames = ( db.query(Frame) .filter(Frame.project_id == project_id) .order_by(Frame.frame_index) @@ -146,6 +151,9 @@ def list_frames( .limit(limit) .all() ) + for frame in frames: + frame.image_url = get_presigned_url(frame.image_url, expires=3600) + return frames @router.get( diff --git a/backend/schemas.py b/backend/schemas.py index a07b83e..84ee914 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -32,6 +32,7 @@ class ProjectOut(ProjectBase): id: int created_at: datetime updated_at: datetime + frame_count: int = 0 # --------------------------------------------------------------------------- diff --git a/backend/services/frame_parser.py b/backend/services/frame_parser.py index 8c263d8..8c2e4e7 100644 --- a/backend/services/frame_parser.py +++ b/backend/services/frame_parser.py @@ -39,12 +39,12 @@ def parse_video( # Try FFmpeg first if shutil.which("ffmpeg"): try: - pattern = os.path.join(output_dir, "frame_%06d.png") + pattern = os.path.join(output_dir, "frame_%06d.jpg") cmd = [ "ffmpeg", "-i", video_path, - "-vf", f"fps={fps},scale='min(1920,iw)':-1", - "-pix_fmt", "rgb24", + "-vf", f"fps={fps},scale=640:-1", + "-q:v", "5", "-y", pattern, ] @@ -52,7 +52,7 @@ def parse_video( result = subprocess.run(cmd, capture_output=True, text=True, check=False) if result.returncode == 0: frame_paths = sorted( - [os.path.join(output_dir, f) for f in os.listdir(output_dir) if f.endswith(".png")] + [os.path.join(output_dir, f) for f in os.listdir(output_dir) if f.endswith(".jpg")] ) if max_frames: frame_paths = frame_paths[:max_frames] @@ -79,8 +79,8 @@ def parse_video( if not ret: break if count % interval == 0: - path = os.path.join(output_dir, f"frame_{saved:06d}.png") - cv2.imwrite(path, frame) + path = os.path.join(output_dir, f"frame_{saved:06d}.jpg") + cv2.imwrite(path, frame, [cv2.IMWRITE_JPEG_QUALITY, 80]) frame_paths.append(path) saved += 1 if max_frames and saved >= max_frames: @@ -175,7 +175,7 @@ def upload_frames_to_minio( upload_file( object_name, data, - content_type="image/png", + content_type="image/jpeg", length=len(data), ) object_names.append(object_name) diff --git a/src/components/CanvasArea.tsx b/src/components/CanvasArea.tsx index b23c623..80c3035 100644 --- a/src/components/CanvasArea.tsx +++ b/src/components/CanvasArea.tsx @@ -7,9 +7,10 @@ import { cn } from '../lib/utils'; interface CanvasAreaProps { activeTool: string; + frameUrl: string; } -export function CanvasArea({ activeTool }: CanvasAreaProps) { +export function CanvasArea({ activeTool, frameUrl }: CanvasAreaProps) { const containerRef = useRef(null); const [stageSize, setStageSize] = useState({ width: 800, height: 600 }); const [scale, setScale] = useState(1); @@ -28,8 +29,8 @@ export function CanvasArea({ activeTool }: CanvasAreaProps) { const effectiveTool = activeTool || storeActiveTool; - // We load a mock image representing a frame - const [image] = useImage('https://images.unsplash.com/photo-1549317661-bd32c8ce0be2?q=80&w=2070&auto=format&fit=crop'); + // Load the actual frame image + const [image] = useImage(frameUrl || ''); useEffect(() => { const handleResize = () => { @@ -87,7 +88,7 @@ export function CanvasArea({ activeTool }: CanvasAreaProps) { setIsInferencing(true); try { const result = await predictMask({ - imageUrl: 'https://images.unsplash.com/photo-1549317661-bd32c8ce0be2?q=80&w=2070&auto=format&fit=crop', + imageUrl: frameUrl || '', points: promptPoints?.map((p) => ({ x: p.x, y: p.y, type: p.type })), box: promptBox, }); diff --git a/src/components/FrameTimeline.tsx b/src/components/FrameTimeline.tsx index d742e06..67937b1 100644 --- a/src/components/FrameTimeline.tsx +++ b/src/components/FrameTimeline.tsx @@ -1,15 +1,22 @@ import React, { useState } from 'react'; import { Play, Pause } from 'lucide-react'; import { cn } from '../lib/utils'; +import { useStore } from '../store/useStore'; export function FrameTimeline() { - const [currentFrame, setCurrentFrame] = useState(142); - const totalFrames = 2400; + const frames = useStore((state) => state.frames); + const currentFrameIndex = useStore((state) => state.currentFrameIndex); + const setCurrentFrame = useStore((state) => state.setCurrentFrame); const [isPlaying, setIsPlaying] = useState(false); + const totalFrames = frames.length; + const currentFrame = totalFrames > 0 ? currentFrameIndex + 1 : 0; + // show frames around current frame - const frameWindow = 20; - const frames = Array.from({ length: 41 }, (_, i) => currentFrame - frameWindow + i); + const frameWindow = 20; + const displayIndices = totalFrames > 0 + ? Array.from({ length: 41 }, (_, i) => currentFrameIndex - frameWindow + i) + : []; return (
@@ -17,19 +24,20 @@ export function FrameTimeline() { setCurrentFrame(parseInt(e.target.value))} + onChange={(e) => setCurrentFrame(parseInt(e.target.value) - 1)} className="w-full absolute inset-0 opacity-0 cursor-ew-resize z-20" + disabled={totalFrames === 0} />
0 ? (currentFrame / totalFrames) * 100 : 0}%` }} />
0 ? (currentFrame / totalFrames) * 100 : 0}%` }} />
@@ -49,27 +57,42 @@ export function FrameTimeline() { className="flex-1 flex gap-px flex-nowrap items-center overflow-hidden justify-center" style={{ WebkitMaskImage: 'linear-gradient(to right, transparent, black 10%, black 90%, transparent)' }} > - {frames.map((frameIdx) => { - if (frameIdx < 1 || frameIdx > totalFrames) { - return
- } - const isCurrent = frameIdx === currentFrame; - return ( -
setCurrentFrame(frameIdx)} - className={cn( - "relative shrink-0 rounded-sm transition-all cursor-pointer flex items-center justify-center overflow-hidden group mx-0.5", - isCurrent ? "w-28 h-16 border-2 border-cyan-500 bg-gray-700 shadow-[0_0_15px_rgba(6,182,212,0.3)] z-10" : "w-16 h-12 border border-white/5 bg-gray-800/50 opacity-40 hover:opacity-100" - )} - > -
- - {frameIdx.toString().padStart(4, '0')} - -
- ); - })} + {totalFrames === 0 ? ( +
暂无帧数据
+ ) : ( + displayIndices.map((idx) => { + if (idx < 0 || idx >= totalFrames) { + return
+ } + const frame = frames[idx]; + const isCurrent = idx === currentFrameIndex; + return ( +
setCurrentFrame(idx)} + className={cn( + "relative shrink-0 rounded-sm transition-all cursor-pointer flex items-center justify-center overflow-hidden group mx-0.5", + isCurrent ? "w-28 h-16 border-2 border-cyan-500 bg-gray-700 shadow-[0_0_15px_rgba(6,182,212,0.3)] z-10" : "w-16 h-12 border border-white/5 bg-gray-800/50 opacity-40 hover:opacity-100" + )} + > + {frame.url ? ( + {`frame-${idx}`} + ) : ( +
+ )} + + {(idx + 1).toString().padStart(4, '0')} + +
+ ); + }) + )}
diff --git a/src/components/ProjectLibrary.tsx b/src/components/ProjectLibrary.tsx index 3c62024..f6d62ee 100644 --- a/src/components/ProjectLibrary.tsx +++ b/src/components/ProjectLibrary.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { UploadCloud, Film, Settings2, MoreHorizontal, Plus, Loader2 } from 'lucide-react'; import { cn } from '../lib/utils'; import { useStore } from '../store/useStore'; -import { getProjects, createProject, uploadMedia } from '../lib/api'; +import { getProjects, createProject, uploadMedia, parseMedia } from '../lib/api'; import type { Project } from '../store/useStore'; interface ProjectLibraryProps { @@ -92,15 +92,17 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) { if (!file) return; try { setIsLoading(true); - const result = await uploadMedia(file); - alert(`上传成功: ${file.name}\n已保存至: ${result.file_url}`); - // 上传成功后创建新项目 + // 1. 创建项目 const newProject = await createProject({ name: file.name, description: `导入于 ${new Date().toLocaleString()}`, }); - addProject(newProject); - // 刷新项目列表 + // 2. 带 project_id 上传 + const result = await uploadMedia(file, String(newProject.id)); + // 3. 触发帧解析 + await parseMedia(String(newProject.id)); + alert(`上传成功: ${file.name}\n已保存至: ${result.url}`); + // 4. 刷新项目列表 const data = await getProjects(); setProjects(data); } catch (err) { diff --git a/src/components/VideoWorkspace.tsx b/src/components/VideoWorkspace.tsx index 128f08d..cb2ab52 100644 --- a/src/components/VideoWorkspace.tsx +++ b/src/components/VideoWorkspace.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { useStore } from '../store/useStore'; +import { getProjectFrames, parseMedia } from '../lib/api'; import { CanvasArea } from './CanvasArea'; import { ToolsPalette } from './ToolsPalette'; import { OntologyInspector } from './OntologyInspector'; @@ -8,6 +9,61 @@ import { FrameTimeline } from './FrameTimeline'; export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }) { const activeTool = useStore((state) => state.activeTool); const setActiveTool = useStore((state) => state.setActiveTool); + const currentProject = useStore((state) => state.currentProject); + const frames = useStore((state) => state.frames); + const currentFrameIndex = useStore((state) => state.currentFrameIndex); + const setFrames = useStore((state) => state.setFrames); + const setCurrentFrame = useStore((state) => state.setCurrentFrame); + + useEffect(() => { + if (!currentProject?.id) return; + let cancelled = false; + + const loadFrames = async () => { + try { + const data = await getProjectFrames(String(currentProject.id)); + if (cancelled) return; + + if (data.length === 0 && currentProject.video_path) { + // No frames yet but video exists → trigger parsing + try { + await parseMedia(String(currentProject.id)); + if (cancelled) return; + const fresh = await getProjectFrames(String(currentProject.id)); + if (cancelled) return; + setFrames(fresh.map((f) => ({ + id: String(f.id), + projectId: String(f.project_id), + index: f.frame_index, + url: f.image_url, + width: f.width ?? 0, + height: f.height ?? 0, + }))); + setCurrentFrame(0); + } catch (err) { + console.error('Parse failed:', err); + } + } else { + setFrames(data.map((f) => ({ + id: String(f.id), + projectId: String(f.project_id), + index: f.frame_index, + url: f.image_url, + width: f.width ?? 0, + height: f.height ?? 0, + }))); + setCurrentFrame(0); + } + } catch (err) { + console.error('Failed to load frames:', err); + } + }; + + loadFrames(); + return () => { cancelled = true; }; + }, [currentProject?.id, setFrames, setCurrentFrame]); + + const currentFrameUrl = frames[currentFrameIndex]?.url || ''; return (
@@ -16,7 +72,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void

核心分割工作区

- Autonomous_Nav_Cam_Left.mp4 + {currentProject?.name || '未选择项目'}
@@ -37,7 +93,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
- +
diff --git a/src/lib/api.ts b/src/lib/api.ts index 053a480..8658153 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -42,7 +42,17 @@ export async function login(username: string, password: string): Promise<{ token // Projects export async function getProjects(): Promise { const response = await apiClient.get('/api/projects'); - return response.data; + return response.data.map((p: any) => ({ + id: String(p.id), + name: p.name, + description: p.description, + status: p.status, + frames: p.frame_count ?? 0, + fps: '30FPS', + createdAt: p.created_at, + updatedAt: p.updated_at, + video_path: p.video_path, + })); } export async function createProject(payload: { @@ -50,7 +60,18 @@ export async function createProject(payload: { description?: string; }): Promise { const response = await apiClient.post('/api/projects', payload); - return response.data; + const p = response.data; + return { + id: String(p.id), + name: p.name, + description: p.description, + status: p.status, + frames: p.frame_count ?? 0, + fps: '30FPS', + createdAt: p.created_at, + updatedAt: p.updated_at, + video_path: p.video_path, + }; } export async function updateProject(id: string, payload: Partial): Promise { @@ -102,6 +123,30 @@ export async function uploadMedia(file: File, projectId?: string): Promise<{ url return { url: file_url, id: object_name }; } +export async function getProjectFrames(projectId: string): Promise> { + const response = await apiClient.get(`/api/projects/${projectId}/frames`); + return response.data; +} + +export async function parseMedia(projectId: string): Promise<{ + project_id: number; + frames_extracted: number; + status: string; + message: string; +}> { + const response = await apiClient.post('/api/media/parse', null, { + params: { project_id: projectId }, + }); + return response.data; +} + // AI Prediction export async function predictMask(payload: { imageUrl: string; diff --git a/src/store/useStore.ts b/src/store/useStore.ts index 5af53ac..8ea984a 100644 --- a/src/store/useStore.ts +++ b/src/store/useStore.ts @@ -8,6 +8,7 @@ export interface Project { fps?: string; frames?: number; thumbnail?: string; + video_path?: string; createdAt?: string; updatedAt?: string; } diff --git a/工程分析/实现方案-20260429_232813.md b/工程分析/实现方案-20260429_232813.md new file mode 100644 index 0000000..e1cb1c3 --- /dev/null +++ b/工程分析/实现方案-20260429_232813.md @@ -0,0 +1,66 @@ +# 实现方案 — 视频帧显示链路修复 + +## 后端 + +### 1. `backend/schemas.py` — ProjectOut 增加 frame_count +```python +class ProjectOut(ProjectBase): + id: int + created_at: datetime + updated_at: datetime + frame_count: int = 0 +``` + +### 2. `backend/routers/projects.py` — 三处修改 +- `list_projects`: 为每个项目计算 `frame_count = len(p.frames)`(利用 ORM relationship) +- `list_frames`: 返回前将每个 `frame.image_url` 替换为 `get_presigned_url(frame.image_url, expires=3600)` +- `get_project`: 同样添加 `frame_count` + +### 3. `backend/main.py` — lifespan 默认视频种子 +启动时异步后台任务: +- 若数据库无项目且 `Data_MyVideo_1.mp4` 存在 +- 创建 Project → 上传 MinIO → 调用 FFmpeg 解析帧 → 上传帧到 MinIO → 注册 Frame 记录 → 更新 status="ready" +- 使用 `asyncio.to_thread()` 避免阻塞事件循环 + +### 4. `backend/routers/media.py` — upload 自动创建项目 +当 `project_id` 为空时: +- 以文件名创建 Project +- 将上传文件关联到该 project +- 返回中增加 `project_id` + +## 前端 + +### 5. `src/lib/api.ts` — 新增两个 API +```ts +export async function getProjectFrames(projectId: string): Promise> +export async function parseMedia(projectId: string): Promise<{ project_id: number; frames_extracted: number; status: string; message: string }> +``` + +### 6. `src/components/VideoWorkspace.tsx` — 帧加载中枢 +- 读取 `currentProject` 和 `frames` +- `useEffect` 监听 `currentProject.id`: + - 调用 `getProjectFrames` + - 映射后端字段到前端 `Frame[]` 结构写入 store + - 若帧数为 0 且项目有 video_path,自动调用 `parseMedia` 触发解析,解析完成后重新获取 +- 将当前帧 URL 通过 prop 传给 `CanvasArea` +- 将帧列表和索引控制传给 `FrameTimeline` +- 标题显示 `currentProject?.name` + +### 7. `src/components/CanvasArea.tsx` — 真实帧渲染 +- 新增 `frameUrl` prop +- `const [image] = useImage(frameUrl || '')` +- `runInference` 使用 `frameUrl` 替代硬编码 URL + +### 8. `src/components/FrameTimeline.tsx` — 真实帧导航 +- 从 store 读取 `frames` 和 `currentFrameIndex` +- `totalFrames = frames.length` +- 每个帧方块显示为 `` 缩略图 +- 点击调用 `setCurrentFrame(index)` +- 进度条范围 1..frames.length + +### 9. `src/components/ProjectLibrary.tsx` — 完整上传链路 +上传流程改为: +1. `createProject({ name: file.name })` +2. `uploadMedia(file, projectId)` +3. `parseMedia(projectId)` +4. `getProjects()` 刷新列表 diff --git a/工程分析/测试方案-20260429_232813.md b/工程分析/测试方案-20260429_232813.md new file mode 100644 index 0000000..fdddbd8 --- /dev/null +++ b/工程分析/测试方案-20260429_232813.md @@ -0,0 +1,37 @@ +# 测试方案 — 视频帧显示链路修复 + +## 环境准备 +- 确保 `Data_MyVideo_1.mp4` 存在于 `/home/wkmgc/Desktop/Seg_Server/` +- 清除数据库中现有项目(或直接删除 `backend/segserver.db` 让后端重新初始化) + +## 测试用例 + +### TC1 — 默认项目自动出现 +1. 删除数据库文件,重启后端 +2. 打开项目库页面 +3. **预期**: 出现 "Data_MyVideo_1" 项目卡片,状态为"已就绪",帧数 > 0 + +### TC2 — 点击默认项目进入工作区能看到影像 +1. 点击 "Data_MyVideo_1" 项目 +2. 进入分割工作区 +3. **预期**: + - 标题栏显示 "Data_MyVideo_1" + - Canvas 区域显示视频第一帧画面(非黑屏) + - 底部时间轴显示真实帧缩略图 + +### TC3 — 帧切换正常 +1. 在工作区中点击底部时间轴的不同帧 +2. **预期**: Canvas 画面切换到对应帧 + +### TC4 — 导入新视频完整链路 +1. 在项目库点击"导入多媒体资源" +2. 选择任意视频文件上传 +3. **预期**: + - 上传过程中显示 loading + - 上传完成后新项目出现在列表中 + - 点击新项目进入工作区,能看到视频帧 + +### TC5 — AI 推理使用当前帧 +1. 在工作区选择点提示或框提示工具 +2. 在画面上点击/框选 +3. **预期**: 请求 payload 中的 `imageUrl` 为当前帧的 MinIO presigned URL,后端返回 mask diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 5916f59..b1f042d 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -5,6 +5,39 @@ --- +## 2026-04-29-23-28-13 — 视频帧显示链路全修复 + +### A. 具体问题 +1. 项目库中没有默认视频 `Data_MyVideo_1.mp4` +2. 分割工作区点击视频后画面一片黑(无影像) +3. 导入新视频后进入工作区同样看不到视频帧 + +### B. 产生原因 +1. 后端 lifespan 没有任何自动扫描/注册本地视频的逻辑;`server.ts` 的硬编码项目对前端不可见 +2. `CanvasArea.tsx` 硬编码了一张 Unsplash 外链,没有从 Zustand store 读取帧数据;进入工作区时不调用帧列表 API +3. 上传流程不完整:未先创建项目再带 project_id 上传,上传后未触发 `/api/media/parse` 帧提取;后端 `/frames` 返回的是 MinIO 对象名而非可访问的 presigned URL;MinIO presigned URL 的 host 为 `localhost:9000`,浏览器端无法访问 +4. 系统磁盘空间(24GB)紧张,MinIO 在提取大量高清 PNG 帧时触发 `XMinioStorageFull`,导致帧上传失败 + +### C. 解决方案 +1. **后端默认视频种子**:`main.py` lifespan 中启动后台线程,自动检测 `Data_MyVideo_1.mp4`,创建 Project → 上传 MinIO → 调用 FFmpeg 解析帧 → 注册 Frame 记录 +2. **后端帧 URL 修复**:`projects.py` 的 `list_frames` 在返回前为每个 `frame.image_url` 调用 `get_presigned_url()` 生成带签名的可访问 URL +3. **后端 presigned URL host 修复**:`.env` 中 `MINIO_ENDPOINT` 从 `localhost:9000` 改为 `192.168.3.11:9000`,确保浏览器端可访问 +4. **后端 ProjectOut 增强**:`schemas.py` 添加 `frame_count`,`projects.py` 在查询时计算 `len(p.frames)`,供前端显示帧数 +5. **后端 upload 自动创建项目**:`media.py` 的 `upload_media` 在不传 `project_id` 时自动以文件名创建 Project 并关联视频 +6. **前端帧加载中枢**:`VideoWorkspace.tsx` 在 `currentProject` 变化时调用 `getProjectFrames`,若帧数为 0 则自动触发 `parseMedia`,获取后映射写入 Zustand store +7. **前端 Canvas 真实渲染**:`CanvasArea.tsx` 接收 `frameUrl` prop,使用 `useImage(frameUrl)` 加载真实帧,AI 推理也使用当前帧 URL +8. **前端时间轴真实帧**:`FrameTimeline.tsx` 从 store 读取 `frames` 和 `currentFrameIndex`,渲染真实帧缩略图 ``,点击切换当前帧 +9. **前端上传链路完善**:`ProjectLibrary.tsx` 上传流程改为:创建项目 → `uploadMedia(file, projectId)` → `parseMedia(projectId)` → 刷新列表 +10. **磁盘空间优化**:将 `frame_parser.py` 的 FFmpeg 输出从 PNG (1920px, ~3MB/张) 改为 JPEG (640px, ~30KB/张),并限制默认视频只提取 100 帧,避免 MinIO 存储溢出 + +### D. 后续如何避免问题 +1. **MinIO endpoint 必须使用服务器 IP**:任何生成外部可访问 URL 的服务(MinIO presigned、后端 baseURL、WebSocket)都必须使用服务器 LAN IP,禁止 localhost +2. **大文件/视频处理必须考虑磁盘预算**:提取帧前估算总大小(帧数 × 单帧大小),必要时降低分辨率、改格式为 JPEG、限制 max_frames +3. **前后端数据字段必须显式映射**:后端 snake_case 与前端 camelCase 不一致时,API 层必须做转换,不能依赖隐式兼容 +4. **上传-解析-显示链路必须闭环测试**:任何涉及文件上传的功能,测试用例必须覆盖:上传 → 后端存储 → 解析 → 前端获取 → 前端渲染 的全流程 + +--- + ## 2026-04-29-23-15-26 — 上传/WS/项目库三 Bug 联修 ### A. 具体问题 diff --git a/工程分析/需求分析-20260429_232813.md b/工程分析/需求分析-20260429_232813.md new file mode 100644 index 0000000..a77b467 --- /dev/null +++ b/工程分析/需求分析-20260429_232813.md @@ -0,0 +1,26 @@ +# 需求分析 — 视频帧显示链路修复 + +## 问题背景 +用户报告三个关联问题: +1. 项目库中没有默认视频 `Data_MyVideo_1.mp4` +2. 点击默认视频进入分割工作区后,画面区域一片黑(无影像) +3. 导入新视频后进入工作区,同样看不到视频帧 + +## 根因分析 +| 问题 | 根因 | +|------|------| +| 无默认项目 | 后端 lifespan 无自动种子逻辑;`server.ts` 硬编码项目但前端根本不请求 Express | +| 工作区黑屏 | `CanvasArea.tsx` 硬编码 Unsplash 外链,未从 store 读取帧;进入工作区不调用帧列表 API | +| 导入后黑屏 | 上传未关联 project_id;上传后未触发 `/api/media/parse` 帧提取;后端 `/frames` 返回的是 MinIO 对象名而非可访问 URL | + +## 需求拆解 +| 编号 | 需求 | 优先级 | +|------|------|--------| +| R1 | 后端启动时自动检测并注册 `Data_MyVideo_1.mp4` 为默认项目 | P0 | +| R2 | 后端 `/api/projects/{id}/frames` 返回 presigned URL 而非对象名 | P0 | +| R3 | 后端 `ProjectOut` 增加 `frame_count` 供前端显示 | P1 | +| R4 | 前端 `VideoWorkspace` 进入时自动获取帧列表并写入 store | P0 | +| R5 | 前端 `CanvasArea` 从 store 读取当前帧 URL 并渲染 | P0 | +| R6 | 前端 `FrameTimeline` 从 store 读取真实帧数据,支持切换 | P0 | +| R7 | 前端 `ProjectLibrary` 上传流程:创建项目 → 带 project_id 上传 → 触发解析 | P0 | +| R8 | 后端 upload 接口支持无 project_id 时自动创建项目 | P1 |