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)
|
||||
|
||||
Reference in New Issue
Block a user