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)