chore: sync code and project files

This commit is contained in:
Tony Zhang
2026-01-09 14:09:16 +08:00
parent 3d1fb37769
commit 30d7eb4b35
94 changed files with 12706 additions and 255 deletions

15
api/routes/__init__.py Normal file
View File

@@ -0,0 +1,15 @@
# API Routes Package

408
api/routes/assets.py Normal file
View File

@@ -0,0 +1,408 @@
"""
素材管理 API 路由
提供素材上传、列表、删除功能
"""
import os
import time
import logging
from typing import List, Optional
from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, File
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel
import config
from modules.db_manager import db
from modules.legacy_path_mapper import map_legacy_local_path
logger = logging.getLogger(__name__)
router = APIRouter()
# ============================================================
# Pydantic Models
# ============================================================
class AssetInfo(BaseModel):
id: int
project_id: str
scene_id: int
asset_type: str
status: str
local_path: Optional[str] = None
url: Optional[str] = None
metadata: dict = {}
class UploadResponse(BaseModel):
success: bool
filename: str
path: str
url: str
class StickerItem(BaseModel):
id: str
name: str
url: str
kind: str = "builtin" # builtin/custom
tags: List[str] = []
category: Optional[str] = None
license: Optional[str] = None
attribution: Optional[str] = None
# ============================================================
# API Endpoints
# ============================================================
@router.get("/list/{project_id}", response_model=List[AssetInfo])
async def list_assets(
project_id: str,
asset_type: Optional[str] = None
):
"""列出项目的所有素材"""
assets = db.get_assets(project_id, asset_type)
result = []
for asset in assets:
# 添加 URL
url = None
local_path = asset.get("local_path")
remote_url = asset.get("remote_url")
# 1) 直接可见路径:走 file proxy
if local_path and os.path.exists(local_path):
url = f"/api/assets/file/{asset.get('id', 0)}"
# 1.5) 远端 URL例如 R2/CDN。本地缺失时也要能预览
elif remote_url and isinstance(remote_url, str) and remote_url.strip():
url = remote_url.strip()
else:
# 2) legacy 映射(/root/video-flow -> /legacy/* 或 /app/*
mapped_path, mapped_url = map_legacy_local_path(local_path)
if mapped_path and os.path.exists(mapped_path):
# 静态 URL 不一定覆盖子目录;优先静态(若提供),否则走 file proxy
url = mapped_url or f"/api/assets/file/{asset.get('id', 0)}"
else:
# 3) 兜底:某些历史数据用 asset_id 命名(例如 /legacy/temp/1.png
aid = int(asset.get("id") or 0)
if aid > 0:
# 优先按原扩展名尝试
ext = Path(local_path).suffix.lower() if isinstance(local_path, str) else ""
candidates = []
if ext:
candidates.append((f"/legacy/temp/{aid}{ext}", f"/static/legacy-temp/{aid}{ext}"))
# 常见图片扩展
for e in [".png", ".jpg", ".jpeg", ".webp"]:
candidates.append((f"/legacy/temp/{aid}{e}", f"/static/legacy-temp/{aid}{e}"))
for p, u in candidates:
if os.path.exists(p):
url = u
break
result.append(AssetInfo(
id=asset.get("id", 0),
project_id=asset["project_id"],
scene_id=asset["scene_id"],
asset_type=asset["asset_type"],
status=asset["status"],
local_path=asset.get("local_path"),
url=url,
metadata=asset.get("metadata", {})
))
return result
@router.get("/file/{asset_id}")
async def get_asset_file(asset_id: int):
"""按 asset_id 直接返回文件(用于前端预览/拖拽素材)。"""
a = db.get_asset_by_id(int(asset_id))
if not a:
raise HTTPException(status_code=404, detail="素材不存在")
local_path = a.get("local_path")
remote_url = a.get("remote_url")
# 1) 原路径
if local_path and os.path.exists(local_path):
return FileResponse(path=local_path, filename=Path(local_path).name)
# 1.5) 远端 URL本地缺失时直接重定向避免前端 404 黑屏)
if remote_url and isinstance(remote_url, str) and remote_url.strip():
return RedirectResponse(url=remote_url.strip(), status_code=307)
# 2) legacy 映射
mapped_path, _ = map_legacy_local_path(local_path)
if mapped_path and os.path.exists(mapped_path):
return FileResponse(path=mapped_path, filename=Path(mapped_path).name)
# 3) asset_id 命名兜底(常见于历史图片:/legacy/temp/{id}.png
aid = int(a.get("id") or 0)
ext = Path(local_path).suffix.lower() if isinstance(local_path, str) else ""
candidates = []
if aid > 0:
if ext:
candidates.append(f"/legacy/temp/{aid}{ext}")
for e in [".png", ".jpg", ".jpeg", ".webp", ".mp4", ".mov", ".m4a", ".mp3"]:
candidates.append(f"/legacy/temp/{aid}{e}")
for p in candidates:
if os.path.exists(p):
return FileResponse(path=p, filename=Path(p).name)
raise HTTPException(status_code=404, detail="素材文件不存在")
@router.post("/upload", response_model=UploadResponse)
async def upload_asset(
file: UploadFile = File(...),
asset_type: str = "custom"
):
"""
上传自定义素材
支持图片、视频、音频
"""
# 验证文件类型
allowed_extensions = {
"image": [".jpg", ".jpeg", ".png", ".gif", ".webp"],
"video": [".mp4", ".mov", ".avi", ".mkv", ".webm"],
"audio": [".mp3", ".wav", ".m4a", ".aac"],
"custom": [".jpg", ".jpeg", ".png", ".gif", ".webp",
".mp4", ".mov", ".avi", ".mkv", ".webm",
".mp3", ".wav", ".m4a", ".aac"]
}
ext = Path(file.filename).suffix.lower()
if ext not in allowed_extensions.get(asset_type, allowed_extensions["custom"]):
raise HTTPException(
status_code=400,
detail=f"不支持的文件类型: {ext}"
)
# 生成唯一文件名
timestamp = int(time.time())
new_filename = f"{asset_type}_{timestamp}{ext}"
file_path = config.TEMP_DIR / new_filename
# 保存文件
try:
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
url = f"/static/temp/{new_filename}"
return UploadResponse(
success=True,
filename=new_filename,
path=str(file_path),
url=url
)
except Exception as e:
logger.error(f"文件上传失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/download/{filename}")
async def download_asset(filename: str):
"""下载素材文件"""
# 查找文件
for directory in [config.OUTPUT_DIR, config.TEMP_DIR]:
file_path = directory / filename
if file_path.exists():
return FileResponse(
path=str(file_path),
filename=filename,
media_type="application/octet-stream"
)
raise HTTPException(status_code=404, detail="文件不存在")
@router.delete("/{asset_id}")
async def delete_asset(asset_id: int):
"""删除素材"""
# TODO: 实现真正的删除逻辑
logger.info(f"删除素材: {asset_id}")
return {"message": "素材已删除", "id": asset_id}
@router.get("/bgm")
async def list_bgm():
"""列出可用的 BGM"""
bgm_dir = config.ASSETS_DIR / "bgm"
if not bgm_dir.exists():
return []
bgm_files = []
for f in bgm_dir.iterdir():
if f.suffix.lower() in ['.mp3', '.mp4', '.m4a', '.wav']:
bgm_files.append({
"id": f.name,
"name": f.stem,
"path": str(f),
"url": f"/static/assets/bgm/{f.name}"
})
return bgm_files
@router.get("/fonts")
async def list_fonts():
"""列出可用的字体"""
fonts = []
# 项目字体
if config.FONTS_DIR.exists():
for f in config.FONTS_DIR.iterdir():
if f.suffix.lower() in ['.ttf', '.otf', '.ttc']:
fonts.append({
"id": f.name,
"name": f.stem,
"path": str(f),
"url": f"/static/fonts/{f.name}",
# 前端预览可直接使用 id 作为 CSS font-family配合 FontFace 动态加载)
"css_family": f.name
})
# 添加系统字体选项
fonts.append({
"id": "system-pingfang",
"name": "苹方 (系统)",
"path": "/System/Library/Fonts/PingFang.ttc",
"url": None,
"css_family": "PingFang SC"
})
return fonts
@router.get("/stickers")
async def list_stickers():
"""列出贴纸PNG/SVG用于左侧贴纸库。"""
builtin_dir = config.ASSETS_DIR / "stickers_builtin"
custom_dir = config.ASSETS_DIR / "stickers_custom"
custom_dir.mkdir(parents=True, exist_ok=True)
items: List[dict] = []
# builtin: 优先读 index.json支持分类/标签/授权说明)
index_path = builtin_dir / "index.json"
if index_path.exists():
try:
import json
data = json.loads(index_path.read_text(encoding="utf-8"))
pack = data.get("pack") or {}
for cat in (data.get("categories") or []):
cat_name = cat.get("name") or cat.get("id") or "未分类"
for it in (cat.get("items") or []):
f = str(it.get("file") or "")
if not f:
continue
url = f"/static/assets/stickers_builtin/{f}"
items.append({
"id": str(it.get("id") or f),
"name": str(it.get("name") or it.get("id") or f),
"url": url,
"kind": "builtin",
"tags": it.get("tags") or [],
"category": cat_name,
"license": pack.get("license"),
"attribution": pack.get("attribution"),
})
except Exception as e:
logger.warning(f"Failed to read stickers_builtin/index.json: {e}")
# custom: 扫描目录
if custom_dir.exists():
for f in custom_dir.iterdir():
if f.suffix.lower() not in [".png", ".svg", ".webp"]:
continue
items.append({
"id": f"custom-{f.name}",
"name": f.stem,
"url": f"/static/assets/stickers_custom/{f.name}",
"kind": "custom",
"tags": [],
"category": "自定义",
"license": None,
"attribution": None,
})
return items
@router.post("/stickers/upload")
async def upload_sticker(file: UploadFile = File(...)):
"""上传贴纸png/svg/webp到 assets/stickers_custom。"""
ext = Path(file.filename).suffix.lower()
if ext not in [".png", ".svg", ".webp"]:
raise HTTPException(status_code=400, detail=f"不支持的贴纸类型: {ext}")
target_dir = config.ASSETS_DIR / "stickers_custom"
target_dir.mkdir(parents=True, exist_ok=True)
safe_name = Path(file.filename).name
target = target_dir / safe_name
if target.exists():
ts = int(time.time())
target = target_dir / f"{Path(safe_name).stem}_{ts}{ext}"
content = await file.read()
with open(target, "wb") as f:
f.write(content)
return {
"success": True,
"id": f"custom-{target.name}",
"name": target.stem,
"url": f"/static/assets/stickers_custom/{target.name}",
}
@router.post("/fonts/upload")
async def upload_font(file: UploadFile = File(...)):
"""上传字体ttf/otf/ttc保存到 assets/fonts供字幕/花字使用。"""
ext = Path(file.filename).suffix.lower()
if ext not in [".ttf", ".otf", ".ttc"]:
raise HTTPException(status_code=400, detail=f"不支持的字体类型: {ext}")
config.FONTS_DIR.mkdir(parents=True, exist_ok=True)
safe_name = Path(file.filename).name
target = config.FONTS_DIR / safe_name
# 若同名存在,则加时间戳避免覆盖
if target.exists():
ts = int(time.time())
target = config.FONTS_DIR / f"{Path(safe_name).stem}_{ts}{ext}"
try:
content = await file.read()
if not content or len(content) < 1024:
raise HTTPException(status_code=400, detail="字体文件为空或损坏")
with open(target, "wb") as f:
f.write(content)
return {
"success": True,
"id": target.name,
"name": target.stem,
"path": str(target),
"url": f"/static/fonts/{target.name}",
"css_family": target.name,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"字体上传失败: {e}")
raise HTTPException(status_code=500, detail=str(e))

837
api/routes/compose.py Normal file
View File

@@ -0,0 +1,837 @@
"""
视频合成 API 路由
基于编辑器状态进行最终合成
支持同步/异步两种模式,异步模式通过 Celery 任务队列处理
"""
import os
import time
import logging
import subprocess
from typing import List, Optional, Dict, Any
from pathlib import Path
from fastapi import APIRouter, HTTPException, BackgroundTasks
from pydantic import BaseModel
import config
from modules.db_manager import db
from modules.composer import VideoComposer
from modules import ffmpeg_utils, factory
from modules.text_renderer import renderer
# Celery 任务导入 (可选,默认关闭;需要显式开启 USE_CELERY=1)
CELERY_AVAILABLE = False
if str(os.getenv("USE_CELERY", "")).lower() in ("1", "true", "yes", "on"):
try:
from api.tasks.video_tasks import compose_from_script_task, compose_from_tracks_task
CELERY_AVAILABLE = True
except ImportError:
CELERY_AVAILABLE = False
logger = logging.getLogger(__name__)
router = APIRouter()
# ============================================================
# Pydantic Models
# ============================================================
class ComposeRequest(BaseModel):
"""合成请求"""
project_id: str
# 轨道数据(从编辑器状态转换)
video_clips: List[Dict[str, Any]] = []
voiceover_clips: List[Dict[str, Any]] = []
subtitle_clips: List[Dict[str, Any]] = []
fancy_text_clips: List[Dict[str, Any]] = []
sticker_clips: List[Dict[str, Any]] = []
bgm_clip: Optional[Dict[str, Any]] = None
# 全局设置
voice_type: str = "zh_female_santongyongns_saturn_bigtts"
bgm_volume: float = 0.15
output_name: Optional[str] = None
def _validate_no_overlap(clips: List[Dict[str, Any]], *, min_gap: float = 0.0) -> None:
"""Validate clips are non-overlapping when sorted by start time."""
ordered = sorted(clips, key=lambda x: float(x.get("start") or 0))
prev_end = 0.0
for c in ordered:
start = float(c.get("start") or 0.0)
dur = float(c.get("duration") or 0.0)
end = start + dur
if start + 1e-6 < prev_end - min_gap:
raise ValueError("视频片段存在重叠,请先在时间轴调整避免同轨道覆盖。")
prev_end = max(prev_end, end)
def _validate_trim_bounds(video_clips: List[Dict[str, Any]]) -> None:
"""
Validate trim bounds are sane and, if source_duration is provided, do not exceed it.
"""
for c in video_clips:
trim_start = float(c.get("trim_start") or 0.0)
trim_end = c.get("trim_end")
duration = float(c.get("duration") or 0.0)
if trim_end is None:
trim_end = trim_start + duration
trim_end = float(trim_end)
if trim_end <= trim_start + 1e-6:
raise ValueError("视频片段裁剪区间无效trim_end 必须大于 trim_start。")
src_dur = c.get("source_duration")
if src_dur is not None:
try:
src_dur_f = float(src_dur)
if src_dur_f > 0 and trim_end > src_dur_f + 1e-3:
raise ValueError("视频片段裁剪越界trim_end 超过源视频时长,请先缩短片段或调整 trim。")
except ValueError:
raise
except Exception:
# ignore parsing errors
pass
class ComposeResponse(BaseModel):
success: bool
message: str
output_path: Optional[str] = None
output_url: Optional[str] = None
task_id: Optional[str] = None
class ComposeStatus(BaseModel):
status: str # queued, pending, processing, completed, failed, cancelled
progress: float = 0
message: str = ""
output_path: Optional[str] = None
output_url: Optional[str] = None
def _persist_job(task_id: str, project_id: str, *, status: str, progress: float, message: str, request: Optional[Dict[str, Any]] = None):
"""Persist status to DB (render_jobs)."""
try:
existing = db.get_render_job(task_id)
if not existing:
db.create_render_job(task_id, project_id, status=status, progress=progress, message=message, request=request or {})
else:
db.update_render_job(task_id, {"status": status, "progress": progress, "message": message})
except Exception as e:
logger.debug(f"persist_job failed (non-fatal): {e}")
# ============================================================
# API Endpoints
# ============================================================
@router.post("/render", response_model=ComposeResponse)
async def render_video(request: ComposeRequest, background_tasks: BackgroundTasks):
"""
异步合成视频
优先使用 Celery 任务队列(支持水平扩展)
如果 Celery 不可用,降级为 FastAPI BackgroundTasks
"""
project = db.get_project(request.project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 优先使用 Celery 任务队列
if CELERY_AVAILABLE:
try:
task = compose_from_tracks_task.delay(
project_id=request.project_id,
video_clips=request.video_clips,
voiceover_clips=request.voiceover_clips,
subtitle_clips=request.subtitle_clips,
fancy_text_clips=request.fancy_text_clips,
bgm_clip=request.bgm_clip,
voice_type=request.voice_type,
bgm_volume=request.bgm_volume,
output_name=request.output_name
)
# 持久化任务记录(便于回溯/重试)
_persist_job(
task.id,
request.project_id,
status="queued",
progress=0.0,
message="合成任务已提交到队列",
request=request.model_dump(),
)
return ComposeResponse(
success=True,
message="合成任务已提交到队列",
task_id=task.id
)
except Exception as e:
logger.warning(f"Celery 任务提交失败,降级为同步模式: {e}")
# 降级:使用 FastAPI BackgroundTasks
task_id = f"compose_{request.project_id}_{int(time.time())}"
_persist_job(task_id, request.project_id, status="queued", progress=0.0, message="任务已创建(本地模式)", request=request.model_dump())
background_tasks.add_task(
_do_compose,
task_id,
request
)
return ComposeResponse(
success=True,
message="合成任务已提交",
task_id=task_id
)
@router.post("/render-sync", response_model=ComposeResponse)
async def render_video_sync(request: ComposeRequest):
"""
同步合成视频(适合短视频)
直接返回结果
"""
project = db.get_project(request.project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
try:
# 轻量导出前校验MVP禁止同轨道视频重叠
if request.video_clips:
_validate_no_overlap(request.video_clips)
_validate_trim_bounds(request.video_clips)
output_path = await _compose_video(None, request)
return ComposeResponse(
success=True,
message="合成完成",
output_path=output_path,
output_url=f"/static/output/{Path(output_path).name}"
)
except Exception as e:
logger.error(f"合成失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/status/{task_id}")
async def get_compose_status(task_id: str):
"""
获取合成任务状态
支持 Celery 任务和本地 BackgroundTask 两种模式
"""
# 先检查是否是 Celery 任务
if CELERY_AVAILABLE:
try:
from celery.result import AsyncResult
from api.celery_app import celery_app
result = AsyncResult(task_id, app=celery_app)
if result.state == "PENDING":
_persist_job(task_id, (db.get_render_job(task_id) or {}).get("project_id") or "", status="pending", progress=0.0, message="等待处理...")
return ComposeStatus(status="pending", progress=0, message="等待处理...")
elif result.state == "PROGRESS":
meta = result.info or {}
_persist_job(task_id, (db.get_render_job(task_id) or {}).get("project_id") or "", status="processing", progress=meta.get("progress", 0), message=meta.get("message", "处理中..."))
return ComposeStatus(
status="processing",
progress=meta.get("progress", 0),
message=meta.get("message", "处理中...")
)
elif result.state == "SUCCESS":
data = result.result or {}
_persist_job(task_id, (db.get_render_job(task_id) or {}).get("project_id") or "", status="completed", progress=1.0, message="合成完成")
try:
db.update_render_job(task_id, {"output_path": data.get("output_path"), "output_url": data.get("output_url")})
except Exception:
pass
return ComposeStatus(
status="completed",
progress=1.0,
message="合成完成",
output_path=data.get("output_path"),
output_url=data.get("output_url")
)
elif result.state == "FAILURE":
_persist_job(task_id, (db.get_render_job(task_id) or {}).get("project_id") or "", status="failed", progress=0.0, message=str(result.info) if result.info else "任务失败")
try:
db.update_render_job(task_id, {"error": str(result.info) if result.info else "任务失败"})
except Exception:
pass
return ComposeStatus(
status="failed",
progress=0,
message=str(result.info) if result.info else "任务失败"
)
else:
return ComposeStatus(status=result.state.lower(), progress=0, message=result.state)
except Exception as e:
logger.debug(f"Celery 状态查询失败: {e}")
# 本地任务/持久化任务:从 DB 取
job = db.get_render_job(task_id)
if job:
return ComposeStatus(
status=job.get("status") or "pending",
progress=float(job.get("progress") or 0.0),
message=job.get("message") or "",
output_path=job.get("output_path"),
output_url=job.get("output_url"),
)
raise HTTPException(status_code=404, detail="任务不存在")
@router.post("/retry/{task_id}", response_model=ComposeResponse)
async def retry_compose(task_id: str, background_tasks: BackgroundTasks):
"""失败任务一键重试:复用原 request 创建新任务。"""
job = db.get_render_job(task_id)
if not job or not job.get("request"):
raise HTTPException(status_code=404, detail="任务不存在或无可重试的请求数据")
if (job.get("status") or "").lower() not in ("failed", "cancelled"):
raise HTTPException(status_code=400, detail="仅失败/取消的任务可重试")
req = job["request"]
new_id = f"compose_{job.get('project_id')}_{int(time.time())}"
db.create_render_job(new_id, job.get("project_id") or "", status="queued", progress=0.0, message="重试任务已创建", request=req, parent_id=task_id)
# 走 BackgroundTasks不阻塞请求
background_tasks.add_task(_do_compose, new_id, ComposeRequest(**req))
return ComposeResponse(success=True, message="重试任务已提交", task_id=new_id)
@router.post("/quick", response_model=ComposeResponse)
async def quick_compose(project_id: str, bgm_id: Optional[str] = None, async_mode: bool = False):
"""
快速合成(使用项目默认设置)
适合工作流一键合成
Args:
project_id: 项目 ID
bgm_id: BGM 文件名
async_mode: 是否使用异步模式(推荐大量并发时使用)
"""
project = db.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
script_data = project.get("script_data")
if not script_data:
raise HTTPException(status_code=400, detail="项目缺少脚本数据")
# 获取视频素材
assets = db.get_assets(project_id, "video")
video_map = {a["scene_id"]: a["local_path"] for a in assets if a["status"] == "completed"}
if not video_map:
raise HTTPException(status_code=400, detail="项目缺少视频素材")
# BGM 路径
bgm_path = None
if bgm_id:
bgm_path = str(config.ASSETS_DIR / "bgm" / bgm_id)
if not os.path.exists(bgm_path):
bgm_path = None
# 异步模式:提交到 Celery 队列
if async_mode and CELERY_AVAILABLE:
try:
task = compose_from_script_task.delay(
project_id=project_id,
script_data=script_data,
video_map=video_map,
bgm_path=bgm_path,
voice_type=config.VOLC_TTS_DEFAULT_VOICE
)
return ComposeResponse(
success=True,
message="合成任务已提交到队列",
task_id=task.id
)
except Exception as e:
logger.warning(f"Celery 任务提交失败,降级为同步模式: {e}")
# 同步模式
try:
composer = VideoComposer(voice_type=config.VOLC_TTS_DEFAULT_VOICE)
output_name = f"final_{project_id}_{int(time.time())}"
output_path = composer.compose_from_script(
script=script_data,
video_map=video_map,
bgm_path=bgm_path,
output_name=output_name
)
# 保存到数据库
db.save_asset(project_id, 0, "final_video", "completed", local_path=output_path)
db.update_project_status(project_id, "completed")
return ComposeResponse(
success=True,
message="合成完成",
output_path=output_path,
output_url=f"/static/output/{Path(output_path).name}"
)
except Exception as e:
logger.error(f"快速合成失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# Internal Functions
# ============================================================
async def _do_compose(task_id: str, request: ComposeRequest):
"""后台合成任务"""
try:
db.update_render_job(task_id, {"status": "processing", "progress": 0.05, "message": "正在准备素材..."})
output_path = await _compose_video(task_id, request)
db.update_render_job(task_id, {
"status": "completed",
"progress": 1.0,
"message": "合成完成",
"output_path": output_path,
"output_url": f"/static/output/{Path(output_path).name}",
})
# 保存到数据库
db.save_asset(request.project_id, 0, "final_video", "completed", local_path=output_path)
db.update_project_status(request.project_id, "completed")
except Exception as e:
logger.error(f"合成任务失败: {e}")
db.update_render_job(task_id, {"status": "failed", "progress": 0.0, "message": str(e), "error": str(e)})
async def _compose_video(task_id: Optional[str], request: ComposeRequest) -> str:
"""
执行视频合成
基于编辑器轨道数据
"""
timestamp = int(time.time())
output_name = request.output_name or f"composed_{request.project_id}_{timestamp}"
if task_id:
db.update_render_job(task_id, {"status": "processing", "progress": 0.10, "message": "收集视频片段..."})
# 1. 收集视频片段(按时间排序)
video_clips = sorted(request.video_clips, key=lambda x: x.get("start", 0))
if not video_clips:
raise ValueError("没有视频片段")
video_paths = []
video_fades = []
for clip in video_clips:
source_path = clip.get("source_path")
if source_path and os.path.exists(source_path):
# 如果有裁剪,先裁剪
trim_start = clip.get("trim_start", 0)
trim_end = clip.get("trim_end")
# 读取转场参数(存放在 style 里;必须 WYSIWYG预览=导出)
style = clip.get("style") or {}
vf_in = float(style.get("vFadeIn") or style.get("v_fade_in") or 0.0)
vf_out = float(style.get("vFadeOut") or style.get("v_fade_out") or 0.0)
# 转场库WYSIWYG仅使用 vTransitionType/vTransitionDur由 ffmpeg_utils 在拼接阶段实现
t_type = str(style.get("vTransitionType") or style.get("v_transition_type") or "")
try:
t_dur = float(style.get("vTransitionDur") or style.get("v_transition_dur") or 0.0)
except Exception:
t_dur = 0.0
if trim_start > 0 or trim_end:
# 需要裁剪
trimmed_path = str(config.TEMP_DIR / f"trim_{timestamp}_{len(video_paths)}.mp4")
duration = (trim_end or 999) - trim_start
cmd = [
ffmpeg_utils.FFMPEG_PATH, "-y",
"-ss", str(trim_start),
"-i", source_path,
"-t", str(duration),
"-c", "copy",
trimmed_path
]
ffmpeg_utils._run_ffmpeg(cmd)
video_paths.append(trimmed_path)
video_fades.append({"in": max(0.0, vf_in), "out": max(0.0, vf_out), "type": t_type, "dur": max(0.0, t_dur)})
else:
video_paths.append(source_path)
video_fades.append({"in": max(0.0, vf_in), "out": max(0.0, vf_out), "type": t_type, "dur": max(0.0, t_dur)})
# 2. 拼接视频(尽量保留原声;失败则回退无音频)
if task_id:
db.update_render_job(task_id, {"progress": 0.25, "message": "拼接视频..."})
merged_path = str(config.TEMP_DIR / f"{output_name}_merged.mp4")
ffmpeg_utils.concat_videos_with_audio(video_paths, merged_path, (1080, 1920), fades=video_fades)
current_video = merged_path
# 添加静音轨(仅当拼接结果没有音轨时)
if task_id:
db.update_render_job(task_id, {"progress": 0.30, "message": "添加静音轨..."})
has_audio = False
try:
r = subprocess.run(
[ffmpeg_utils.FFPROBE_PATH, "-v", "error", "-select_streams", "a", "-show_entries", "stream=index", "-of", "csv=p=0", current_video],
capture_output=True,
text=True,
)
has_audio = bool((r.stdout or "").strip())
except Exception:
has_audio = False
if not has_audio:
silent_path = str(config.TEMP_DIR / f"{output_name}_silent.mp4")
ffmpeg_utils.add_silence_audio(current_video, silent_path)
current_video = silent_path
# 获取总时长
info = ffmpeg_utils.get_video_info(current_video)
total_duration = float(info.get("duration", 10))
# 3. 生成并混入旁白
if request.voiceover_clips:
if task_id:
db.update_render_job(task_id, {"progress": 0.38, "message": "生成/混入旁白..."})
mixed_audio_path = str(config.TEMP_DIR / f"{output_name}_mixed_vo.mp3")
# 初始化静音底轨
ffmpeg_utils._run_ffmpeg([
ffmpeg_utils.FFMPEG_PATH, "-y",
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo",
"-t", str(total_duration),
"-c:a", "mp3",
mixed_audio_path
])
for i, clip in enumerate(request.voiceover_clips):
start_time = float(clip.get("start", 0) or 0.0)
target_duration = float(clip.get("duration", 3) or 3.0)
vol = float(clip.get("volume", 1.0) or 1.0)
fade_in = float(clip.get("fade_in", 0.05) or 0.0)
fade_out = float(clip.get("fade_out", 0.05) or 0.0)
playback_rate = clip.get("playback_rate", clip.get("playbackRate", None))
try:
playback_rate = float(playback_rate) if playback_rate is not None else None
except Exception:
playback_rate = None
# 优先使用已生成的旁白音频(编辑器预览生成)
existing_path = clip.get("source_path")
voice_path = None
if existing_path and os.path.exists(existing_path):
voice_path = existing_path
else:
text = (clip.get("text") or "").strip()
if not text:
continue
voice_path = factory.generate_voiceover_volcengine(
text=text,
voice_type=request.voice_type,
output_path=str(config.TEMP_DIR / f"{output_name}_vo_{i}.mp3")
)
if not voice_path:
continue
# 旁白:纯播放倍速贴合时长(可快可慢,预览/导出一致)
adjusted_path = str(config.TEMP_DIR / f"{output_name}_vo_adj_{i}.mp3")
if playback_rate and playback_rate > 0:
# 先按用户倍速改变语速,再严格裁剪/补齐到片段时长(不再二次计算倍速)
sped = str(config.TEMP_DIR / f"{output_name}_vo_sp_{i}.mp3")
ffmpeg_utils.change_audio_speed(voice_path, playback_rate, sped)
ffmpeg_utils.force_audio_duration(sped, target_duration, adjusted_path)
else:
ffmpeg_utils.fit_audio_to_duration_by_speed(voice_path, target_duration, adjusted_path)
# 音量/淡入淡出(与预览一致)
processed_path = adjusted_path
try:
if vol != 1.0 or fade_in > 0 or fade_out > 0:
processed_path = str(config.TEMP_DIR / f"{output_name}_vo_fx_{i}.mp3")
fo_st = max(target_duration - fade_out, 0.0)
af = (
f"afade=t=in:st=0:d={fade_in},"
f"afade=t=out:st={fo_st}:d={fade_out},"
f"volume={vol}"
)
ffmpeg_utils._run_ffmpeg([
ffmpeg_utils.FFMPEG_PATH, "-y",
"-i", adjusted_path,
"-filter:a", af,
processed_path
])
except Exception:
processed_path = adjusted_path
# 混合
new_mixed = str(config.TEMP_DIR / f"{output_name}_mixed_{i}.mp3")
ffmpeg_utils.mix_audio_at_offset(mixed_audio_path, processed_path, start_time, new_mixed, overlay_volume=1.0)
mixed_audio_path = new_mixed
# 混入视频
if task_id:
db.update_render_job(task_id, {"progress": 0.55, "message": "混音合成..."})
voiced_path = str(config.TEMP_DIR / f"{output_name}_voiced.mp4")
ffmpeg_utils.mix_audio(
current_video, mixed_audio_path, voiced_path,
audio_volume=1.5,
video_volume=0.2
)
current_video = voiced_path
# 4. 添加字幕(使用文本渲染器生成 PNG 叠加,支持字体/颜色/B/I/U
if request.subtitle_clips:
if task_id:
db.update_render_job(task_id, {"progress": 0.65, "message": "渲染字幕..."})
overlay_configs = []
for clip in request.subtitle_clips:
text = (clip.get("text") or "").strip()
if not text:
continue
style = clip.get("style") or {}
# WYSIWYG自动换行交给 TextRenderer按 max_width 控制)
# 将前端 style 映射到 renderer style
r_style = {
"font_family": style.get("font_family") or style.get("fontFamily") or None,
"font_size": int(style.get("font_size") or style.get("fontSize") or 60),
"font_color": style.get("font_color") or style.get("fontColor") or "#FFFFFF",
"bold": bool(style.get("bold", False)),
"italic": bool(style.get("italic", False)),
"underline": bool(style.get("underline", False)),
# 默认给字幕一点描边,提升可读性
"stroke": style.get("stroke") or {"color": "#000000", "width": int(style.get("stroke_width") or 5)},
}
# 文本框宽度:前端用 0~1相对画布宽度控制换行导出映射到像素1080
try:
box_w = style.get("box_w") or style.get("boxW") or style.get("max_width") or style.get("maxWidth")
if isinstance(box_w, (int, float)):
if 0 < float(box_w) <= 1:
r_style["max_width"] = int(1080 * float(box_w))
elif float(box_w) > 1:
r_style["max_width"] = int(float(box_w))
except Exception:
pass
img_path = renderer.render(text, r_style, cache=False)
def _pos(v, axis: str):
# WYSIWYG前端 position 允许 0~1相对画面或 ffmpeg 表达式字符串
if isinstance(v, (int, float)):
fv = float(v)
if 0 <= fv <= 1:
return f"({axis}-w)*{fv}" if axis == "W" else f"({axis}-h)*{fv}"
return str(fv)
if isinstance(v, str) and v.strip():
return v
return None
position = clip.get("position") or {}
overlay_configs.append({
"path": img_path,
"x": _pos(position.get("x"), "W") or "(W-w)/2",
"y": _pos(position.get("y"), "H") or "H*0.78",
"start": clip.get("start", 0),
"duration": clip.get("duration", 3)
})
if overlay_configs:
subtitled_path = str(config.TEMP_DIR / f"{output_name}_subtitled.mp4")
ffmpeg_utils.overlay_multiple_images(current_video, overlay_configs, subtitled_path)
current_video = subtitled_path
# 5. 叠加花字
if request.fancy_text_clips:
if task_id:
db.update_render_job(task_id, {"progress": 0.78, "message": "叠加花字..."})
overlay_configs = []
for clip in request.fancy_text_clips:
text = clip.get("text", "")
if not text:
continue
style = clip.get("style", {
"font_size": 72,
"font_color": "#FFFFFF",
"stroke": {"color": "#000000", "width": 5}
})
# 文本框宽度0~1相对画布宽度-> 像素1080
try:
box_w = style.get("box_w") or style.get("boxW") or style.get("max_width") or style.get("maxWidth")
if isinstance(box_w, (int, float)):
if 0 < float(box_w) <= 1:
style["max_width"] = int(1080 * float(box_w))
elif float(box_w) > 1:
style["max_width"] = int(float(box_w))
except Exception:
pass
img_path = renderer.render(text, style, cache=False)
def _pos(v, axis: str):
if isinstance(v, (int, float)):
fv = float(v)
if 0 <= fv <= 1:
return f"({axis}-w)*{fv}" if axis == "W" else f"({axis}-h)*{fv}"
return str(fv)
if isinstance(v, str) and v.strip():
return v
return None
position = clip.get("position", {})
overlay_configs.append({
"path": img_path,
"x": _pos(position.get("x"), "W") or "(W-w)/2",
"y": _pos(position.get("y"), "H") or "180",
"start": clip.get("start", 0),
"duration": clip.get("duration", 5)
})
if overlay_configs:
fancy_path = str(config.TEMP_DIR / f"{output_name}_fancy.mp4")
ffmpeg_utils.overlay_multiple_images(current_video, overlay_configs, fancy_path)
current_video = fancy_path
# 5.5 叠加贴纸PNG/SVG
if request.sticker_clips:
if task_id:
db.update_render_job(task_id, {"progress": 0.82, "message": "叠加贴纸..."})
overlay_configs = []
for i, clip in enumerate(request.sticker_clips):
src_url = clip.get("source_url") or clip.get("sourceUrl")
src_path = clip.get("source_path") or clip.get("sourcePath")
# 贴纸可能来自 /static/assets/...,导出侧优先用 source_path其次尝试解析 url 到本地路径
path = src_path
if not path and isinstance(src_url, str) and src_url.startswith("/static/"):
# /static/assets/... 映射到容器内 /app/assets/...
try:
rel = src_url.replace("/static/assets/", "")
path = str(config.ASSETS_DIR / rel)
except Exception:
path = None
if not path or not os.path.exists(path):
continue
# 规范化为 PNG
png_path = str(config.TEMP_DIR / f"{output_name}_st_{i}.png")
try:
norm = ffmpeg_utils.normalize_sticker_to_png(path, png_path)
except Exception:
norm = None
if not norm or not os.path.exists(norm):
continue
pos = clip.get("position") or {}
x = pos.get("x", 0.8)
y = pos.get("y", 0.2)
# x/y: 0~1 视为比例
def _pos(v, axis: str):
if isinstance(v, (int, float)):
fv = float(v)
if 0 <= fv <= 1:
return f"({axis}-w)*{fv}" if axis == "W" else f"({axis}-h)*{fv}"
return str(fv)
if isinstance(v, str) and v.strip():
return v
return None
st = clip.get("style") or {}
scale = 1.0
try:
scale = float(st.get("scale", 1.0) or 1.0)
except Exception:
scale = 1.0
scale = max(0.3, min(3.0, scale))
# 通过 overlay 的 scale先 scale 贴纸,再 overlay
# overlay_multiple_images 不支持单张单独 scale filter所以这里把 PNG 预缩放成新文件
scaled_path = str(config.TEMP_DIR / f"{output_name}_st_sc_{i}.png")
try:
if abs(scale - 1.0) > 1e-3:
ffmpeg_utils._run_ffmpeg([
ffmpeg_utils.FFMPEG_PATH, "-y",
"-i", norm,
"-vf", f"scale=iw*{scale}:ih*{scale}:flags=lanczos",
scaled_path
])
norm = scaled_path
except Exception:
pass
overlay_configs.append({
"path": norm,
"x": _pos(x, "W") or "(W-w)*0.8",
"y": _pos(y, "H") or "(H-h)*0.2",
"start": clip.get("start", 0),
"duration": clip.get("duration", 2),
})
if overlay_configs:
st_path = str(config.TEMP_DIR / f"{output_name}_stickers.mp4")
ffmpeg_utils.overlay_multiple_images(current_video, overlay_configs, st_path)
current_video = st_path
# 6. 添加 BGM
if request.bgm_clip:
if task_id:
db.update_render_job(task_id, {"progress": 0.88, "message": "混入 BGM..."})
bgm_source = request.bgm_clip.get("source_path")
if bgm_source and os.path.exists(bgm_source):
bgm_output = str(config.TEMP_DIR / f"{output_name}_bgm.mp4")
# BGM 参数:优先使用 clip 内配置
bgm_start = float(request.bgm_clip.get("start", 0) or 0.0)
bgm_dur = request.bgm_clip.get("duration")
try:
bgm_dur = float(bgm_dur) if bgm_dur is not None else None
except Exception:
bgm_dur = None
bgm_vol = float(request.bgm_clip.get("volume", request.bgm_volume) or request.bgm_volume)
bgm_fade_in = float(request.bgm_clip.get("fade_in", 0.8) or 0.0)
bgm_fade_out = float(request.bgm_clip.get("fade_out", 0.8) or 0.0)
bgm_ducking = request.bgm_clip.get("ducking")
if not isinstance(bgm_ducking, bool):
bgm_ducking = True
bgm_duck_volume = float(request.bgm_clip.get("duck_volume", 0.25) or 0.25)
# 闪避区间:来自旁白时间轴(更可控)
duck_ranges = None
if request.voiceover_clips and bgm_ducking:
duck_ranges = []
for vc in request.voiceover_clips:
s = float(vc.get("start", 0) or 0.0)
d = float(vc.get("duration", 0) or 0.0)
if d <= 0:
continue
# 给一点点缓冲,避免边缘咬字突兀
duck_ranges.append((max(0.0, s - 0.03), s + d + 0.05))
ffmpeg_utils.add_bgm(
current_video, bgm_source, bgm_output,
bgm_volume=bgm_vol,
ducking=bool(bgm_ducking),
duck_volume=bgm_duck_volume,
duck_ranges=duck_ranges,
start_time=bgm_start,
clip_duration=bgm_dur,
fade_in=bgm_fade_in,
fade_out=bgm_fade_out,
)
current_video = bgm_output
# 7. 输出最终文件
if task_id:
db.update_render_job(task_id, {"progress": 0.95, "message": "写出最终文件..."})
final_path = str(config.OUTPUT_DIR / f"{output_name}.mp4")
import shutil
shutil.copy(current_video, final_path)
logger.info(f"合成完成: {final_path}")
return final_path

825
api/routes/editor.py Normal file
View File

@@ -0,0 +1,825 @@
"""
编辑器 API 路由
提供时间轴编辑、轨道管理功能
"""
import os
import time
import json
import logging
from typing import List, Optional, Dict, Any
from pathlib import Path
from functools import lru_cache
from fastapi import APIRouter, HTTPException, Body
from pydantic import BaseModel, Field
import config
from modules.db_manager import db
from modules import factory, ffmpeg_utils
from modules.text_renderer import renderer
from modules.legacy_path_mapper import map_legacy_local_path
from modules.preview_proxy import ensure_video_proxy
logger = logging.getLogger(__name__)
router = APIRouter()
def _safe_float(v: Any) -> Optional[float]:
try:
if v is None:
return None
return float(v)
except Exception:
return None
@lru_cache(maxsize=2048)
def _probe_source_duration_cache_key(path: str, mtime_ns: int, size: int) -> float:
"""
Cache wrapper to avoid repeated ffprobe for same file version.
Note: caller must pass (path, stat.mtime_ns, stat.size).
"""
info = ffmpeg_utils.get_video_info(path)
return float(info.get("duration") or 0.0)
def _get_source_duration_seconds(path: Optional[str]) -> Optional[float]:
if not path:
return None
if not os.path.exists(path):
return None
try:
st = os.stat(path)
dur = _probe_source_duration_cache_key(path, st.st_mtime_ns, st.st_size)
return dur if dur and dur > 0 else None
except Exception:
return None
# ============================================================
# Pydantic Models - 编辑器数据结构
# ============================================================
class TimelineClip(BaseModel):
"""时间轴片段"""
id: str
type: str # video, audio, subtitle, fancy_text, bgm
start: float # 开始时间(秒)
duration: float # 持续时间(秒)
source_path: Optional[str] = None # 源文件路径
source_url: Optional[str] = None # 源文件 URL
# 视频特有
trim_start: float = 0 # 裁剪起点
trim_end: Optional[float] = None # 裁剪终点
source_duration: Optional[float] = None # 源素材时长(秒)
# 文本特有
text: Optional[str] = None
style: Optional[Dict[str, Any]] = None
position: Optional[Dict[str, Any]] = None # {x, y}
# 音频特有
volume: float = 1.0
fade_in: Optional[float] = None
fade_out: Optional[float] = None
ducking: Optional[bool] = None
duck_volume: Optional[float] = None
playback_rate: Optional[float] = None
class Track(BaseModel):
"""轨道"""
id: str
name: str
type: str # video, audio, subtitle, fancy_text, bgm, sticker
clips: List[TimelineClip] = []
locked: bool = False
visible: bool = True
muted: bool = False
class EditorState(BaseModel):
"""编辑器状态"""
project_id: str
total_duration: float = 0
tracks: List[Track] = []
current_time: float = 0
zoom: float = 1.0
ripple_mode: bool = True
subtitle_style: Optional[Dict[str, Any]] = None
class VoiceoverRequest(BaseModel):
"""旁白生成请求"""
text: str
voice_type: str = "zh_female_santongyongns_saturn_bigtts"
target_duration: Optional[float] = None
class FancyTextRequest(BaseModel):
"""花字生成请求"""
text: str
style: Dict[str, Any] = Field(default_factory=lambda: {
"font_size": 72,
"font_color": "#FFFFFF",
"stroke": {"color": "#000000", "width": 5}
})
class TrimRequest(BaseModel):
"""视频裁剪请求"""
source_path: str
start_time: float
end_time: float
# ============================================================
# API Endpoints
# ============================================================
@router.get("/{project_id}/state", response_model=EditorState)
async def get_editor_state(project_id: str, use_proxy: bool = True):
"""
获取编辑器状态
从项目数据和素材自动构建时间轴
"""
project = db.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 获取素材
assets = db.get_assets(project_id)
script_data = project.get("script_data", {})
product_info = project.get("product_info", {}) or {}
# 如果已保存过 editor_state优先回放非破坏式轨道时间轴优先
saved_state = product_info.get("editor_state")
if isinstance(saved_state, dict) and saved_state.get("tracks"):
try:
state_obj = EditorState.model_validate(saved_state) # pydantic v2
# 回放兜底补全source_duration 可能是旧版本保存为 null
for t in state_obj.tracks:
for c in t.clips:
# 统一用 source_duration 表示“源素材时长”(视频/音频都适用)
if c.source_duration is None or (isinstance(c.source_duration, (int, float)) and c.source_duration <= 0):
if c.source_path:
try:
if t.type in ("audio", "bgm") or c.type in ("audio", "bgm"):
c.source_duration = float(ffmpeg_utils.get_audio_info(c.source_path).get("duration") or 0) or None
else:
c.source_duration = _get_source_duration_seconds(c.source_path)
except Exception:
c.source_duration = _get_source_duration_seconds(c.source_path)
# normalize trim_end if missing仅视频有意义但这里兜底不会出错
if c.trim_end is None:
c.trim_end = (c.trim_start or 0) + (c.duration or 0)
# 回放兜底补全:如果保存态里“旁白/字幕/花字”为空,但 script_data 里有内容,则自动回填
voiceover_timeline = (script_data or {}).get("voiceover_timeline") or []
scenes = (script_data or {}).get("scenes") or []
def _ensure_track(tid: str, ttype: str, name: str) -> Track:
existing = next((t for t in state_obj.tracks if t.id == tid), None)
if existing:
return existing
t = Track(id=tid, name=name, type=ttype, clips=[])
state_obj.tracks.append(t)
return t
# ensure total_duration for ratio-derived items
total_duration = float(state_obj.total_duration or 0.0)
# voiceover
vo_t = _ensure_track("audio-voiceover", "audio", "旁白")
if (not vo_t.clips) and voiceover_timeline:
for i, item in enumerate(voiceover_timeline):
start_time = float(item.get("start_time", item.get("start_ratio", 0) * total_duration))
duration = float(item.get("duration", item.get("duration_ratio", 0.25) * total_duration))
vo_t.clips.append(TimelineClip(
id=f"vo-{i}",
type="audio",
start=start_time,
duration=duration,
text=item.get("text", ""),
volume=1.0
))
# subtitle
sub_t = _ensure_track("subtitle-main", "subtitle", "字幕")
if (not sub_t.clips) and voiceover_timeline:
for i, item in enumerate(voiceover_timeline):
start_time = float(item.get("start_time", item.get("start_ratio", 0) * total_duration))
duration = float(item.get("duration", item.get("duration_ratio", 0.25) * total_duration))
sub_t.clips.append(TimelineClip(
id=f"sub-{i}",
type="subtitle",
start=start_time,
duration=duration,
text=item.get("subtitle", item.get("text", "")),
style={"fontsize": 60, "fontcolor": "white"},
position={"x": "(w-text_w)/2", "y": "h-200"}
))
# fancy text
fancy_t = _ensure_track("fancy-text", "fancy_text", "花字")
if (not fancy_t.clips) and scenes:
# best effort: align with existing video duration if any
scene_start = 0.0
video_track = next((t for t in state_obj.tracks if t.type == "video"), None)
# build scene durations from video clips if possible
scene_durations = []
if video_track and video_track.clips:
scene_durations = [float(c.duration or 0.0) for c in video_track.clips]
for idx, scene in enumerate(scenes):
scene_duration = scene_durations[idx] if idx < len(scene_durations) and scene_durations[idx] > 0 else 5.0
ft = scene.get("fancy_text", {}) if isinstance(scene, dict) else {}
if isinstance(ft, dict) and ft.get("text"):
fancy_t.clips.append(TimelineClip(
id=f"fancy-{scene.get('id', idx)}",
type="fancy_text",
start=scene_start,
duration=scene_duration,
text=ft.get("text", ""),
style={
"font_size": 72,
"font_color": "#FFFFFF",
"stroke": {"color": "#000000", "width": 5}
},
position={"x": "(W-w)/2", "y": "180"}
))
scene_start += scene_duration
# stickers贴纸轨道默认存在便于拖拽添加
_ensure_track("sticker-main", "sticker", "贴纸")
# video如果保存态里没有视频轨或为空但 assets 里有视频,则回填,避免“从 video flow 进来却黑屏”)
video_assets = sorted(
[a for a in assets if a.get("asset_type") == "video" and a.get("status") == "completed"],
key=lambda x: x.get("scene_id", 0)
)
video_t = _ensure_track("video-main", "video", "视频")
if (not video_t.clips) and video_assets:
cur_t = 0.0
for asset in video_assets:
remote_url = asset.get("remote_url")
source_path, _ = map_legacy_local_path(asset.get("local_path"))
duration = 5.0
if source_path and os.path.exists(source_path):
try:
duration = float(ffmpeg_utils.get_video_info(source_path).get("duration", 5.0))
except Exception:
duration = 5.0
# 统一走 file proxy内部会处理 legacy 映射 / remote_url
url = f"/api/assets/file/{asset.get('id')}"
video_t.clips.append(TimelineClip(
id=f"video-{asset.get('scene_id')}",
type="video",
start=cur_t,
duration=duration,
source_path=source_path,
source_url=url,
trim_start=0,
trim_end=duration,
source_duration=duration
))
cur_t += duration
continue
if remote_url and isinstance(remote_url, str) and remote_url.strip():
meta = asset.get("metadata") or {}
duration = float(meta.get("duration") or meta.get("source_duration") or 5.0)
url = f"/api/assets/file/{asset.get('id')}"
video_t.clips.append(TimelineClip(
id=f"video-{asset.get('scene_id')}",
type="video",
start=cur_t,
duration=duration,
source_path=None,
source_url=url,
trim_start=0,
trim_end=duration,
source_duration=duration
))
cur_t += duration
# ------------------------------
# 时间轴对齐:如果视频片段是真实时长(例如 3s而字幕/花字按 5s 切段,
# 会出现“花字超出视频、拖拽时看不对齐”的体验。
# 这里按视频轨道重排花字/字幕/旁白的 start/duration并把 total_duration 收敛到 videoEnd。
# ------------------------------
def _clip_end(c: TimelineClip) -> float:
try:
return float(c.start or 0.0) + float(c.duration or 0.0)
except Exception:
return 0.0
scene_timeline = []
if video_t and video_t.clips:
for vc in sorted(video_t.clips, key=lambda c: float(c.start or 0.0)):
# prefer parse scene_id from id=video-{scene_id}
sid = None
if isinstance(vc.id, str) and vc.id.startswith("video-"):
try:
sid = int(vc.id.replace("video-", ""))
except Exception:
sid = None
scene_timeline.append({
"scene_id": sid,
"start": float(vc.start or 0.0),
"duration": max(0.01, float(vc.duration or 0.0)),
})
if scene_timeline:
# 1) 花字:按 fancy-{scene_id} 精确对齐
fancy_t = next((t for t in state_obj.tracks if t.type == "fancy_text"), None)
if fancy_t and fancy_t.clips:
by_id = {c.id: c for c in fancy_t.clips if isinstance(c.id, str)}
for seg in scene_timeline:
sid = seg["scene_id"]
if sid is None:
continue
cid = f"fancy-{sid}"
c = by_id.get(cid)
if not c:
continue
c.start = float(seg["start"])
c.duration = float(seg["duration"])
# 修复“表达式坐标导致拖拽不直观”:初始化为居中百分比坐标(后续拖拽会改成数值)
if isinstance(c.position, dict):
if not isinstance(c.position.get("x"), (int, float)):
c.position["x"] = 0.5
if not isinstance(c.position.get("y"), (int, float)):
c.position["y"] = 0.2
# 2) 字幕/旁白:如果片段数与场景数一致,则按索引对齐
subtitle_t = next((t for t in state_obj.tracks if t.type == "subtitle"), None)
voice_t = next((t for t in state_obj.tracks if t.id == "audio-voiceover"), None)
for tr in [subtitle_t, voice_t]:
if not tr or not tr.clips:
continue
if len(tr.clips) != len(scene_timeline):
continue
for i, seg in enumerate(scene_timeline):
tr.clips[i].start = float(seg["start"])
tr.clips[i].duration = float(seg["duration"])
# 3) total_duration收敛到视频结束时间
video_end = max(0.0, max((seg["start"] + seg["duration"]) for seg in scene_timeline))
if video_end > 0:
state_obj.total_duration = float(video_end)
# 4) 兜底裁剪:任何片段不允许超出 total_duration避免视频结束后黑屏但字幕/花字继续)
td = float(state_obj.total_duration or 0.0)
if td > 0:
for tr in state_obj.tracks:
kept = []
for c in (tr.clips or []):
if float(c.start or 0.0) >= td:
continue
end = _clip_end(c)
if end > td:
c.duration = max(0.01, td - float(c.start or 0.0))
kept.append(c)
tr.clips = kept
# ------------------------------
# BGM如果没有片段但脚本给了 bgm_style则默认塞一条可在前端再调整/替换)
# ------------------------------
bgm_t = next((t for t in state_obj.tracks if t.type == "bgm" or t.id == "audio-bgm"), None)
if bgm_t is None:
bgm_t = _ensure_track("audio-bgm", "bgm", "背景音乐")
if bgm_t and (not bgm_t.clips):
bgm_style = (script_data or {}).get("bgm_style") or ""
bgm_dir = config.ASSETS_DIR / "bgm"
chosen = None
if bgm_dir.exists():
files = [f for f in bgm_dir.iterdir() if f.is_file() and f.suffix.lower() in [".mp3", ".mp4", ".m4a", ".wav"]]
files.sort(key=lambda p: p.name)
if isinstance(bgm_style, str) and bgm_style.strip():
# very small heuristic: pick file that shares any keyword
kws = [k.strip() for k in bgm_style.replace("", " ").replace(",", " ").split() if len(k.strip()) >= 2]
for f in files:
name = f.stem
if any(k in name for k in kws):
chosen = f
break
if chosen is None and files:
chosen = files[0]
if chosen is not None:
td = float(state_obj.total_duration or 0.0)
if td <= 0:
# fallback: use max end across all tracks
td = max(0.0, max((_clip_end(c) for t in state_obj.tracks for c in (t.clips or [])), default=0.0))
if td > 0:
bgm_t.clips.append(TimelineClip(
id="bgm-0",
type="bgm",
start=0.0,
duration=float(td),
source_path=str(chosen),
source_url=f"/static/assets/bgm/{chosen.name}",
volume=0.25,
style={"loop": True},
))
return state_obj
except Exception:
# fall back to rebuild
pass
# 构建轨道
tracks = []
# 1. 视频轨道
video_track = Track(
id="video-main",
name="视频",
type="video",
clips=[]
)
current_time = 0
video_assets = sorted(
[a for a in assets if a["asset_type"] == "video" and a["status"] == "completed"],
key=lambda x: x["scene_id"]
)
for asset in video_assets:
remote_url = asset.get("remote_url")
source_path, mapped_url = map_legacy_local_path(asset.get("local_path"))
# 1) 本地存在:正常走本地(统一用 /api/assets/file 作为 source_url更稳
if source_path and os.path.exists(source_path):
try:
info = ffmpeg_utils.get_video_info(source_path)
duration = float(info.get("duration", 5.0))
except Exception:
duration = 5.0
url = f"/api/assets/file/{asset['id']}"
video_track.clips.append(TimelineClip(
id=f"video-{asset['scene_id']}",
type="video",
start=current_time,
duration=duration,
source_path=source_path,
source_url=url,
trim_start=0,
trim_end=duration,
source_duration=duration
))
current_time += duration
continue
# 2) 本地缺失但有 remote_url也要能预览至少不黑屏
if remote_url and isinstance(remote_url, str) and remote_url.strip():
meta = asset.get("metadata") or {}
duration = float(meta.get("duration") or meta.get("source_duration") or 5.0)
url = f"/api/assets/file/{asset['id']}" # 统一走 file proxy会 307 到 remote_url
video_track.clips.append(TimelineClip(
id=f"video-{asset['scene_id']}",
type="video",
start=current_time,
duration=duration,
source_path=None,
source_url=url,
trim_start=0,
trim_end=duration,
source_duration=duration
))
current_time += duration
tracks.append(video_track)
total_duration = current_time
# 2. 旁白/TTS 轨道
voiceover_track = Track(
id="audio-voiceover",
name="旁白",
type="audio",
clips=[]
)
voiceover_timeline = script_data.get("voiceover_timeline", [])
for i, item in enumerate(voiceover_timeline):
start_time = float(item.get("start_time", item.get("start_ratio", 0) * total_duration))
duration = float(item.get("duration", item.get("duration_ratio", 0.25) * total_duration))
voiceover_track.clips.append(TimelineClip(
id=f"vo-{i}",
type="audio",
start=start_time,
duration=duration,
text=item.get("text", ""),
volume=1.0
))
tracks.append(voiceover_track)
# 3. 字幕轨道
subtitle_track = Track(
id="subtitle-main",
name="字幕",
type="subtitle",
clips=[]
)
for i, item in enumerate(voiceover_timeline):
start_time = float(item.get("start_time", item.get("start_ratio", 0) * total_duration))
duration = float(item.get("duration", item.get("duration_ratio", 0.25) * total_duration))
subtitle_track.clips.append(TimelineClip(
id=f"sub-{i}",
type="subtitle",
start=start_time,
duration=duration,
text=item.get("subtitle", item.get("text", "")),
style={"fontsize": 60, "fontcolor": "white"},
position={"x": "(w-text_w)/2", "y": "h-200"}
))
tracks.append(subtitle_track)
# 4. 花字轨道
fancy_track = Track(
id="fancy-text",
name="花字",
type="fancy_text",
clips=[]
)
scenes = script_data.get("scenes", [])
scene_start = 0
for scene in scenes:
# 计算该场景的时长
scene_video = next(
(a for a in video_assets if a["scene_id"] == scene["id"]),
None
)
source_path, _ = map_legacy_local_path(scene_video.get("local_path") if scene_video else None)
if source_path and os.path.exists(source_path):
try:
info = ffmpeg_utils.get_video_info(source_path)
scene_duration = float(info.get("duration", 5.0))
except:
scene_duration = 5.0
else:
scene_duration = 5.0
ft = scene.get("fancy_text", {})
if isinstance(ft, dict) and ft.get("text"):
fancy_track.clips.append(TimelineClip(
id=f"fancy-{scene['id']}",
type="fancy_text",
start=scene_start,
duration=scene_duration,
text=ft.get("text", ""),
style={
"font_size": 72,
"font_color": "#FFFFFF",
"stroke": {"color": "#000000", "width": 5}
},
position={"x": "(W-w)/2", "y": "180"}
))
scene_start += scene_duration
tracks.append(fancy_track)
# 5. BGM 轨道
bgm_track = Track(
id="audio-bgm",
name="背景音乐",
type="bgm",
clips=[],
muted=False
)
# 默认 BGM如果脚本给了 bgm_style则塞一条便于一键出片用户可在前端替换/删除)
try:
bgm_style = (script_data or {}).get("bgm_style") or ""
bgm_dir = config.ASSETS_DIR / "bgm"
chosen = None
if bgm_dir.exists():
files = [f for f in bgm_dir.iterdir() if f.is_file() and f.suffix.lower() in [".mp3", ".mp4", ".m4a", ".wav"]]
files.sort(key=lambda p: p.name)
if isinstance(bgm_style, str) and bgm_style.strip():
kws = [k.strip() for k in bgm_style.replace("", " ").replace(",", " ").split() if len(k.strip()) >= 2]
for f in files:
name = f.stem
if any(k in name for k in kws):
chosen = f
break
if chosen is None and files:
chosen = files[0]
if chosen is not None and float(total_duration or 0.0) > 0:
bgm_track.clips.append(TimelineClip(
id="bgm-0",
type="bgm",
start=0.0,
duration=float(total_duration),
source_path=str(chosen),
source_url=f"/static/assets/bgm/{chosen.name}",
volume=0.25,
style={"loop": True},
))
except Exception:
pass
tracks.append(bgm_track)
# 6. 贴纸轨道
sticker_track = Track(
id="sticker-main",
name="贴纸",
type="sticker",
clips=[],
muted=False
)
tracks.append(sticker_track)
return EditorState(
project_id=project_id,
total_duration=total_duration,
tracks=tracks,
current_time=0,
zoom=1.0
)
@router.post("/{project_id}/state")
async def save_editor_state(project_id: str, state: EditorState):
"""保存编辑器状态到数据库"""
project = db.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 1) 持久化 editor_state用于 Cut/Trim/Split 回放,不改表结构)
product_info = project.get("product_info", {}) or {}
product_info["editor_state"] = state.model_dump()
db.update_project_product_info(project_id, product_info)
# 将编辑器状态转换回 script_data 格式
script_data = project.get("script_data", {})
# 更新 voiceover_timeline
voiceover_timeline = []
subtitle_clips = []
for track in state.tracks:
if track.type == "audio" and track.id == "audio-voiceover":
for clip in track.clips:
voiceover_timeline.append({
"text": clip.text or "",
"start_time": clip.start,
"duration": clip.duration
})
elif track.type == "subtitle":
for clip in track.clips:
subtitle_clips.append({
"text": clip.text or "",
"subtitle": clip.text or "",
"start_time": clip.start,
"duration": clip.duration
})
# 合并字幕到 voiceover_timeline
for i, vo in enumerate(voiceover_timeline):
if i < len(subtitle_clips):
vo["subtitle"] = subtitle_clips[i].get("text", vo.get("text", ""))
script_data["voiceover_timeline"] = voiceover_timeline
# 更新花字
for track in state.tracks:
if track.type == "fancy_text":
for clip in track.clips:
# 找到对应的 scene
scene_id_str = clip.id.replace("fancy-", "")
try:
scene_id = int(scene_id_str)
for scene in script_data.get("scenes", []):
if scene["id"] == scene_id:
if "fancy_text" not in scene:
scene["fancy_text"] = {}
scene["fancy_text"]["text"] = clip.text or ""
scene["fancy_text"]["start_time"] = clip.start
scene["fancy_text"]["duration"] = clip.duration
break
except ValueError:
pass
db.update_project_script(project_id, script_data)
return {"message": "编辑器状态已保存"}
@router.post("/generate-voiceover")
async def generate_voiceover(request: VoiceoverRequest):
"""
生成 TTS 音频
返回音频文件路径
"""
try:
output_path = str(config.TEMP_DIR / f"vo_{int(time.time())}.mp3")
audio_path = factory.generate_voiceover_volcengine(
text=request.text,
voice_type=request.voice_type,
output_path=output_path
)
if audio_path and os.path.exists(audio_path):
# 如果需要调整时长
if request.target_duration:
adjusted_path = str(config.TEMP_DIR / f"vo_adj_{int(time.time())}.mp3")
ffmpeg_utils.fit_audio_to_duration_by_speed(audio_path, request.target_duration, adjusted_path)
audio_path = adjusted_path
# 返回源时长(用于前端计算倍速)
try:
dur = float(ffmpeg_utils.get_audio_info(audio_path).get("duration") or 0.0)
except Exception:
dur = 0.0
return {
"success": True,
"path": audio_path,
"url": f"/static/temp/{Path(audio_path).name}",
"duration": dur if dur > 0 else None,
}
else:
raise HTTPException(status_code=500, detail="TTS 生成失败")
except Exception as e:
logger.error(f"TTS 生成错误: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/generate-fancy-text")
async def generate_fancy_text(request: FancyTextRequest):
"""
生成花字图片
返回图片路径
"""
try:
img_path = renderer.render(
text=request.text,
style=request.style,
cache=False
)
if img_path and os.path.exists(img_path):
return {
"success": True,
"path": img_path,
"url": f"/static/temp/{Path(img_path).name}"
}
else:
raise HTTPException(status_code=500, detail="花字生成失败")
except Exception as e:
logger.error(f"花字生成错误: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/trim-video")
async def trim_video(request: TrimRequest):
"""
裁剪视频片段
返回新视频路径
"""
if not os.path.exists(request.source_path):
raise HTTPException(status_code=404, detail="源视频不存在")
try:
output_path = str(config.TEMP_DIR / f"trimmed_{int(time.time())}.mp4")
# 使用 ffmpeg 裁剪
duration = request.end_time - request.start_time
cmd = [
ffmpeg_utils.FFMPEG_PATH, "-y",
"-ss", str(request.start_time),
"-i", request.source_path,
"-t", str(duration),
"-c", "copy",
output_path
]
ffmpeg_utils._run_ffmpeg(cmd)
return {
"success": True,
"path": output_path,
"url": f"/static/temp/{Path(output_path).name}",
"duration": duration
}
except Exception as e:
logger.error(f"视频裁剪错误: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{project_id}/clip/{clip_id}")
async def delete_clip(project_id: str, clip_id: str):
"""删除时间轴上的片段"""
# 这里主要是前端状态管理,后端只做记录
logger.info(f"删除片段: {project_id}/{clip_id}")
return {"message": "片段已删除", "clip_id": clip_id}

315
api/routes/projects.py Normal file
View File

@@ -0,0 +1,315 @@
"""
项目管理 API 路由
提供项目 CRUD 和状态查询
"""
import os
import time
import logging
from typing import List, Optional, Any, Dict
from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from pydantic import BaseModel
import config
from modules.db_manager import db
from modules.script_gen import ScriptGenerator
from modules.image_gen import ImageGenerator
from modules.video_gen import VideoGenerator
from modules.legacy_path_mapper import map_legacy_local_path
logger = logging.getLogger(__name__)
router = APIRouter()
# ============================================================
# Pydantic Models
# ============================================================
class ProductInfo(BaseModel):
category: str = ""
price: str = ""
tags: str = ""
params: str = ""
style_hint: str = ""
class ProjectCreateRequest(BaseModel):
name: str
product_info: ProductInfo
class ProjectResponse(BaseModel):
id: str
name: str
status: str
product_info: dict
script_data: Optional[dict] = None
created_at: float
updated_at: float
class ProjectListItem(BaseModel):
id: str
name: str
status: str
updated_at: float
class PromptDebugResponse(BaseModel):
"""
导出当次脚本生成实际使用的 prompt来自 projects.script_data._debug
注意:默认不返回 raw_output避免体积过大/泄露无关信息。
"""
project_id: str
provider: Optional[str] = None
system_prompt: Optional[str] = None
user_prompt: Optional[str] = None
image_urls: Optional[List[str]] = None
raw_output: Optional[str] = None
class SceneAssetResponse(BaseModel):
scene_id: int
asset_type: str
status: str
local_path: Optional[str] = None
url: Optional[str] = None
# ============================================================
# API Endpoints
# ============================================================
@router.get("", response_model=List[ProjectListItem])
async def list_projects():
"""获取所有项目列表"""
projects = db.list_projects()
return projects
@router.post("", response_model=dict)
async def create_project(request: ProjectCreateRequest):
"""创建新项目"""
project_id = f"PROJ-{int(time.time())}"
product_info_dict = request.product_info.model_dump()
db.create_project(project_id, request.name, product_info_dict)
return {
"id": project_id,
"message": "项目创建成功"
}
@router.get("/{project_id}", response_model=ProjectResponse)
async def get_project(project_id: str):
"""获取项目详情"""
project = db.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
return project
@router.get("/{project_id}/prompt-debug", response_model=PromptDebugResponse)
async def get_prompt_debug(project_id: str, include_raw_output: bool = False):
"""
获取该项目“生成脚本时实际使用”的 system_prompt / user_prompt。
- 数据来源projects.script_data._debug由 ScriptGenerator 在生成脚本时写入)
- include_raw_output=true 时返回 raw_output可能很大
"""
project = db.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
script_data: Dict[str, Any] = project.get("script_data") or {}
debug: Dict[str, Any] = script_data.get("_debug") or {}
resp = {
"project_id": project_id,
"provider": debug.get("provider"),
"system_prompt": debug.get("system_prompt"),
"user_prompt": debug.get("user_prompt"),
"image_urls": debug.get("image_urls"),
}
if include_raw_output:
resp["raw_output"] = debug.get("raw_output")
return resp
@router.get("/{project_id}/assets", response_model=List[dict])
async def get_project_assets(project_id: str):
"""获取项目所有素材"""
assets = db.get_assets(project_id)
# 添加可访问的 URL
for asset in assets:
source_path, mapped_url = map_legacy_local_path(asset.get("local_path"))
if source_path and os.path.exists(source_path):
# 转换为相对路径 URL
local_path = Path(source_path)
if mapped_url:
asset["url"] = mapped_url
asset["local_path"] = source_path
elif str(config.OUTPUT_DIR) in str(local_path):
asset["url"] = f"/static/output/{local_path.name}"
asset["local_path"] = source_path
elif str(config.TEMP_DIR) in str(local_path):
asset["url"] = f"/static/temp/{local_path.name}"
asset["local_path"] = source_path
return assets
@router.post("/{project_id}/upload-images")
async def upload_product_images(
project_id: str,
files: List[UploadFile] = File(...)
):
"""上传商品主图"""
project = db.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
saved_paths = []
for file in files:
# 保存到 temp 目录
file_path = config.TEMP_DIR / file.filename
with open(file_path, "wb") as f:
content = await file.read()
f.write(content)
saved_paths.append(str(file_path))
# 更新项目 product_info
product_info = project.get("product_info", {})
product_info["uploaded_images"] = saved_paths
# 注意:这里需要重新保存整个项目,简化处理
# 实际应该添加一个 update_product_info 方法
return {
"message": f"上传成功 {len(saved_paths)} 张图片",
"paths": saved_paths
}
@router.post("/{project_id}/generate-script")
async def generate_script(
project_id: str,
model_provider: str = "shubiaobiao"
):
"""生成脚本"""
project = db.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
product_info = project.get("product_info", {})
image_paths = product_info.get("uploaded_images", [])
gen = ScriptGenerator()
script = gen.generate_script(
project["name"],
product_info,
image_paths,
model_provider=model_provider
)
if script:
db.update_project_script(project_id, script)
return {
"message": "脚本生成成功",
"script": script
}
else:
raise HTTPException(status_code=500, detail="脚本生成失败")
@router.post("/{project_id}/generate-images")
async def generate_images(
project_id: str,
model_provider: str = "shubiaobiao"
):
"""生成分镜图片"""
project = db.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
script_data = project.get("script_data")
if not script_data:
raise HTTPException(status_code=400, detail="请先生成脚本")
product_info = project.get("product_info", {})
base_imgs = product_info.get("uploaded_images", [])
if not base_imgs:
raise HTTPException(status_code=400, detail="请先上传商品主图")
img_gen = ImageGenerator()
scenes = script_data.get("scenes", [])
visual_anchor = script_data.get("visual_anchor", "")
results = {}
current_refs = list(base_imgs)
for scene in scenes:
scene_id = scene["id"]
img_path = img_gen.generate_single_scene_image(
scene=scene,
original_image_path=current_refs,
previous_image_path=None,
model_provider=model_provider,
visual_anchor=visual_anchor
)
if img_path:
results[scene_id] = img_path
current_refs.append(img_path)
db.save_asset(project_id, scene_id, "image", "completed", local_path=img_path)
db.update_project_status(project_id, "images_generated")
return {
"message": f"生成成功 {len(results)} 张图片",
"images": results
}
@router.post("/{project_id}/generate-videos")
async def generate_videos(project_id: str):
"""生成分镜视频"""
project = db.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
script_data = project.get("script_data")
if not script_data:
raise HTTPException(status_code=400, detail="请先生成脚本")
# 获取已生成的图片
assets = db.get_assets(project_id, "image")
scene_images = {a["scene_id"]: a["local_path"] for a in assets if a["status"] == "completed"}
if not scene_images:
raise HTTPException(status_code=400, detail="请先生成分镜图片")
vid_gen = VideoGenerator()
videos = vid_gen.generate_scene_videos(
project_id,
script_data,
scene_images
)
if videos:
for sid, path in videos.items():
db.save_asset(project_id, sid, "video", "completed", local_path=path)
db.update_project_status(project_id, "videos_generated")
return {
"message": f"生成成功 {len(videos)} 个视频",
"videos": videos
}
else:
raise HTTPException(status_code=500, detail="视频生成失败")