20260429_232813-fix: video frame display pipeline — default project seed, presigned URLs, Canvas/FrameTimeline real frames, upload-parse flow
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -32,6 +32,7 @@ class ProjectOut(ProjectBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
frame_count: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface Project {
|
||||
fps?: string;
|
||||
frames?: number;
|
||||
thumbnail?: string;
|
||||
video_path?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
66
工程分析/实现方案-20260429_232813.md
Normal file
66
工程分析/实现方案-20260429_232813.md
Normal 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()` 刷新列表
|
||||
37
工程分析/测试方案-20260429_232813.md
Normal file
37
工程分析/测试方案-20260429_232813.md
Normal 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
|
||||
33
工程分析/经验记录.md
33
工程分析/经验记录.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`,渲染真实帧缩略图 `<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. 具体问题
|
||||
|
||||
26
工程分析/需求分析-20260429_232813.md
Normal file
26
工程分析/需求分析-20260429_232813.md
Normal 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 |
|
||||
Reference in New Issue
Block a user