auto-save 2026-05-12 15:57 (~5)
This commit is contained in:
83
api/main.py
83
api/main.py
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import BackgroundTasks, FastAPI, HTTPException
|
||||
from fastapi import BackgroundTasks, FastAPI, File, HTTPException, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -106,7 +106,9 @@ app.add_middleware(
|
||||
def run(cmd: list[str], cwd: Path | None = None) -> str:
|
||||
res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(f"cmd failed: {' '.join(cmd[:3])}... · {res.stderr[:500]}")
|
||||
# ffmpeg 把 banner 写 stderr,挑最后几行(真错误一般在末尾)
|
||||
tail = "\n".join(res.stderr.splitlines()[-12:]) or res.stderr[-800:]
|
||||
raise RuntimeError(f"cmd failed: {' '.join(cmd[:3])}... · {tail}")
|
||||
return res.stdout
|
||||
|
||||
|
||||
@@ -122,18 +124,21 @@ async def pipeline_download_split_frames(job_id: str) -> None:
|
||||
job = JOBS[job_id]
|
||||
d = job_dir(job_id)
|
||||
try:
|
||||
# ---- 1. yt-dlp 下载
|
||||
update(job, status="downloading", message="yt-dlp 下载中…", progress=5)
|
||||
mp4 = d / "source.mp4"
|
||||
run([
|
||||
"yt-dlp", "-f", "best[ext=mp4]/best",
|
||||
"-o", str(mp4),
|
||||
"--no-warnings", "--no-playlist",
|
||||
"--retries", "3",
|
||||
job.url,
|
||||
])
|
||||
if not mp4.exists():
|
||||
raise RuntimeError("下载完成但找不到 source.mp4")
|
||||
# ---- 1. yt-dlp 下载(上传模式 mp4 已存在 → 跳过)
|
||||
if mp4.exists():
|
||||
update(job, status="downloading", message="本地上传,跳过下载", progress=15)
|
||||
else:
|
||||
update(job, status="downloading", message="yt-dlp 下载中…", progress=5)
|
||||
run([
|
||||
"yt-dlp", "-f", "best[ext=mp4]/best",
|
||||
"-o", str(mp4),
|
||||
"--no-warnings", "--no-playlist",
|
||||
"--retries", "3",
|
||||
job.url,
|
||||
])
|
||||
if not mp4.exists():
|
||||
raise RuntimeError("下载完成但找不到 source.mp4")
|
||||
|
||||
# 元数据
|
||||
meta = ffprobe_meta(mp4)
|
||||
@@ -165,14 +170,20 @@ async def pipeline_download_split_frames(job_id: str) -> None:
|
||||
shutil.rmtree(frames_dir)
|
||||
frames_dir.mkdir(parents=True)
|
||||
|
||||
# 先用场景切换检测
|
||||
run([
|
||||
"ffmpeg", "-y", "-i", str(mp4),
|
||||
"-vf", "select='gt(scene,0.4)',showinfo",
|
||||
"-vsync", "vfr",
|
||||
"-frames:v", "30",
|
||||
str(frames_dir / "scene_%03d.jpg"),
|
||||
])
|
||||
# 先用场景切换检测(失败时不阻塞,走均匀采样兜底)
|
||||
try:
|
||||
run([
|
||||
"ffmpeg", "-y", "-i", str(mp4),
|
||||
"-vf", "select='gt(scene,0.4)'",
|
||||
"-fps_mode", "vfr",
|
||||
"-frames:v", "30",
|
||||
"-pix_fmt", "yuvj420p", # mjpeg encoder 要 JPEG full-range
|
||||
"-q:v", "3",
|
||||
str(frames_dir / "scene_%03d.jpg"),
|
||||
])
|
||||
except Exception:
|
||||
# 场景切换检测在某些纯合成 / 静态视频上会失败,让它静默走兜底
|
||||
pass
|
||||
scene_frames = sorted(frames_dir.glob("scene_*.jpg"))
|
||||
|
||||
# 均匀采样兜底 / 补足
|
||||
@@ -184,7 +195,9 @@ async def pipeline_download_split_frames(job_id: str) -> None:
|
||||
out = frames_dir / f"sample_{i:03d}.jpg"
|
||||
run([
|
||||
"ffmpeg", "-y", "-ss", str(t), "-i", str(mp4),
|
||||
"-frames:v", "1", "-q:v", "3", str(out),
|
||||
"-frames:v", "1",
|
||||
"-pix_fmt", "yuvj420p",
|
||||
"-q:v", "3", str(out),
|
||||
])
|
||||
|
||||
# 统一排序、按时间戳读取、限制 10 张
|
||||
@@ -299,6 +312,32 @@ async def create_job(req: CreateJobReq, bg: BackgroundTasks) -> Job:
|
||||
return job
|
||||
|
||||
|
||||
@app.post("/jobs/upload", response_model=Job)
|
||||
async def create_job_from_upload(bg: BackgroundTasks, file: UploadFile = File(...)) -> Job:
|
||||
if not file.filename:
|
||||
raise HTTPException(400, "file required")
|
||||
# 简化:只验后缀,不嗅探 magic bytes
|
||||
ext = Path(file.filename).suffix.lower()
|
||||
if ext not in {".mp4", ".mov", ".webm", ".mkv", ".m4v"}:
|
||||
raise HTTPException(400, f"unsupported video format: {ext}")
|
||||
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
d = job_dir(job_id)
|
||||
mp4 = d / "source.mp4"
|
||||
# 直接落盘(流式写入,避免全量进内存)
|
||||
with mp4.open("wb") as f:
|
||||
while chunk := await file.read(1024 * 1024):
|
||||
f.write(chunk)
|
||||
if not mp4.exists() or mp4.stat().st_size == 0:
|
||||
raise HTTPException(500, "upload failed")
|
||||
|
||||
job = Job(id=job_id, url=f"upload://{file.filename}")
|
||||
JOBS[job_id] = job
|
||||
save_state(job)
|
||||
bg.add_task(pipeline_download_split_frames, job_id)
|
||||
return job
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}", response_model=Job)
|
||||
def get_job(job_id: str) -> Job:
|
||||
job = JOBS.get(job_id)
|
||||
|
||||
Reference in New Issue
Block a user