20260429_232813-fix: video frame display pipeline — default project seed, presigned URLs, Canvas/FrameTimeline real frames, upload-parse flow

This commit is contained in:
2026-04-29 23:42:18 +08:00
parent 51f1a60216
commit 35d6e1503c
16 changed files with 454 additions and 56 deletions

View File

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

View File

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

View File

@@ -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.",
}

View File

@@ -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(

View File

@@ -32,6 +32,7 @@ class ProjectOut(ProjectBase):
id: int
created_at: datetime
updated_at: datetime
frame_count: int = 0
# ---------------------------------------------------------------------------

View File

@@ -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)

View File

@@ -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<HTMLDivElement>(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,
});

View File

@@ -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 (
<div className="h-32 bg-[#111] border-t border-white/5 flex flex-col shrink-0 z-20">
@@ -17,19 +24,20 @@ export function FrameTimeline() {
<input
type="range"
min="1"
max={totalFrames}
max={Math.max(totalFrames, 1)}
value={currentFrame}
onChange={(e) => 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}
/>
<div className="h-1 bg-white/10 w-full relative group-hover:h-2 transition-all">
<div
className="h-full bg-cyan-500 absolute left-0"
style={{ width: `${(currentFrame / totalFrames) * 100}%` }}
style={{ width: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
/>
<div
className="w-3 h-3 bg-white rounded-full absolute top-1/2 -translate-y-1/2 -ml-1.5 shadow-sm transform scale-0 group-hover:scale-100 transition-transform shadow-cyan-500/50"
style={{ left: `${(currentFrame / totalFrames) * 100}%` }}
style={{ left: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
/>
</div>
</div>
@@ -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 <div key={`empty-${frameIdx}`} className="w-16 h-12 opacity-0 shrink-0" />
}
const isCurrent = frameIdx === currentFrame;
return (
<div
key={frameIdx}
onClick={() => 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"
)}
>
<div className="absolute inset-0 bg-gradient-to-br from-gray-800 to-gray-900 opacity-20" />
<span className={cn("text-[9px] font-mono text-white text-center z-10", isCurrent ? "text-[10px] font-bold" : "")}>
{frameIdx.toString().padStart(4, '0')}
</span>
</div>
);
})}
{totalFrames === 0 ? (
<div className="text-xs text-gray-600 font-mono"></div>
) : (
displayIndices.map((idx) => {
if (idx < 0 || idx >= totalFrames) {
return <div key={`empty-${idx}`} className="w-16 h-12 opacity-0 shrink-0" />
}
const frame = frames[idx];
const isCurrent = idx === currentFrameIndex;
return (
<div
key={frame.id}
onClick={() => 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 ? (
<img
src={frame.url}
alt={`frame-${idx}`}
className="absolute inset-0 w-full h-full object-cover"
loading="lazy"
draggable={false}
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-gray-800 to-gray-900 opacity-20" />
)}
<span className={cn("text-[9px] font-mono text-white text-center z-10 drop-shadow", isCurrent ? "text-[10px] font-bold" : "")}>
{(idx + 1).toString().padStart(4, '0')}
</span>
</div>
);
})
)}
</div>
<div className="w-48 text-right shrink-0">

View File

@@ -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) {

View File

@@ -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 (
<div className="w-full h-full flex flex-col bg-[#0a0a0a]">
@@ -16,7 +72,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
<div className="flex items-center gap-4">
<h2 className="text-xs font-semibold uppercase tracking-widest text-gray-400"></h2>
<div className="h-4 w-px bg-white/10"></div>
<span className="text-sm text-white font-mono">Autonomous_Nav_Cam_Left.mp4</span>
<span className="text-sm text-white font-mono">{currentProject?.name || '未选择项目'}</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 text-[10px] uppercase font-medium">
@@ -37,7 +93,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
<div className="flex-1 relative flex items-center justify-center p-8 bg-[#151515] overflow-hidden">
<div className="relative w-full h-full bg-[#1e1e1e] border border-white/5 shadow-2xl rounded-sm">
<CanvasArea activeTool={activeTool} />
<CanvasArea activeTool={activeTool} frameUrl={currentFrameUrl} />
</div>
</div>

View File

@@ -42,7 +42,17 @@ export async function login(username: string, password: string): Promise<{ token
// Projects
export async function getProjects(): Promise<Project[]> {
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<Project> {
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<Project>): Promise<Project> {
@@ -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<Array<{
id: number;
project_id: number;
frame_index: number;
image_url: string;
width: number | null;
height: number | null;
}>> {
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;

View File

@@ -8,6 +8,7 @@ export interface Project {
fps?: string;
frames?: number;
thumbnail?: string;
video_path?: string;
createdAt?: string;
updatedAt?: string;
}

View File

@@ -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<Array<{ id: number; project_id: number; frame_index: number; image_url: string; width: number | null; height: number | null }>>
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`
- 每个帧方块显示为 `<img>` 缩略图
- 点击调用 `setCurrentFrame(index)`
- 进度条范围 1..frames.length
### 9. `src/components/ProjectLibrary.tsx` — 完整上传链路
上传流程改为:
1. `createProject({ name: file.name })`
2. `uploadMedia(file, projectId)`
3. `parseMedia(projectId)`
4. `getProjects()` 刷新列表

View File

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

View File

@@ -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 URLMinIO 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`,渲染真实帧缩略图 `<img>`,点击切换当前帧
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. 具体问题

View File

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