chore: sync code and project files
This commit is contained in:
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
85
api/celery_app.py
Normal file
85
api/celery_app.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Celery 应用配置
|
||||
支持异步任务处理,可水平扩展 Worker
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 确保项目根目录在 path 中
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from celery import Celery
|
||||
|
||||
# Redis 配置 (可通过环境变量覆盖)
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
|
||||
# 创建 Celery 应用
|
||||
celery_app = Celery(
|
||||
"video_flow",
|
||||
broker=REDIS_URL,
|
||||
backend=REDIS_URL,
|
||||
include=["api.tasks.video_tasks", "api.tasks.audio_tasks"]
|
||||
)
|
||||
|
||||
# Celery 配置
|
||||
celery_app.conf.update(
|
||||
# 任务序列化
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
|
||||
# 时区
|
||||
timezone="Asia/Shanghai",
|
||||
enable_utc=True,
|
||||
|
||||
# 任务追踪
|
||||
task_track_started=True,
|
||||
task_time_limit=600, # 10分钟超时
|
||||
task_soft_time_limit=540, # 9分钟软超时
|
||||
|
||||
# 结果保存
|
||||
result_expires=3600, # 1小时后过期
|
||||
|
||||
# Worker 配置
|
||||
worker_prefetch_multiplier=1, # 每次只取一个任务,适合长任务
|
||||
worker_concurrency=2, # 每个 Worker 的并发数
|
||||
|
||||
# 任务路由 (可选,用于任务分类)
|
||||
task_routes={
|
||||
"api.tasks.video_tasks.*": {"queue": "video"},
|
||||
"api.tasks.audio_tasks.*": {"queue": "audio"},
|
||||
},
|
||||
|
||||
# 默认队列
|
||||
task_default_queue="default",
|
||||
)
|
||||
|
||||
# 健康检查任务
|
||||
@celery_app.task(bind=True)
|
||||
def health_check(self):
|
||||
"""Worker 健康检查"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"worker_id": self.request.id,
|
||||
"hostname": self.request.hostname
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
celery_app.start()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ app.add_middleware(
|
||||
app.mount("/static/output", StaticFiles(directory=str(config.OUTPUT_DIR)), name="output")
|
||||
app.mount("/static/temp", StaticFiles(directory=str(config.TEMP_DIR)), name="temp")
|
||||
app.mount("/static/assets", StaticFiles(directory=str(config.ASSETS_DIR)), name="assets")
|
||||
app.mount("/static/fonts", StaticFiles(directory=str(config.FONTS_DIR)), name="fonts")
|
||||
|
||||
# Legacy mounts(8502 runtime 产物,宿主机目录通过 docker-compose 挂载到容器内)
|
||||
try:
|
||||
|
||||
15
api/routes/__init__.py
Normal file
15
api/routes/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# API Routes Package
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
408
api/routes/assets.py
Normal file
408
api/routes/assets.py
Normal 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
837
api/routes/compose.py
Normal 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
825
api/routes/editor.py
Normal 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
315
api/routes/projects.py
Normal 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="视频生成失败")
|
||||
|
||||
|
||||
|
||||
15
api/tasks/__init__.py
Normal file
15
api/tasks/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# Celery Tasks Package
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
212
api/tasks/audio_tasks.py
Normal file
212
api/tasks/audio_tasks.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
音频处理 Celery 任务
|
||||
TTS 生成、花字渲染等
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
import config
|
||||
from modules import factory, ffmpeg_utils
|
||||
from modules.text_renderer import renderer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(bind=True, name="audio.generate_tts")
|
||||
def generate_tts_task(
|
||||
self,
|
||||
text: str,
|
||||
voice_type: str = "zh_female_santongyongns_saturn_bigtts",
|
||||
target_duration: Optional[float] = None,
|
||||
output_path: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
生成 TTS 音频(异步任务)
|
||||
|
||||
Args:
|
||||
text: 要转换的文本
|
||||
voice_type: TTS 音色
|
||||
target_duration: 目标时长(秒),如果指定会调整音频速度
|
||||
output_path: 输出路径
|
||||
|
||||
Returns:
|
||||
{"status": "success", "path": "...", "url": "..."}
|
||||
"""
|
||||
task_id = self.request.id
|
||||
logger.info(f"[Task {task_id}] 生成 TTS: {text[:30]}...")
|
||||
|
||||
if not output_path:
|
||||
timestamp = int(time.time())
|
||||
output_path = str(config.TEMP_DIR / f"tts_{timestamp}.mp3")
|
||||
|
||||
try:
|
||||
# 生成 TTS
|
||||
audio_path = factory.generate_voiceover_volcengine(
|
||||
text=text,
|
||||
voice_type=voice_type,
|
||||
output_path=output_path
|
||||
)
|
||||
|
||||
if not audio_path or not os.path.exists(audio_path):
|
||||
raise RuntimeError("TTS 生成失败")
|
||||
|
||||
# 如果需要调整时长
|
||||
if target_duration:
|
||||
adjusted_path = str(config.TEMP_DIR / f"tts_adj_{int(time.time())}.mp3")
|
||||
ffmpeg_utils.adjust_audio_duration(audio_path, target_duration, adjusted_path)
|
||||
|
||||
# 删除原始文件
|
||||
if audio_path != output_path:
|
||||
os.remove(audio_path)
|
||||
|
||||
audio_path = adjusted_path
|
||||
|
||||
output_url = f"/static/temp/{Path(audio_path).name}"
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"path": audio_path,
|
||||
"url": output_url,
|
||||
"task_id": task_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Task {task_id}] TTS 生成失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@shared_task(bind=True, name="audio.generate_fancy_text")
|
||||
def generate_fancy_text_task(
|
||||
self,
|
||||
text: str,
|
||||
style: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
生成花字图片(异步任务)
|
||||
|
||||
Args:
|
||||
text: 花字文本
|
||||
style: 样式配置
|
||||
|
||||
Returns:
|
||||
{"status": "success", "path": "...", "url": "..."}
|
||||
"""
|
||||
task_id = self.request.id
|
||||
logger.info(f"[Task {task_id}] 生成花字: {text}")
|
||||
|
||||
if not style:
|
||||
style = {
|
||||
"font_size": 72,
|
||||
"font_color": "#FFFFFF",
|
||||
"stroke": {"color": "#000000", "width": 5}
|
||||
}
|
||||
|
||||
try:
|
||||
img_path = renderer.render(
|
||||
text=text,
|
||||
style=style,
|
||||
cache=False
|
||||
)
|
||||
|
||||
if not img_path or not os.path.exists(img_path):
|
||||
raise RuntimeError("花字生成失败")
|
||||
|
||||
output_url = f"/static/temp/{Path(img_path).name}"
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"path": img_path,
|
||||
"url": output_url,
|
||||
"task_id": task_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Task {task_id}] 花字生成失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@shared_task(bind=True, name="audio.batch_generate_tts")
|
||||
def batch_generate_tts_task(
|
||||
self,
|
||||
items: list,
|
||||
voice_type: str = "zh_female_santongyongns_saturn_bigtts"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
批量生成 TTS 音频(异步任务)
|
||||
|
||||
Args:
|
||||
items: [{"text": "...", "target_duration": 3.0}, ...]
|
||||
voice_type: TTS 音色
|
||||
|
||||
Returns:
|
||||
{"status": "success", "results": [...]}
|
||||
"""
|
||||
task_id = self.request.id
|
||||
logger.info(f"[Task {task_id}] 批量生成 TTS: {len(items)} 条")
|
||||
|
||||
results = []
|
||||
timestamp = int(time.time())
|
||||
|
||||
for i, item in enumerate(items):
|
||||
text = item.get("text", "")
|
||||
target_duration = item.get("target_duration")
|
||||
|
||||
if not text:
|
||||
results.append({"index": i, "status": "skipped", "reason": "空文本"})
|
||||
continue
|
||||
|
||||
try:
|
||||
output_path = str(config.TEMP_DIR / f"tts_batch_{timestamp}_{i}.mp3")
|
||||
|
||||
audio_path = factory.generate_voiceover_volcengine(
|
||||
text=text,
|
||||
voice_type=voice_type,
|
||||
output_path=output_path
|
||||
)
|
||||
|
||||
if target_duration and audio_path:
|
||||
adjusted_path = str(config.TEMP_DIR / f"tts_batch_adj_{timestamp}_{i}.mp3")
|
||||
ffmpeg_utils.adjust_audio_duration(audio_path, target_duration, adjusted_path)
|
||||
audio_path = adjusted_path
|
||||
|
||||
if audio_path:
|
||||
results.append({
|
||||
"index": i,
|
||||
"status": "success",
|
||||
"path": audio_path,
|
||||
"url": f"/static/temp/{Path(audio_path).name}"
|
||||
})
|
||||
else:
|
||||
results.append({"index": i, "status": "failed", "reason": "生成失败"})
|
||||
|
||||
except Exception as e:
|
||||
results.append({"index": i, "status": "failed", "reason": str(e)})
|
||||
|
||||
# 更新进度
|
||||
progress = (i + 1) / len(items)
|
||||
self.update_state(state="PROGRESS", meta={"progress": progress, "completed": i + 1, "total": len(items)})
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"results": results,
|
||||
"task_id": task_id
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
418
api/tasks/video_tasks.py
Normal file
418
api/tasks/video_tasks.py
Normal file
@@ -0,0 +1,418 @@
|
||||
"""
|
||||
视频处理 Celery 任务
|
||||
封装现有的 FFmpeg 处理逻辑为异步任务
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(bind=True, name="video.compose_from_script")
|
||||
def compose_from_script_task(
|
||||
self,
|
||||
project_id: str,
|
||||
script_data: Dict[str, Any],
|
||||
video_map: Dict[int, str],
|
||||
bgm_path: Optional[str] = None,
|
||||
voice_type: str = "zh_female_santongyongns_saturn_bigtts",
|
||||
output_name: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
基于脚本合成视频(异步任务)
|
||||
|
||||
Args:
|
||||
project_id: 项目 ID
|
||||
script_data: 脚本数据
|
||||
video_map: 场景视频映射
|
||||
bgm_path: BGM 路径
|
||||
voice_type: TTS 音色
|
||||
output_name: 输出文件名
|
||||
|
||||
Returns:
|
||||
{"status": "success", "output_path": "...", "output_url": "..."}
|
||||
"""
|
||||
task_id = self.request.id
|
||||
logger.info(f"[Task {task_id}] 开始合成视频: {project_id}")
|
||||
|
||||
# 更新任务状态
|
||||
self.update_state(state="PROGRESS", meta={"progress": 0.1, "message": "准备素材..."})
|
||||
|
||||
try:
|
||||
# 验证视频文件存在
|
||||
valid_videos = {}
|
||||
for scene_id, path in video_map.items():
|
||||
if path and os.path.exists(path):
|
||||
valid_videos[int(scene_id)] = path
|
||||
|
||||
if not valid_videos:
|
||||
raise ValueError("没有可用的视频素材")
|
||||
|
||||
self.update_state(state="PROGRESS", meta={"progress": 0.2, "message": "创建合成器..."})
|
||||
|
||||
# 创建合成器
|
||||
composer = VideoComposer(voice_type=voice_type)
|
||||
|
||||
# 生成输出名称
|
||||
if not output_name:
|
||||
output_name = f"final_{project_id}_{int(time.time())}"
|
||||
|
||||
self.update_state(state="PROGRESS", meta={"progress": 0.3, "message": "执行合成..."})
|
||||
|
||||
# 执行合成
|
||||
output_path = composer.compose_from_script(
|
||||
script=script_data,
|
||||
video_map=valid_videos,
|
||||
bgm_path=bgm_path,
|
||||
output_name=output_name
|
||||
)
|
||||
|
||||
self.update_state(state="PROGRESS", meta={"progress": 0.9, "message": "保存结果..."})
|
||||
|
||||
# 更新数据库
|
||||
db.save_asset(project_id, 0, "final_video", "completed", local_path=output_path)
|
||||
db.update_project_status(project_id, "completed")
|
||||
|
||||
output_url = f"/static/output/{Path(output_path).name}"
|
||||
|
||||
logger.info(f"[Task {task_id}] 合成完成: {output_path}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"output_path": output_path,
|
||||
"output_url": output_url,
|
||||
"task_id": task_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Task {task_id}] 合成失败: {e}")
|
||||
db.update_project_status(project_id, "failed")
|
||||
raise
|
||||
|
||||
|
||||
@shared_task(bind=True, name="video.compose_from_tracks")
|
||||
def compose_from_tracks_task(
|
||||
self,
|
||||
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]],
|
||||
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
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
基于编辑器轨道数据合成视频(异步任务)
|
||||
这是核心的多轨合成逻辑
|
||||
"""
|
||||
task_id = self.request.id
|
||||
logger.info(f"[Task {task_id}] 开始多轨合成: {project_id}")
|
||||
|
||||
timestamp = int(time.time())
|
||||
if not output_name:
|
||||
output_name = f"composed_{project_id}_{timestamp}"
|
||||
|
||||
temp_files = []
|
||||
|
||||
try:
|
||||
self.update_state(state="PROGRESS", meta={"progress": 0.05, "message": "验证素材..."})
|
||||
|
||||
# 1. 收集并验证视频片段
|
||||
video_clips = sorted(video_clips, key=lambda x: x.get("start", 0))
|
||||
if not video_clips:
|
||||
raise ValueError("没有视频片段")
|
||||
|
||||
video_paths = []
|
||||
for clip in video_clips:
|
||||
source_path = clip.get("source_path")
|
||||
if not source_path or not os.path.exists(source_path):
|
||||
continue
|
||||
|
||||
trim_start = clip.get("trim_start", 0)
|
||||
trim_end = clip.get("trim_end")
|
||||
|
||||
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)
|
||||
temp_files.append(trimmed_path)
|
||||
else:
|
||||
video_paths.append(source_path)
|
||||
|
||||
if not video_paths:
|
||||
raise ValueError("没有可用的视频片段")
|
||||
|
||||
self.update_state(state="PROGRESS", meta={"progress": 0.15, "message": "拼接视频..."})
|
||||
|
||||
# 2. 拼接视频
|
||||
merged_path = str(config.TEMP_DIR / f"{output_name}_merged.mp4")
|
||||
ffmpeg_utils.concat_videos(video_paths, merged_path, (1080, 1920))
|
||||
temp_files.append(merged_path)
|
||||
current_video = merged_path
|
||||
|
||||
# 添加静音轨
|
||||
silent_path = str(config.TEMP_DIR / f"{output_name}_silent.mp4")
|
||||
ffmpeg_utils.add_silence_audio(current_video, silent_path)
|
||||
temp_files.append(silent_path)
|
||||
current_video = silent_path
|
||||
|
||||
# 获取总时长
|
||||
info = ffmpeg_utils.get_video_info(current_video)
|
||||
total_duration = float(info.get("duration", 10))
|
||||
|
||||
self.update_state(state="PROGRESS", meta={"progress": 0.3, "message": "生成旁白..."})
|
||||
|
||||
# 3. 生成并混入旁白
|
||||
if voiceover_clips:
|
||||
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
|
||||
])
|
||||
temp_files.append(mixed_audio_path)
|
||||
|
||||
for i, clip in enumerate(voiceover_clips):
|
||||
text = clip.get("text", "")
|
||||
if not text:
|
||||
continue
|
||||
|
||||
start_time = clip.get("start", 0)
|
||||
target_duration = clip.get("duration", 3)
|
||||
|
||||
# 生成 TTS
|
||||
tts_path = factory.generate_voiceover_volcengine(
|
||||
text=text,
|
||||
voice_type=voice_type,
|
||||
output_path=str(config.TEMP_DIR / f"{output_name}_vo_{i}.mp3")
|
||||
)
|
||||
|
||||
if not tts_path:
|
||||
continue
|
||||
temp_files.append(tts_path)
|
||||
|
||||
# 调整时长
|
||||
adjusted_path = str(config.TEMP_DIR / f"{output_name}_vo_adj_{i}.mp3")
|
||||
ffmpeg_utils.adjust_audio_duration(tts_path, target_duration, adjusted_path)
|
||||
temp_files.append(adjusted_path)
|
||||
|
||||
# 混合
|
||||
new_mixed = str(config.TEMP_DIR / f"{output_name}_mixed_{i}.mp3")
|
||||
ffmpeg_utils.mix_audio_at_offset(mixed_audio_path, adjusted_path, start_time, new_mixed)
|
||||
mixed_audio_path = new_mixed
|
||||
temp_files.append(new_mixed)
|
||||
|
||||
# 混入视频
|
||||
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
|
||||
)
|
||||
temp_files.append(voiced_path)
|
||||
current_video = voiced_path
|
||||
|
||||
self.update_state(state="PROGRESS", meta={"progress": 0.5, "message": "添加字幕..."})
|
||||
|
||||
# 4. 添加字幕
|
||||
if subtitle_clips:
|
||||
subtitles = []
|
||||
for clip in subtitle_clips:
|
||||
text = clip.get("text", "")
|
||||
if text:
|
||||
subtitles.append({
|
||||
"text": ffmpeg_utils.wrap_text_smart(text),
|
||||
"start": clip.get("start", 0),
|
||||
"duration": clip.get("duration", 3),
|
||||
"style": clip.get("style", {})
|
||||
})
|
||||
|
||||
if subtitles:
|
||||
subtitled_path = str(config.TEMP_DIR / f"{output_name}_subtitled.mp4")
|
||||
subtitle_style = {
|
||||
"font": ffmpeg_utils._get_font_path(),
|
||||
"fontsize": 60,
|
||||
"fontcolor": "white",
|
||||
"borderw": 5,
|
||||
"bordercolor": "black",
|
||||
"box": 0,
|
||||
"x": "(w-text_w)/2",
|
||||
"y": "h-200",
|
||||
}
|
||||
ffmpeg_utils.add_multiple_subtitles(
|
||||
current_video, subtitles, subtitled_path, default_style=subtitle_style
|
||||
)
|
||||
temp_files.append(subtitled_path)
|
||||
current_video = subtitled_path
|
||||
|
||||
self.update_state(state="PROGRESS", meta={"progress": 0.65, "message": "叠加花字..."})
|
||||
|
||||
# 5. 叠加花字
|
||||
if fancy_text_clips:
|
||||
overlay_configs = []
|
||||
for clip in 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}
|
||||
})
|
||||
|
||||
img_path = renderer.render(text, style, cache=False)
|
||||
temp_files.append(img_path)
|
||||
|
||||
position = clip.get("position", {})
|
||||
overlay_configs.append({
|
||||
"path": img_path,
|
||||
"x": position.get("x", "(W-w)/2"),
|
||||
"y": position.get("y", "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)
|
||||
temp_files.append(fancy_path)
|
||||
current_video = fancy_path
|
||||
|
||||
self.update_state(state="PROGRESS", meta={"progress": 0.8, "message": "添加背景音乐..."})
|
||||
|
||||
# 6. 添加 BGM
|
||||
if bgm_clip:
|
||||
bgm_source = 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")
|
||||
ffmpeg_utils.add_bgm(
|
||||
current_video, bgm_source, bgm_output,
|
||||
bgm_volume=bgm_volume
|
||||
)
|
||||
temp_files.append(bgm_output)
|
||||
current_video = bgm_output
|
||||
|
||||
self.update_state(state="PROGRESS", meta={"progress": 0.9, "message": "保存输出..."})
|
||||
|
||||
# 7. 输出最终文件
|
||||
final_path = str(config.OUTPUT_DIR / f"{output_name}.mp4")
|
||||
shutil.copy(current_video, final_path)
|
||||
|
||||
# 更新数据库
|
||||
db.save_asset(project_id, 0, "final_video", "completed", local_path=final_path)
|
||||
db.update_project_status(project_id, "completed")
|
||||
|
||||
output_url = f"/static/output/{Path(final_path).name}"
|
||||
|
||||
logger.info(f"[Task {task_id}] 多轨合成完成: {final_path}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"output_path": final_path,
|
||||
"output_url": output_url,
|
||||
"task_id": task_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Task {task_id}] 多轨合成失败: {e}")
|
||||
db.update_project_status(project_id, "failed")
|
||||
raise
|
||||
|
||||
finally:
|
||||
# 清理临时文件
|
||||
for f in temp_files:
|
||||
try:
|
||||
if os.path.exists(f):
|
||||
os.remove(f)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
@shared_task(bind=True, name="video.trim")
|
||||
def trim_video_task(
|
||||
self,
|
||||
source_path: str,
|
||||
start_time: float,
|
||||
end_time: float,
|
||||
output_path: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
裁剪视频片段(异步任务)
|
||||
"""
|
||||
task_id = self.request.id
|
||||
logger.info(f"[Task {task_id}] 裁剪视频: {source_path}")
|
||||
|
||||
if not os.path.exists(source_path):
|
||||
raise FileNotFoundError(f"源视频不存在: {source_path}")
|
||||
|
||||
if not output_path:
|
||||
timestamp = int(time.time())
|
||||
output_path = str(config.TEMP_DIR / f"trimmed_{timestamp}.mp4")
|
||||
|
||||
try:
|
||||
duration = end_time - start_time
|
||||
cmd = [
|
||||
ffmpeg_utils.FFMPEG_PATH, "-y",
|
||||
"-ss", str(start_time),
|
||||
"-i", source_path,
|
||||
"-t", str(duration),
|
||||
"-c", "copy",
|
||||
output_path
|
||||
]
|
||||
ffmpeg_utils._run_ffmpeg(cmd)
|
||||
|
||||
output_url = f"/static/temp/{Path(output_path).name}"
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"output_path": output_path,
|
||||
"output_url": output_url,
|
||||
"duration": duration
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Task {task_id}] 裁剪失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user