chore: sync code and project files
@@ -7,6 +7,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libsm6 \
|
libsm6 \
|
||||||
libxext6 \
|
libxext6 \
|
||||||
libgl1 \
|
libgl1 \
|
||||||
|
librsvg2-bin \
|
||||||
fonts-noto-cjk \
|
fonts-noto-cjk \
|
||||||
fonts-wqy-zenhei \
|
fonts-wqy-zenhei \
|
||||||
curl \
|
curl \
|
||||||
|
|||||||
0
api/__init__.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/output", StaticFiles(directory=str(config.OUTPUT_DIR)), name="output")
|
||||||
app.mount("/static/temp", StaticFiles(directory=str(config.TEMP_DIR)), name="temp")
|
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/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 挂载到容器内)
|
# Legacy mounts(8502 runtime 产物,宿主机目录通过 docker-compose 挂载到容器内)
|
||||||
try:
|
try:
|
||||||
|
|||||||
15
api/routes/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# API Routes Package
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,15 @@
|
|||||||
|
# Celery Tasks Package
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
646
app.py
@@ -26,6 +26,7 @@ from modules import path_utils
|
|||||||
from modules import limits
|
from modules import limits
|
||||||
from modules.legacy_path_mapper import map_legacy_local_path
|
from modules.legacy_path_mapper import map_legacy_local_path
|
||||||
from modules.legacy_normalizer import normalize_legacy_project
|
from modules.legacy_normalizer import normalize_legacy_project
|
||||||
|
import extra_streamlit_components as stx
|
||||||
|
|
||||||
# Page Config
|
# Page Config
|
||||||
st.set_page_config(
|
st.set_page_config(
|
||||||
@@ -35,6 +36,84 @@ st.set_page_config(
|
|||||||
initial_sidebar_state="expanded"
|
initial_sidebar_state="expanded"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Auth (login + remember cookie)
|
||||||
|
# ============================================================
|
||||||
|
COOKIE_NAME = "vf_session"
|
||||||
|
cookie_manager = stx.CookieManager(key="vf_cookie_mgr")
|
||||||
|
|
||||||
|
def _current_user() -> dict:
|
||||||
|
u = st.session_state.get("current_user")
|
||||||
|
return u if isinstance(u, dict) else None
|
||||||
|
|
||||||
|
def _set_current_user(u: dict):
|
||||||
|
st.session_state.current_user = u
|
||||||
|
|
||||||
|
def _try_restore_login():
|
||||||
|
if _current_user():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
token = cookie_manager.get(COOKIE_NAME)
|
||||||
|
except Exception:
|
||||||
|
token = None
|
||||||
|
if token:
|
||||||
|
u = db.validate_session(token)
|
||||||
|
if u:
|
||||||
|
_set_current_user(u)
|
||||||
|
|
||||||
|
def _logout():
|
||||||
|
try:
|
||||||
|
token = cookie_manager.get(COOKIE_NAME)
|
||||||
|
except Exception:
|
||||||
|
token = None
|
||||||
|
if token:
|
||||||
|
db.revoke_session(token)
|
||||||
|
try:
|
||||||
|
cookie_manager.delete(COOKIE_NAME)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
st.session_state.pop("current_user", None)
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
def _login_gate():
|
||||||
|
_try_restore_login()
|
||||||
|
u = _current_user()
|
||||||
|
if u:
|
||||||
|
return
|
||||||
|
st.title("登录")
|
||||||
|
# Login page runs before the global CSS block below; inject minimal CSS here.
|
||||||
|
st.markdown(
|
||||||
|
"""
|
||||||
|
<style>
|
||||||
|
button[aria-label="Show password text"],
|
||||||
|
button[aria-label="Hide password text"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
""",
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
c1, c2 = st.columns([1, 2])
|
||||||
|
with c1:
|
||||||
|
username = st.text_input("用户名", value="", key="login_user")
|
||||||
|
password = st.text_input("密码", value="", type="password", key="login_pass")
|
||||||
|
if st.button("登录", type="primary"):
|
||||||
|
au = db.authenticate_user(username.strip(), password)
|
||||||
|
if not au:
|
||||||
|
st.error("用户名或密码错误,或账号已禁用")
|
||||||
|
st.stop()
|
||||||
|
token = db.create_session(au["id"])
|
||||||
|
try:
|
||||||
|
cookie_manager.set(COOKIE_NAME, token, max_age=7 * 24 * 3600)
|
||||||
|
except Exception:
|
||||||
|
# fallback: session-only
|
||||||
|
pass
|
||||||
|
_set_current_user(au)
|
||||||
|
st.rerun()
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
_login_gate()
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# BGM 智能匹配函数
|
# BGM 智能匹配函数
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -109,6 +188,12 @@ st.markdown("""
|
|||||||
.stTextInput input, .stTextArea textarea {
|
.stTextInput input, .stTextArea textarea {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide Streamlit password reveal (eye) buttons to avoid accidental exposure */
|
||||||
|
button[aria-label="Show password text"],
|
||||||
|
button[aria-label="Hide password text"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
""", unsafe_allow_html=True)
|
""", unsafe_allow_html=True)
|
||||||
|
|
||||||
@@ -150,9 +235,9 @@ def _ui_key(suffix: str) -> str:
|
|||||||
|
|
||||||
def load_project(project_id):
|
def load_project(project_id):
|
||||||
"""Load project state from DB"""
|
"""Load project state from DB"""
|
||||||
data = db.get_project(project_id)
|
data = db.get_project_for_user(project_id, _current_user())
|
||||||
if not data:
|
if not data:
|
||||||
st.error("Project not found")
|
st.error("Project not found / no permission")
|
||||||
return
|
return
|
||||||
|
|
||||||
st.session_state.project_id = project_id
|
st.session_state.project_id = project_id
|
||||||
@@ -203,6 +288,7 @@ def load_project(project_id):
|
|||||||
st.session_state.uploaded_images = []
|
st.session_state.uploaded_images = []
|
||||||
|
|
||||||
# Restore assets
|
# Restore assets
|
||||||
|
# RBAC: assets are project-scoped, so permission already checked above.
|
||||||
assets = db.get_assets(project_id)
|
assets = db.get_assets(project_id)
|
||||||
images = {}
|
images = {}
|
||||||
videos = {}
|
videos = {}
|
||||||
@@ -238,11 +324,107 @@ def load_project(project_id):
|
|||||||
else:
|
else:
|
||||||
st.session_state.current_step = 0
|
st.session_state.current_step = 0
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Helper Functions (must be defined before Sidebar uses them)
|
||||||
|
# ============================================================
|
||||||
|
def _record_metrics(project_id: str, patch: dict):
|
||||||
|
"""Persist lightweight timing/diagnostic metrics into project.product_info['_metrics']."""
|
||||||
|
if not project_id or not isinstance(patch, dict) or not patch:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
proj = db.get_project(project_id) or {}
|
||||||
|
product_info = proj.get("product_info") or {}
|
||||||
|
metrics = product_info.get("_metrics") if isinstance(product_info.get("_metrics"), dict) else {}
|
||||||
|
metrics.update(patch)
|
||||||
|
metrics["updated_at"] = time.time()
|
||||||
|
product_info["_metrics"] = metrics
|
||||||
|
db.update_project_product_info(project_id, product_info)
|
||||||
|
except Exception:
|
||||||
|
# metrics must never break UX
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _get_metrics(project_id: str) -> dict:
|
||||||
|
try:
|
||||||
|
proj = db.get_project(project_id) or {}
|
||||||
|
product_info = proj.get("product_info") or {}
|
||||||
|
m = product_info.get("_metrics")
|
||||||
|
return m if isinstance(m, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_local_videos(project_id: str, scenes: list):
|
||||||
|
"""
|
||||||
|
Step 5 合成前的保障逻辑:
|
||||||
|
检查分镜视频是否已下载到服务器本地。如果只有 URL 没有本地文件,则后台静默下载。
|
||||||
|
"""
|
||||||
|
if not project_id or not scenes:
|
||||||
|
return
|
||||||
|
|
||||||
|
vid_gen = VideoGenerator()
|
||||||
|
downloaded = 0
|
||||||
|
missing_assets = []
|
||||||
|
|
||||||
|
for scene in scenes:
|
||||||
|
scene_id = scene["id"]
|
||||||
|
local_path = st.session_state.scene_videos.get(scene_id)
|
||||||
|
|
||||||
|
# 如果本地文件不存在,尝试补全
|
||||||
|
if not local_path or not os.path.exists(local_path):
|
||||||
|
asset = db.get_asset(project_id, scene_id, "video")
|
||||||
|
if not asset:
|
||||||
|
missing_assets.append(f"Scene {scene_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
meta = asset.get("metadata") or {}
|
||||||
|
video_url = meta.get("video_url")
|
||||||
|
task_id = asset.get("task_id")
|
||||||
|
|
||||||
|
# 如果 DB 里没有 URL,但有 task_id,尝试现场查询一次
|
||||||
|
if not video_url and task_id:
|
||||||
|
logger.info(f"Checking task {task_id} status for Scene {scene_id} during composition...")
|
||||||
|
status, url = vid_gen.check_task_status(task_id)
|
||||||
|
if status == "succeeded" and url:
|
||||||
|
video_url = url
|
||||||
|
db.update_asset_metadata(project_id, scene_id, "video", {"video_url": url, "volc_status": status})
|
||||||
|
|
||||||
|
if video_url:
|
||||||
|
out_name = path_utils.unique_filename(
|
||||||
|
prefix="scene_video",
|
||||||
|
ext="mp4",
|
||||||
|
project_id=project_id,
|
||||||
|
scene_id=scene_id,
|
||||||
|
extra=f"auto_{int(time.time())}"
|
||||||
|
)
|
||||||
|
logger.info(f"Auto-downloading missing video for Scene {scene_id} from {video_url}")
|
||||||
|
target_dir = path_utils.project_videos_dir(project_id)
|
||||||
|
target_path = str(target_dir / out_name)
|
||||||
|
if vid_gen._download_video_to(video_url, target_path):
|
||||||
|
st.session_state.scene_videos[scene_id] = target_path
|
||||||
|
db.save_asset(project_id, scene_id, "video", "completed", local_path=target_path)
|
||||||
|
downloaded += 1
|
||||||
|
else:
|
||||||
|
missing_assets.append(f"Scene {scene_id} (未生成/无URL)")
|
||||||
|
|
||||||
|
if downloaded > 0:
|
||||||
|
logger.info(f"Successfully auto-downloaded {downloaded} missing videos for project {project_id}")
|
||||||
|
|
||||||
|
if missing_assets:
|
||||||
|
msg = f"无法合成:以下分镜缺少视频素材,请先在第 4 步生成:{', '.join(missing_assets)}"
|
||||||
|
logger.warning(msg)
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Sidebar
|
# Sidebar
|
||||||
# ============================================================
|
# ============================================================
|
||||||
with st.sidebar:
|
with st.sidebar:
|
||||||
st.title("📽️ Video Flow")
|
st.title("📽️ Video Flow")
|
||||||
|
cu = _current_user()
|
||||||
|
if cu:
|
||||||
|
st.caption(f"登录用户: {cu.get('username')} ({cu.get('role')})")
|
||||||
|
if st.button("退出登录", type="secondary"):
|
||||||
|
_logout()
|
||||||
|
|
||||||
# Mode Selection - 正确计算 index
|
# Mode Selection - 正确计算 index
|
||||||
mode_options = ["🛠️ 工作台", "📜 历史任务", "⚙️ 设置"]
|
mode_options = ["🛠️ 工作台", "📜 历史任务", "⚙️ 设置"]
|
||||||
@@ -262,7 +444,7 @@ with st.sidebar:
|
|||||||
if st.session_state.view_mode == "workspace":
|
if st.session_state.view_mode == "workspace":
|
||||||
# Project Selection
|
# Project Selection
|
||||||
st.subheader("Current Project")
|
st.subheader("Current Project")
|
||||||
projects = db.list_projects()
|
projects = db.list_projects_for_user(_current_user())
|
||||||
proj_options = {p['id']: f"{p.get('name', 'Untitled')} ({p['id']})" for p in projects}
|
proj_options = {p['id']: f"{p.get('name', 'Untitled')} ({p['id']})" for p in projects}
|
||||||
|
|
||||||
selected_proj_id = st.selectbox(
|
selected_proj_id = st.selectbox(
|
||||||
@@ -307,13 +489,6 @@ with st.sidebar:
|
|||||||
for k in keys:
|
for k in keys:
|
||||||
if k in m:
|
if k in m:
|
||||||
st.caption(f"{k}: {m.get(k)}")
|
st.caption(f"{k}: {m.get(k)}")
|
||||||
# 在线剪辑入口(React Editor)
|
|
||||||
web_base_url = os.getenv("WEB_BASE_URL", "http://localhost:3000").rstrip("/")
|
|
||||||
st.markdown(
|
|
||||||
f"[打开在线剪辑器]({web_base_url}/editor/{st.session_state.project_id})",
|
|
||||||
unsafe_allow_html=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
st.markdown("---")
|
st.markdown("---")
|
||||||
|
|
||||||
# Navigation / Progress
|
# Navigation / Progress
|
||||||
@@ -335,35 +510,6 @@ with st.sidebar:
|
|||||||
if key != "view_mode": del st.session_state[key]
|
if key != "view_mode": del st.session_state[key]
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Helper Functions
|
|
||||||
# ============================================================
|
|
||||||
def _record_metrics(project_id: str, patch: dict):
|
|
||||||
"""Persist lightweight timing/diagnostic metrics into project.product_info['_metrics']."""
|
|
||||||
if not project_id or not isinstance(patch, dict) or not patch:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
proj = db.get_project(project_id) or {}
|
|
||||||
product_info = proj.get("product_info") or {}
|
|
||||||
metrics = product_info.get("_metrics") if isinstance(product_info.get("_metrics"), dict) else {}
|
|
||||||
metrics.update(patch)
|
|
||||||
metrics["updated_at"] = time.time()
|
|
||||||
product_info["_metrics"] = metrics
|
|
||||||
db.update_project_product_info(project_id, product_info)
|
|
||||||
except Exception:
|
|
||||||
# metrics must never break UX
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _get_metrics(project_id: str) -> dict:
|
|
||||||
try:
|
|
||||||
proj = db.get_project(project_id) or {}
|
|
||||||
product_info = proj.get("product_info") or {}
|
|
||||||
m = product_info.get("_metrics")
|
|
||||||
return m if isinstance(m, dict) else {}
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def save_uploaded_file(project_id: str, uploaded_file):
|
def save_uploaded_file(project_id: str, uploaded_file):
|
||||||
"""Save uploaded file to per-project upload dir (avoid overwrites across projects)."""
|
"""Save uploaded file to per-project upload dir (avoid overwrites across projects)."""
|
||||||
if uploaded_file is None:
|
if uploaded_file is None:
|
||||||
@@ -467,13 +613,24 @@ if st.session_state.view_mode == "workspace":
|
|||||||
# DB: Create Project
|
# DB: Create Project
|
||||||
# 将 uploaded_images 保存到 product_info 以便持久化
|
# 将 uploaded_images 保存到 product_info 以便持久化
|
||||||
product_info = {"category": category, "price": price, "tags": tags, "params": params, "style_hint": style_hint, "uploaded_images": image_paths}
|
product_info = {"category": category, "price": price, "tags": tags, "params": params, "style_hint": style_hint, "uploaded_images": image_paths}
|
||||||
db.create_project(st.session_state.project_id, product_name, product_info)
|
db.create_project(
|
||||||
|
st.session_state.project_id,
|
||||||
|
product_name,
|
||||||
|
product_info,
|
||||||
|
owner_user_id=(_current_user() or {}).get("id"),
|
||||||
|
)
|
||||||
|
|
||||||
# Call Script Generator
|
# Call Script Generator
|
||||||
with st.spinner(f"正在分析商品信息并生成脚本 ({selected_model_label})..."):
|
with st.spinner(f"正在分析商品信息并生成脚本 ({selected_model_label})..."):
|
||||||
gen = ScriptGenerator()
|
gen = ScriptGenerator()
|
||||||
t0 = perf_counter()
|
t0 = perf_counter()
|
||||||
script = gen.generate_script(product_name, product_info, image_paths, model_provider=model_provider)
|
script = gen.generate_script(
|
||||||
|
product_name,
|
||||||
|
product_info,
|
||||||
|
image_paths,
|
||||||
|
model_provider=model_provider,
|
||||||
|
user_id=(_current_user() or {}).get("id"),
|
||||||
|
)
|
||||||
_record_metrics(st.session_state.project_id, {
|
_record_metrics(st.session_state.project_id, {
|
||||||
"script_gen_s": round(perf_counter() - t0, 3),
|
"script_gen_s": round(perf_counter() - t0, 3),
|
||||||
"script_model": model_provider,
|
"script_model": model_provider,
|
||||||
@@ -689,9 +846,9 @@ if st.session_state.view_mode == "workspace":
|
|||||||
if not ok:
|
if not ok:
|
||||||
st.warning("系统正在生成其他任务(生图并发已达上限),请稍后再试。")
|
st.warning("系统正在生成其他任务(生图并发已达上限),请稍后再试。")
|
||||||
st.stop()
|
st.stop()
|
||||||
img_gen = ImageGenerator()
|
img_gen = ImageGenerator()
|
||||||
# Pass ALL uploaded images as reference
|
# Pass ALL uploaded images as reference
|
||||||
base_imgs = st.session_state.uploaded_images if st.session_state.uploaded_images else []
|
base_imgs = st.session_state.uploaded_images if st.session_state.uploaded_images else []
|
||||||
|
|
||||||
if not base_imgs:
|
if not base_imgs:
|
||||||
st.error("No base image found (未找到参考底图). Please upload in Step 1.")
|
st.error("No base image found (未找到参考底图). Please upload in Step 1.")
|
||||||
@@ -768,17 +925,15 @@ if st.session_state.view_mode == "workspace":
|
|||||||
status_text.text(f"已完成 {done}/{total_scenes}(Scene {scene_id})")
|
status_text.text(f"已完成 {done}/{total_scenes}(Scene {scene_id})")
|
||||||
try:
|
try:
|
||||||
img_path = fut.result()
|
img_path = fut.result()
|
||||||
|
if img_path:
|
||||||
|
st.session_state.scene_images[scene_id] = img_path
|
||||||
|
db.save_asset(st.session_state.project_id, scene_id, "image", "completed", local_path=img_path)
|
||||||
|
# Invalidate stale video for this scene (image changed => old video is wrong)
|
||||||
|
db.clear_asset(st.session_state.project_id, scene_id, "video", status="pending")
|
||||||
|
st.session_state.scene_videos.pop(scene_id, None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
img_path = None
|
|
||||||
st.warning(f"Scene {scene_id} 生成失败:{e}")
|
st.warning(f"Scene {scene_id} 生成失败:{e}")
|
||||||
|
|
||||||
if img_path:
|
|
||||||
st.session_state.scene_images[scene_id] = img_path
|
|
||||||
db.save_asset(st.session_state.project_id, scene_id, "image", "completed", local_path=img_path)
|
|
||||||
# Invalidate stale video for this scene (image changed => old video is wrong)
|
|
||||||
db.clear_asset(st.session_state.project_id, scene_id, "video", status="pending")
|
|
||||||
st.session_state.scene_videos.pop(scene_id, None)
|
|
||||||
|
|
||||||
progress_bar.progress(done / total_scenes)
|
progress_bar.progress(done / total_scenes)
|
||||||
|
|
||||||
status_text.text("生图完成!")
|
status_text.text("生图完成!")
|
||||||
@@ -863,112 +1018,152 @@ if st.session_state.view_mode == "workspace":
|
|||||||
scenes = st.session_state.script_data.get("scenes", [])
|
scenes = st.session_state.script_data.get("scenes", [])
|
||||||
vid_gen = VideoGenerator()
|
vid_gen = VideoGenerator()
|
||||||
|
|
||||||
# Submit-only (non-blocking) to avoid freezing Streamlit under concurrency
|
st.caption("简化策略:第 4 步完成“生成→轮询→下载到服务器本地”。当至少有一个分镜视频落盘后,才允许进入第 5 步合成。")
|
||||||
if st.button("🎬 提交图生视频任务(非阻塞)", type="primary"):
|
|
||||||
|
if st.button("🎬 生成分镜视频并下载到服务器(阻塞)", type="primary"):
|
||||||
with limits.acquire_video(blocking=False) as ok:
|
with limits.acquire_video(blocking=False) as ok:
|
||||||
if not ok:
|
if not ok:
|
||||||
st.warning("系统正在处理其他视频任务(并发已达上限),请稍后再试。")
|
st.warning("系统正在处理其他视频任务(并发已达上限),请稍后再试。")
|
||||||
st.stop()
|
st.stop()
|
||||||
t0 = perf_counter()
|
|
||||||
submitted = 0
|
if not st.session_state.project_id:
|
||||||
|
st.error("缺少 project_id,无法生成视频。")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
if not scenes:
|
||||||
|
st.error("脚本中没有分镜,无法生成视频。")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
# 先把 DB 中已完成且存在的本地视频恢复到 session
|
||||||
for scene in scenes:
|
for scene in scenes:
|
||||||
scene_id = scene["id"]
|
scene_id = scene["id"]
|
||||||
|
existing = st.session_state.scene_videos.get(scene_id)
|
||||||
|
if existing and os.path.exists(existing):
|
||||||
|
continue
|
||||||
|
asset = db.get_asset(st.session_state.project_id, scene_id, "video")
|
||||||
|
local_path = (asset or {}).get("local_path")
|
||||||
|
if local_path and os.path.exists(local_path):
|
||||||
|
st.session_state.scene_videos[scene_id] = local_path
|
||||||
|
|
||||||
|
t0 = perf_counter()
|
||||||
|
total = len(scenes)
|
||||||
|
done = sum(1 for _sid, p in (st.session_state.scene_videos or {}).items() if p and os.path.exists(p))
|
||||||
|
progress = st.progress(0.0)
|
||||||
|
status_text = st.empty()
|
||||||
|
|
||||||
|
# 收集/提交任务
|
||||||
|
tasks = {} # scene_id -> task_id
|
||||||
|
for scene in scenes:
|
||||||
|
scene_id = scene["id"]
|
||||||
|
existing = st.session_state.scene_videos.get(scene_id)
|
||||||
|
if existing and os.path.exists(existing):
|
||||||
|
continue
|
||||||
|
|
||||||
|
asset = db.get_asset(st.session_state.project_id, scene_id, "video")
|
||||||
|
task_id = (asset or {}).get("task_id")
|
||||||
|
if task_id:
|
||||||
|
tasks[scene_id] = task_id
|
||||||
|
continue
|
||||||
|
|
||||||
image_path = st.session_state.scene_images.get(scene_id)
|
image_path = st.session_state.scene_images.get(scene_id)
|
||||||
prompt = scene.get("video_prompt", "High quality video")
|
prompt = scene.get("video_prompt", "High quality video")
|
||||||
task_id = vid_gen.submit_scene_video_task(
|
new_task_id = vid_gen.submit_scene_video_task(
|
||||||
st.session_state.project_id, scene_id, image_path, prompt
|
st.session_state.project_id, scene_id, image_path, prompt
|
||||||
)
|
)
|
||||||
if task_id:
|
if new_task_id:
|
||||||
submitted += 1
|
tasks[scene_id] = new_task_id
|
||||||
_record_metrics(st.session_state.project_id, {
|
|
||||||
"video_submit_s": round(perf_counter() - t0, 3),
|
if tasks:
|
||||||
"video_submitted": submitted,
|
db.update_project_status(st.session_state.project_id, "videos_processing")
|
||||||
})
|
|
||||||
if submitted:
|
out_dir = path_utils.project_videos_dir(st.session_state.project_id)
|
||||||
db.update_project_status(st.session_state.project_id, "videos_processing")
|
pending = set(tasks.keys())
|
||||||
st.success(f"已提交 {submitted} 个分镜视频任务。可点击下方“刷新恢复”下载结果。")
|
deadline = time.time() + 15 * 60 # 15 min
|
||||||
time.sleep(0.5)
|
|
||||||
st.rerun()
|
while pending and time.time() < deadline:
|
||||||
else:
|
status_text.text(f"视频生成/下载中:已完成 {done}/{total},队列中 {len(pending)} ...")
|
||||||
st.warning("未提交任何任务(可能缺少图片或接口失败)。")
|
to_remove = []
|
||||||
|
|
||||||
|
for scene_id in list(pending):
|
||||||
|
task_id = tasks.get(scene_id)
|
||||||
|
if not task_id:
|
||||||
|
to_remove.append(scene_id)
|
||||||
|
continue
|
||||||
|
|
||||||
if st.button("🔄 刷新状态并恢复已完成任务", type="secondary"):
|
|
||||||
with limits.acquire_video(blocking=False) as ok:
|
|
||||||
if not ok:
|
|
||||||
st.warning("系统正在处理其他视频任务(并发已达上限),请稍后再试。")
|
|
||||||
st.stop()
|
|
||||||
t0 = perf_counter()
|
|
||||||
updated = 0
|
|
||||||
for scene in scenes:
|
|
||||||
scene_id = scene["id"]
|
|
||||||
asset = db.get_asset(st.session_state.project_id, scene_id, "video")
|
|
||||||
if not asset or not asset.get("task_id"):
|
|
||||||
continue
|
|
||||||
# if already have local video, skip
|
|
||||||
existing = st.session_state.scene_videos.get(scene_id)
|
|
||||||
if existing and os.path.exists(existing):
|
|
||||||
continue
|
|
||||||
task_id = asset.get("task_id")
|
|
||||||
# Query volc status; store URL for direct preview (no server download)
|
|
||||||
status = None
|
|
||||||
url = None
|
|
||||||
# short retries for "succeeded but url missing"
|
|
||||||
for attempt in range(3):
|
|
||||||
status, url = vid_gen.check_task_status(task_id)
|
status, url = vid_gen.check_task_status(task_id)
|
||||||
if status == "succeeded" and url:
|
if status == "succeeded" and url:
|
||||||
break
|
out_name = path_utils.unique_filename(
|
||||||
time.sleep(0.5 * (2 ** attempt))
|
prefix="scene_video",
|
||||||
|
ext="mp4",
|
||||||
|
project_id=st.session_state.project_id,
|
||||||
|
scene_id=scene_id,
|
||||||
|
extra=(task_id[-8:] if isinstance(task_id, str) else None),
|
||||||
|
)
|
||||||
|
target_path = str(out_dir / out_name)
|
||||||
|
ok_dl = vid_gen._download_video_to(url, target_path)
|
||||||
|
if ok_dl and os.path.exists(target_path):
|
||||||
|
st.session_state.scene_videos[scene_id] = target_path
|
||||||
|
db.save_asset(
|
||||||
|
st.session_state.project_id,
|
||||||
|
scene_id,
|
||||||
|
"video",
|
||||||
|
"completed",
|
||||||
|
local_path=target_path,
|
||||||
|
task_id=task_id,
|
||||||
|
metadata={"downloaded_at": time.time()},
|
||||||
|
)
|
||||||
|
done += 1
|
||||||
|
else:
|
||||||
|
db.save_asset(
|
||||||
|
st.session_state.project_id,
|
||||||
|
scene_id,
|
||||||
|
"video",
|
||||||
|
"failed",
|
||||||
|
task_id=task_id,
|
||||||
|
metadata={"download_error": True, "checked_at": time.time()},
|
||||||
|
)
|
||||||
|
to_remove.append(scene_id)
|
||||||
|
elif status in ["failed", "cancelled"]:
|
||||||
|
db.save_asset(
|
||||||
|
st.session_state.project_id,
|
||||||
|
scene_id,
|
||||||
|
"video",
|
||||||
|
"failed",
|
||||||
|
task_id=task_id,
|
||||||
|
metadata={"volc_status": status, "checked_at": time.time()},
|
||||||
|
)
|
||||||
|
to_remove.append(scene_id)
|
||||||
|
else:
|
||||||
|
db.update_asset_metadata(
|
||||||
|
st.session_state.project_id,
|
||||||
|
scene_id,
|
||||||
|
"video",
|
||||||
|
{"volc_status": status, "checked_at": time.time()},
|
||||||
|
)
|
||||||
|
|
||||||
meta_patch = {"checked_at": time.time(), "volc_status": status}
|
for sid in to_remove:
|
||||||
if url:
|
pending.discard(sid)
|
||||||
meta_patch["video_url"] = url
|
|
||||||
db.update_asset_metadata(st.session_state.project_id, scene_id, "video", meta_patch)
|
progress.progress(min(1.0, done / max(total, 1)))
|
||||||
updated += 1
|
if pending:
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
st.warning(f"仍有 {len(pending)} 个分镜未在本轮完成:{sorted(list(pending))}。可再次点击按钮继续轮询与下载。")
|
||||||
|
|
||||||
_record_metrics(st.session_state.project_id, {
|
_record_metrics(st.session_state.project_id, {
|
||||||
"video_recover_s": round(perf_counter() - t0, 3),
|
"video_blocking_total_s": round(perf_counter() - t0, 3),
|
||||||
"video_recovered": updated,
|
"video_done": done,
|
||||||
|
"video_total": total,
|
||||||
})
|
})
|
||||||
if updated:
|
|
||||||
st.success(f"已刷新 {updated} 个分镜状态(成功的将以 URL 直连预览)。")
|
|
||||||
else:
|
|
||||||
st.info("暂无可恢复的视频(可能仍在排队/生成中)。")
|
|
||||||
time.sleep(0.5)
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
if st.button("📥 准备合成素材(下载成功的视频到服务器)", type="secondary"):
|
if any(p and os.path.exists(p) for p in (st.session_state.scene_videos or {}).values()):
|
||||||
with limits.acquire_video(blocking=False) as ok:
|
db.update_project_status(st.session_state.project_id, "videos_generated")
|
||||||
if not ok:
|
st.success("已生成并下载到服务器本地。进入第 5 步合成。")
|
||||||
st.warning("系统正在处理其他视频任务(并发已达上限),请稍后再试。")
|
st.session_state.current_step = 4
|
||||||
st.stop()
|
st.rerun()
|
||||||
downloaded = 0
|
|
||||||
for scene in scenes:
|
|
||||||
scene_id = scene["id"]
|
|
||||||
existing = st.session_state.scene_videos.get(scene_id)
|
|
||||||
if existing and os.path.exists(existing):
|
|
||||||
continue
|
|
||||||
asset = db.get_asset(st.session_state.project_id, scene_id, "video")
|
|
||||||
meta = (asset or {}).get("metadata") or {}
|
|
||||||
video_url = meta.get("video_url")
|
|
||||||
if not video_url:
|
|
||||||
continue
|
|
||||||
out_name = path_utils.unique_filename(
|
|
||||||
prefix="scene_video",
|
|
||||||
ext="mp4",
|
|
||||||
project_id=st.session_state.project_id,
|
|
||||||
scene_id=scene_id,
|
|
||||||
)
|
|
||||||
target_path = str(path_utils.project_videos_dir(st.session_state.project_id) / out_name)
|
|
||||||
if vid_gen._download_video_to(video_url, target_path):
|
|
||||||
st.session_state.scene_videos[scene_id] = target_path
|
|
||||||
db.save_asset(st.session_state.project_id, scene_id, "video", "completed", local_path=target_path, task_id=(asset or {}).get("task_id"), metadata=meta)
|
|
||||||
downloaded += 1
|
|
||||||
if downloaded:
|
|
||||||
st.success(f"已下载 {downloaded} 段视频,可进入合成。")
|
|
||||||
else:
|
else:
|
||||||
st.info("暂无可下载的视频(请先刷新状态获取 video_url)。")
|
st.error("未生成任何可用视频,请检查生视频接口或稍后重试。")
|
||||||
time.sleep(0.5)
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
# Display Videos (even when partially available)
|
# Display Videos (even when partially available)
|
||||||
if st.session_state.scene_videos or scenes:
|
if st.session_state.scene_videos or scenes:
|
||||||
@@ -987,43 +1182,14 @@ if st.session_state.view_mode == "workspace":
|
|||||||
if vid_path and os.path.exists(vid_path):
|
if vid_path and os.path.exists(vid_path):
|
||||||
st.video(vid_path)
|
st.video(vid_path)
|
||||||
else:
|
else:
|
||||||
# Try URL preview from DB metadata
|
|
||||||
asset = db.get_asset(st.session_state.project_id, scene_id, "video")
|
asset = db.get_asset(st.session_state.project_id, scene_id, "video")
|
||||||
meta = (asset or {}).get("metadata") or {}
|
status = (asset or {}).get("status") or "pending"
|
||||||
video_url = meta.get("video_url")
|
task_id = (asset or {}).get("task_id")
|
||||||
if video_url:
|
if task_id:
|
||||||
# Detect stale mapping: if source image signature differs, warn and avoid misleading preview
|
st.caption(f"状态: {status} | Task: {str(task_id)[-6:]}")
|
||||||
stale = False
|
|
||||||
try:
|
|
||||||
cur_img = st.session_state.scene_images.get(scene_id)
|
|
||||||
if cur_img and os.path.exists(cur_img):
|
|
||||||
st_img = stat(cur_img)
|
|
||||||
cur_size = int(getattr(st_img, "st_size", 0) or 0)
|
|
||||||
cur_mtime = float(getattr(st_img, "st_mtime", 0.0) or 0.0)
|
|
||||||
src_size = meta.get("source_image_size")
|
|
||||||
src_mtime = meta.get("source_image_mtime")
|
|
||||||
if (src_size and cur_size and int(src_size) != cur_size) or (src_mtime and cur_mtime and abs(float(src_mtime) - cur_mtime) > 1e-3):
|
|
||||||
stale = True
|
|
||||||
except Exception:
|
|
||||||
stale = False
|
|
||||||
if stale:
|
|
||||||
st.warning("检测到该视频可能基于旧图片生成(图片已更新)。请点击“提交图生视频任务”重新生成,以避免主体不一致。")
|
|
||||||
st.caption("URL 直连预览(不经服务器落盘)")
|
|
||||||
st.video(video_url)
|
|
||||||
else:
|
else:
|
||||||
st.warning("Video missing")
|
st.caption(f"状态: {status}")
|
||||||
# --- Recovery Logic ---
|
st.warning("暂无本地视频(请点击上方“生成分镜视频并下载到服务器”)。")
|
||||||
if asset and asset.get("task_id"):
|
|
||||||
task_id = asset.get("task_id")
|
|
||||||
if st.button(f"🔍 刷新URL (Task {task_id[-6:]})", key=f"recov_{scene_id}"):
|
|
||||||
with st.spinner("查询任务状态中..."):
|
|
||||||
status, url = vid_gen.check_task_status(task_id)
|
|
||||||
patch = {"checked_at": time.time(), "volc_status": status}
|
|
||||||
if url:
|
|
||||||
patch["video_url"] = url
|
|
||||||
db.update_asset_metadata(st.session_state.project_id, scene_id, "video", patch)
|
|
||||||
st.success("已刷新任务状态。")
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
# Per-scene regenerate button
|
# Per-scene regenerate button
|
||||||
if st.button(f"🔄 重生 S{scene_id}", key=f"regen_vid_{scene_id}"):
|
if st.button(f"🔄 重生 S{scene_id}", key=f"regen_vid_{scene_id}"):
|
||||||
@@ -1046,14 +1212,11 @@ if st.session_state.view_mode == "workspace":
|
|||||||
scene_id=scene_id,
|
scene_id=scene_id,
|
||||||
extra=(t_id[-8:] if isinstance(t_id, str) else None),
|
extra=(t_id[-8:] if isinstance(t_id, str) else None),
|
||||||
)
|
)
|
||||||
new_path = vid_gen._download_video(
|
target_dir = path_utils.project_videos_dir(st.session_state.project_id)
|
||||||
url,
|
target_path = str(target_dir / out_name)
|
||||||
out_name,
|
if vid_gen._download_video_to(url, target_path) and os.path.exists(target_path):
|
||||||
output_dir=path_utils.project_videos_dir(st.session_state.project_id),
|
st.session_state.scene_videos[scene_id] = target_path
|
||||||
)
|
db.save_asset(st.session_state.project_id, scene_id, "video", "completed", local_path=target_path, task_id=t_id)
|
||||||
if new_path:
|
|
||||||
st.session_state.scene_videos[scene_id] = new_path
|
|
||||||
db.save_asset(st.session_state.project_id, scene_id, "video", "completed", local_path=new_path, task_id=t_id)
|
|
||||||
st.rerun()
|
st.rerun()
|
||||||
break
|
break
|
||||||
elif status in ["failed", "cancelled"]:
|
elif status in ["failed", "cancelled"]:
|
||||||
@@ -1067,18 +1230,23 @@ if st.session_state.view_mode == "workspace":
|
|||||||
|
|
||||||
c_act1, c_act2 = st.columns([1, 4])
|
c_act1, c_act2 = st.columns([1, 4])
|
||||||
with c_act1:
|
with c_act1:
|
||||||
if st.button("🔄 重新生成所有视频", type="secondary"):
|
if st.button("🧹 清空视频并重新生成", type="secondary"):
|
||||||
# Clear videos and rerun
|
# Clear videos and rerun
|
||||||
st.session_state.scene_videos = {}
|
st.session_state.scene_videos = {}
|
||||||
# Also clear DB video assets to avoid stale URL preview
|
# Also clear DB video assets
|
||||||
if st.session_state.project_id:
|
if st.session_state.project_id:
|
||||||
db.clear_assets(st.session_state.project_id, "video", status="pending")
|
db.clear_assets(st.session_state.project_id, "video", status="pending")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
with c_act2:
|
with c_act2:
|
||||||
if st.button("下一步:合成最终成片", type="primary"):
|
can_compose = any(
|
||||||
|
p and os.path.exists(p) for p in (st.session_state.scene_videos or {}).values()
|
||||||
|
)
|
||||||
|
if st.button("进入第 5 步:合成最终成片", type="primary", disabled=not can_compose):
|
||||||
st.session_state.current_step = 4
|
st.session_state.current_step = 4
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
if not can_compose:
|
||||||
|
st.caption("⚠️ 需要至少一个分镜视频已下载到服务器本地后,才能进入合成。")
|
||||||
|
|
||||||
# --- Step 5: Final Composition & Tuning ---
|
# --- Step 5: Final Composition & Tuning ---
|
||||||
if st.session_state.current_step >= 4:
|
if st.session_state.current_step >= 4:
|
||||||
@@ -1174,6 +1342,9 @@ if st.session_state.view_mode == "workspace":
|
|||||||
|
|
||||||
if st.button("🔄 重新合成 (Re-Compose)", type="primary"):
|
if st.button("🔄 重新合成 (Re-Compose)", type="primary"):
|
||||||
with st.spinner("正在应用修改并重新合成..."):
|
with st.spinner("正在应用修改并重新合成..."):
|
||||||
|
# 自动补齐视频下载逻辑 (关键优化)
|
||||||
|
_ensure_local_videos(st.session_state.project_id, st.session_state.script_data.get("scenes", []))
|
||||||
|
|
||||||
composer = VideoComposer(voice_type=selected_voice)
|
composer = VideoComposer(voice_type=selected_voice)
|
||||||
|
|
||||||
bgm_path = None
|
bgm_path = None
|
||||||
@@ -1255,6 +1426,9 @@ if st.session_state.view_mode == "workspace":
|
|||||||
st.info("暂无合成视频,请先点击开始合成。")
|
st.info("暂无合成视频,请先点击开始合成。")
|
||||||
if st.button("✨ 开始首次合成", type="primary"):
|
if st.button("✨ 开始首次合成", type="primary"):
|
||||||
with st.spinner("正在进行多轨合成..."):
|
with st.spinner("正在进行多轨合成..."):
|
||||||
|
# 自动补齐视频下载逻辑 (关键优化)
|
||||||
|
_ensure_local_videos(st.session_state.project_id, st.session_state.script_data.get("scenes", []))
|
||||||
|
|
||||||
# Default compose logic with smart BGM matching
|
# Default compose logic with smart BGM matching
|
||||||
composer = VideoComposer(voice_type=config.VOLC_TTS_DEFAULT_VOICE)
|
composer = VideoComposer(voice_type=config.VOLC_TTS_DEFAULT_VOICE)
|
||||||
|
|
||||||
@@ -1290,6 +1464,9 @@ if st.session_state.view_mode == "workspace":
|
|||||||
else:
|
else:
|
||||||
st.success(f"共找到 {len(found_files)} 个历史版本")
|
st.success(f"共找到 {len(found_files)} 个历史版本")
|
||||||
|
|
||||||
|
# 提示用户:如果重新合成,系统会自动补全未下载的素材
|
||||||
|
st.caption("💡 重新合成将应用当前的微调设置。如果分镜未下载,系统将尝试自动补全。")
|
||||||
|
|
||||||
# 遍历显示所有历史版本
|
# 遍历显示所有历史版本
|
||||||
for idx, vid_path in enumerate(found_files):
|
for idx, vid_path in enumerate(found_files):
|
||||||
mtime = os.path.getmtime(vid_path)
|
mtime = os.path.getmtime(vid_path)
|
||||||
@@ -1324,7 +1501,7 @@ if st.session_state.view_mode == "workspace":
|
|||||||
elif st.session_state.view_mode == "history":
|
elif st.session_state.view_mode == "history":
|
||||||
st.header("📜 历史任务")
|
st.header("📜 历史任务")
|
||||||
|
|
||||||
projects = db.list_projects()
|
projects = db.list_projects_for_user(_current_user())
|
||||||
|
|
||||||
for proj in projects:
|
for proj in projects:
|
||||||
with st.expander(f"{proj['name']} ({proj['updated_at']})"):
|
with st.expander(f"{proj['name']} ({proj['updated_at']})"):
|
||||||
@@ -1394,11 +1571,19 @@ elif st.session_state.view_mode == "settings":
|
|||||||
st.subheader("Prompt 配置")
|
st.subheader("Prompt 配置")
|
||||||
|
|
||||||
# Script Generation Prompt
|
# Script Generation Prompt
|
||||||
current_prompt = db.get_config("prompt_script_gen")
|
cu = _current_user() or {}
|
||||||
|
user_prompt = None
|
||||||
|
try:
|
||||||
|
user_prompt = db.get_user_prompt(cu.get("id"), "prompt_script_gen") if cu.get("id") else None
|
||||||
|
except Exception:
|
||||||
|
user_prompt = None
|
||||||
|
current_prompt = user_prompt or db.get_config("prompt_script_gen")
|
||||||
|
|
||||||
# 显示当前状态
|
# 显示当前状态
|
||||||
if current_prompt:
|
if user_prompt:
|
||||||
st.info("✅ 已加载自定义 Prompt(来自数据库)")
|
st.info("✅ 已加载自定义 Prompt(当前用户)")
|
||||||
|
elif current_prompt:
|
||||||
|
st.info("✅ 已加载自定义 Prompt(全局默认)")
|
||||||
else:
|
else:
|
||||||
st.warning("⚠️ 使用默认 Prompt(数据库中无自定义配置)")
|
st.warning("⚠️ 使用默认 Prompt(数据库中无自定义配置)")
|
||||||
# Load default from instance if not in DB
|
# Load default from instance if not in DB
|
||||||
@@ -1410,18 +1595,89 @@ elif st.session_state.view_mode == "settings":
|
|||||||
col_save, col_reset = st.columns([1, 3])
|
col_save, col_reset = st.columns([1, 3])
|
||||||
with col_save:
|
with col_save:
|
||||||
if st.button("💾 保存配置", type="primary"):
|
if st.button("💾 保存配置", type="primary"):
|
||||||
db.set_config("prompt_script_gen", new_prompt, "System prompt for script generation step")
|
# Save per-user prompt
|
||||||
# 验证保存
|
db.set_user_prompt(cu.get("id"), "prompt_script_gen", new_prompt)
|
||||||
saved = db.get_config("prompt_script_gen")
|
saved = db.get_user_prompt(cu.get("id"), "prompt_script_gen")
|
||||||
if saved == new_prompt:
|
if saved == new_prompt:
|
||||||
st.success("✅ 配置已保存并验证成功!下次生成脚本时将使用新 Prompt。")
|
st.success("✅ 已保存为“当前用户 Prompt”。下次生成脚本仅影响你自己的账号。")
|
||||||
else:
|
else:
|
||||||
st.error("❌ 保存可能失败,请检查日志")
|
st.error("❌ 保存可能失败,请检查日志")
|
||||||
|
|
||||||
with col_reset:
|
with col_reset:
|
||||||
if st.button("🔄 恢复默认"):
|
if st.button("🔄 恢复默认"):
|
||||||
temp_gen = ScriptGenerator()
|
temp_gen = ScriptGenerator()
|
||||||
db.set_config("prompt_script_gen", temp_gen.default_system_prompt, "System prompt for script generation step (DEFAULT)")
|
# Clear per-user override: set empty to remove effect
|
||||||
st.success("已恢复默认 Prompt,请刷新页面查看")
|
db.set_user_prompt(cu.get("id"), "prompt_script_gen", "")
|
||||||
|
st.success("已清除当前用户 Prompt,将回退到全局/默认 Prompt。")
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
st.markdown("---")
|
||||||
|
st.subheader("账号与权限")
|
||||||
|
|
||||||
|
# Password change for current user
|
||||||
|
with st.expander("修改我的密码", expanded=False):
|
||||||
|
p1 = st.text_input("新密码", type="password", key=_ui_key("pwd_new"))
|
||||||
|
p2 = st.text_input("确认新密码", type="password", key=_ui_key("pwd_new2"))
|
||||||
|
if st.button("保存新密码", type="primary", key=_ui_key("pwd_save")):
|
||||||
|
if not p1 or len(p1) < 6:
|
||||||
|
st.error("密码至少 6 位")
|
||||||
|
elif p1 != p2:
|
||||||
|
st.error("两次输入不一致")
|
||||||
|
else:
|
||||||
|
db.reset_user_password(cu.get("id"), p1)
|
||||||
|
st.success("密码已更新,请重新登录。")
|
||||||
|
_logout()
|
||||||
|
|
||||||
|
# Admin console
|
||||||
|
if (cu.get("role") == "admin"):
|
||||||
|
st.markdown("### Admin:账号管理")
|
||||||
|
users = db.list_users()
|
||||||
|
if users:
|
||||||
|
st.dataframe(
|
||||||
|
[{k: u.get(k) for k in ["username", "role", "is_active", "last_login_at", "created_at", "id"]} for u in users],
|
||||||
|
use_container_width=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with st.expander("创建/更新用户", expanded=False):
|
||||||
|
u_name = st.text_input("用户名", key=_ui_key("adm_u_name"))
|
||||||
|
u_role = st.selectbox("角色", ["user", "admin"], index=0, key=_ui_key("adm_u_role"))
|
||||||
|
u_active = st.selectbox("是否启用", ["启用", "禁用"], index=0, key=_ui_key("adm_u_active"))
|
||||||
|
u_pwd = st.text_input("初始/重置密码", type="password", key=_ui_key("adm_u_pwd"))
|
||||||
|
if st.button("保存用户", type="primary", key=_ui_key("adm_u_save")):
|
||||||
|
if not u_name:
|
||||||
|
st.error("用户名不能为空")
|
||||||
|
elif not u_pwd or len(u_pwd) < 6:
|
||||||
|
st.error("密码至少 6 位")
|
||||||
|
else:
|
||||||
|
uid = db.upsert_user(
|
||||||
|
username=u_name.strip(),
|
||||||
|
password=u_pwd,
|
||||||
|
role=u_role,
|
||||||
|
is_active=(1 if u_active == "启用" else 0),
|
||||||
|
)
|
||||||
|
st.success(f"已保存用户:{u_name} (id={uid})")
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
with st.expander("重置/禁用用户(按用户名)", expanded=False):
|
||||||
|
uname = st.selectbox(
|
||||||
|
"选择用户",
|
||||||
|
options=[u.get("username") for u in users if u.get("username")],
|
||||||
|
key=_ui_key("adm_sel_user"),
|
||||||
|
) if users else None
|
||||||
|
new_pass = st.text_input("新密码(可选)", type="password", key=_ui_key("adm_reset_pwd"))
|
||||||
|
new_active = st.selectbox("状态", ["不修改", "启用", "禁用"], key=_ui_key("adm_reset_active"))
|
||||||
|
if st.button("应用修改", type="secondary", key=_ui_key("adm_apply")):
|
||||||
|
target = next((u for u in users if u.get("username") == uname), None)
|
||||||
|
if not target:
|
||||||
|
st.error("未找到用户")
|
||||||
|
else:
|
||||||
|
if new_pass:
|
||||||
|
if len(new_pass) < 6:
|
||||||
|
st.error("密码至少 6 位")
|
||||||
|
st.stop()
|
||||||
|
db.reset_user_password(target.get("id"), new_pass)
|
||||||
|
if new_active != "不修改":
|
||||||
|
db.set_user_active(target.get("id"), 1 if new_active == "启用" else 0)
|
||||||
|
st.success("已更新")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
|
|||||||
14
assets/stickers_builtin/arrow.svg
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<filter id="s" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)" fill="none" stroke="#00E5FF" stroke-width="40" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M120 320 C 220 240, 280 220, 392 168" />
|
||||||
|
<path d="M340 120 L 412 164 L 356 236" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 514 B |
14
assets/stickers_builtin/arrow_curve.svg
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<filter id="s" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)" fill="none" stroke="#00E676" stroke-width="44" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M130 360 C 160 220, 260 180, 360 150" />
|
||||||
|
<path d="M320 96 L402 142 L346 226" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 512 B |
13
assets/stickers_builtin/arrow_down.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<filter id="s" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)" fill="#FF2E88">
|
||||||
|
<path d="M256 442 L402 292 h-86 V70 H196 v222 h-86 Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 396 B |
13
assets/stickers_builtin/arrow_left.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<filter id="s" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)" fill="#7C3AED">
|
||||||
|
<path d="M70 256 L220 110 v86 h222 v120 H220 v86 Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 394 B |
13
assets/stickers_builtin/arrow_right.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<filter id="s" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)" fill="#FFD700">
|
||||||
|
<path d="M442 256 L292 402 v-86 H70 V196 h222 v-86 Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 396 B |
13
assets/stickers_builtin/arrow_up.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<filter id="s" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)" fill="#00E5FF">
|
||||||
|
<path d="M256 70 L110 220 h86 v222 h120 V220 h86 Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 394 B |
21
assets/stickers_builtin/benefit.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FF2E88"/>
|
||||||
|
<stop offset="1" stop-color="#7C3AED"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="s" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<rect x="92" y="150" width="328" height="212" rx="44" fill="url(#g)"/>
|
||||||
|
<path d="M140 190h232" stroke="#fff" stroke-width="18" stroke-linecap="round" opacity="0.55"/>
|
||||||
|
<text x="256" y="295" font-size="84" text-anchor="middle" font-family="Arial Black, Arial" fill="#fff">福利</text>
|
||||||
|
<circle cx="160" cy="330" r="20" fill="#FFD700"/>
|
||||||
|
<circle cx="352" cy="330" r="20" fill="#FFD700"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 894 B |
19
assets/stickers_builtin/bubble_buy.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFD700"/>
|
||||||
|
<stop offset="1" stop-color="#FF3D00"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="s" x="-25%" y="-25%" width="150%" height="150%">
|
||||||
|
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<path d="M92 176c0-44 36-80 80-80h168c92 0 166 74 166 166s-74 166-166 166H258l-70 60 18-60h-34c-44 0-80-36-80-80V176z" fill="#111" opacity="0.18"/>
|
||||||
|
<path d="M92 160c0-44 36-80 80-80h168c92 0 166 74 166 166s-74 166-166 166H258l-70 60 18-60h-34c-44 0-80-36-80-80V160z" fill="url(#g)"/>
|
||||||
|
<text x="296" y="292" font-size="84" text-anchor="middle" font-family="Arial Black, Arial" fill="#111">买它</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 904 B |
15
assets/stickers_builtin/bubble_go.svg
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<filter id="s" x="-25%" y="-25%" width="150%" height="150%">
|
||||||
|
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<path d="M92 170c0-40 32-72 72-72h184c84 0 152 68 152 152s-68 152-152 152H260l-72 62 18-62h-42c-40 0-72-32-72-72V170z" fill="#111" opacity="0.2"/>
|
||||||
|
<path d="M92 156c0-40 32-72 72-72h184c84 0 152 68 152 152s-68 152-152 152H260l-72 62 18-62h-42c-40 0-72-32-72-72V156z" fill="#FF2E88"/>
|
||||||
|
<text x="296" y="286" font-size="92" text-anchor="middle" font-family="Arial Black, Arial" fill="#fff">冲!</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 733 B |
19
assets/stickers_builtin/bubble_link.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#00E5FF"/>
|
||||||
|
<stop offset="1" stop-color="#FF2E88"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="s" x="-25%" y="-25%" width="150%" height="150%">
|
||||||
|
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<path d="M92 172c0-44 36-80 80-80h176c92 0 166 74 166 166s-74 166-166 166H260l-76 66 20-66h-32c-44 0-80-36-80-80V172z" fill="url(#g)"/>
|
||||||
|
<text x="300" y="292" font-size="78" text-anchor="middle" font-family="Arial Black, Arial" fill="#111">上链接</text>
|
||||||
|
<path d="M356 210c18-18 46-18 64 0s18 46 0 64l-22 22c-18 18-46 18-64 0" fill="none" stroke="#111" stroke-width="14" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 900 B |
15
assets/stickers_builtin/bubble_nice.svg
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<filter id="s" x="-25%" y="-25%" width="150%" height="150%">
|
||||||
|
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<path d="M96 176c0-44 36-80 80-80h168c92 0 166 74 166 166s-74 166-166 166H252l-78 66 20-66h-18c-44 0-80-36-80-80V176z" fill="#00E676"/>
|
||||||
|
<text x="292" y="292" font-size="84" text-anchor="middle" font-family="Arial Black, Arial" fill="#111">真香</text>
|
||||||
|
<path d="M166 126l26 22-20 30" fill="none" stroke="#111" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" opacity="0.35"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 725 B |
18
assets/stickers_builtin/bubble_order.svg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7C3AED"/>
|
||||||
|
<stop offset="1" stop-color="#00E5FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="s" x="-25%" y="-25%" width="150%" height="150%">
|
||||||
|
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<path d="M86 170c0-44 36-80 80-80h180c92 0 166 74 166 166s-74 166-166 166H256l-84 72 22-72h-28c-44 0-80-36-80-80V170z" fill="url(#g)"/>
|
||||||
|
<text x="300" y="294" font-size="84" text-anchor="middle" font-family="Arial Black, Arial" fill="#111">安排</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 752 B |
19
assets/stickers_builtin/coupon.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#00E676"/>
|
||||||
|
<stop offset="1" stop-color="#00E5FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="s" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<path d="M110 170h292c20 0 36 16 36 36v28c-22 0-40 18-40 40s18 40 40 40v28c0 20-16 36-36 36H110c-20 0-36-16-36-36v-28c22 0 40-18 40-40s-18-40-40-40v-28c0-20 16-36 36-36z" fill="url(#g)"/>
|
||||||
|
<path d="M210 190v236" stroke="#fff" stroke-width="14" stroke-dasharray="14 14" opacity="0.7"/>
|
||||||
|
<text x="320" y="300" font-size="76" text-anchor="middle" font-family="Arial Black, Arial" fill="#111">领券</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 904 B |
21
assets/stickers_builtin/follow.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#00E5FF"/>
|
||||||
|
<stop offset="1" stop-color="#7C3AED"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="s" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<rect x="60" y="170" width="392" height="172" rx="40" fill="url(#g)"/>
|
||||||
|
<rect x="72" y="182" width="368" height="148" rx="34" fill="#111" opacity="0.18"/>
|
||||||
|
<text x="256" y="285" font-size="88" text-anchor="middle" font-family="Arial Black, Arial" fill="#fff">关注</text>
|
||||||
|
<circle cx="410" cy="256" r="42" fill="#FF2E88"/>
|
||||||
|
<path d="M410 234v44M388 256h44" stroke="#fff" stroke-width="16" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 922 B |
19
assets/stickers_builtin/hot.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FF3D00"/>
|
||||||
|
<stop offset="1" stop-color="#FFD700"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="s" x="-25%" y="-25%" width="150%" height="150%">
|
||||||
|
<feDropShadow dx="0" dy="14" stdDeviation="16" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<path d="M110 210 Q256 90 402 210 Q452 252 430 318 Q395 420 256 440 Q117 420 82 318 Q60 252 110 210 Z" fill="url(#g)"/>
|
||||||
|
<path d="M256 150c-18 26-8 44 10 64 18 20 28 38 18 62-10 26-36 32-56 18 6 34 42 58 82 40 38-18 48-62 28-96-18-32-42-48-38-88 2-18 10-28 18-38-28 8-46 22-62 38z" fill="#fff" opacity="0.9"/>
|
||||||
|
<text x="256" y="318" font-size="84" text-anchor="middle" font-family="Arial Black, Arial" fill="#111">爆款</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 929 B |
53
assets/stickers_builtin/index.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"pack": {
|
||||||
|
"id": "builtin-basic",
|
||||||
|
"name": "内置贴纸库(中国抖音常用)",
|
||||||
|
"license": "Project Built-in (owned)",
|
||||||
|
"attribution": "Built-in sticker pack generated by Video Flow. No external attribution required."
|
||||||
|
},
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"id": "douyin-basic",
|
||||||
|
"name": "抖音常用",
|
||||||
|
"items": [
|
||||||
|
{ "id": "like", "name": "点赞", "file": "like.svg", "tags": ["点赞", "爱心", "互动"] },
|
||||||
|
{ "id": "follow", "name": "关注", "file": "follow.svg", "tags": ["关注", "订阅", "点赞关注"] },
|
||||||
|
{ "id": "hot", "name": "爆款", "file": "hot.svg", "tags": ["爆款", "热卖", "火"] },
|
||||||
|
{ "id": "new", "name": "新品", "file": "new.svg", "tags": ["新品", "上新"] },
|
||||||
|
{ "id": "benefit", "name": "福利", "file": "benefit.svg", "tags": ["福利", "赠品", "优惠"] },
|
||||||
|
{ "id": "sale", "name": "优惠", "file": "sale.svg", "tags": ["优惠", "促销", "价格"] },
|
||||||
|
{ "id": "coupon", "name": "领券", "file": "coupon.svg", "tags": ["领券", "优惠券", "券"] },
|
||||||
|
{ "id": "limit", "name": "限时", "file": "limit.svg", "tags": ["限时", "抢购", "倒计时"] },
|
||||||
|
{ "id": "lowest", "name": "到手价", "file": "lowest.svg", "tags": ["到手价", "价格", "低价"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
,
|
||||||
|
{
|
||||||
|
"id": "arrows",
|
||||||
|
"name": "箭头指引",
|
||||||
|
"items": [
|
||||||
|
{ "id": "arrow-up", "name": "上箭头", "file": "arrow_up.svg", "tags": ["箭头", "指向", "上"] },
|
||||||
|
{ "id": "arrow-down", "name": "下箭头", "file": "arrow_down.svg", "tags": ["箭头", "指向", "下"] },
|
||||||
|
{ "id": "arrow-left", "name": "左箭头", "file": "arrow_left.svg", "tags": ["箭头", "指向", "左"] },
|
||||||
|
{ "id": "arrow-right", "name": "右箭头", "file": "arrow_right.svg", "tags": ["箭头", "指向", "右"] },
|
||||||
|
{ "id": "arrow-curve", "name": "弯箭头", "file": "arrow_curve.svg", "tags": ["箭头", "指向", "弯"] },
|
||||||
|
{ "id": "pointer", "name": "手指", "file": "pointer.svg", "tags": ["手指", "指向", "点击"] },
|
||||||
|
{ "id": "tap", "name": "戳这里", "file": "tap.svg", "tags": ["戳这里", "引导", "点击"] },
|
||||||
|
{ "id": "look", "name": "看这里", "file": "look.svg", "tags": ["看这里", "引导", "注意"] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bubbles",
|
||||||
|
"name": "气泡弹幕",
|
||||||
|
"items": [
|
||||||
|
{ "id": "bubble-1", "name": "气泡-冲!", "file": "bubble_go.svg", "tags": ["气泡", "弹幕", "冲"] },
|
||||||
|
{ "id": "bubble-2", "name": "气泡-安排", "file": "bubble_order.svg", "tags": ["气泡", "弹幕", "安排"] },
|
||||||
|
{ "id": "bubble-3", "name": "气泡-买它", "file": "bubble_buy.svg", "tags": ["气泡", "弹幕", "买它"] },
|
||||||
|
{ "id": "bubble-4", "name": "气泡-真香", "file": "bubble_nice.svg", "tags": ["气泡", "弹幕", "真香"] },
|
||||||
|
{ "id": "bubble-5", "name": "气泡-上链接", "file": "bubble_link.svg", "tags": ["气泡", "弹幕", "链接"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
17
assets/stickers_builtin/like.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FF2E88"/>
|
||||||
|
<stop offset="1" stop-color="#FF7A00"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="s" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<path fill="url(#g)" d="M256 456s-148-88-196-176C18 208 36 132 106 104c48-19 100-6 138 34 38-40 90-53 138-34 70 28 88 104 46 176-48 88-196 176-196 176z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 650 B |
22
assets/stickers_builtin/limit.svg
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FF3D00"/>
|
||||||
|
<stop offset="1" stop-color="#FF2E88"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="s" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<rect x="88" y="160" width="336" height="192" rx="44" fill="url(#g)"/>
|
||||||
|
<text x="256" y="270" font-size="84" text-anchor="middle" font-family="Arial Black, Arial" fill="#fff">限时</text>
|
||||||
|
<g transform="translate(140,310)">
|
||||||
|
<rect x="0" y="0" width="232" height="46" rx="18" fill="#111" opacity="0.25"/>
|
||||||
|
<text x="116" y="34" font-size="26" text-anchor="middle" font-family="Arial Black, Arial" fill="#fff">抢购中</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 945 B |
19
assets/stickers_builtin/look.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#00E5FF"/>
|
||||||
|
<stop offset="1" stop-color="#00E676"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="s" x="-25%" y="-25%" width="150%" height="150%">
|
||||||
|
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<rect x="88" y="196" width="336" height="160" rx="56" fill="url(#g)"/>
|
||||||
|
<text x="256" y="302" font-size="84" text-anchor="middle" font-family="Arial Black, Arial" fill="#111">看这里</text>
|
||||||
|
<path d="M120 170l44 28-20 52" fill="none" stroke="#111" stroke-width="14" stroke-linecap="round" stroke-linejoin="round" opacity="0.35"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 833 B |
19
assets/stickers_builtin/lowest.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFD700"/>
|
||||||
|
<stop offset="1" stop-color="#FF3D00"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="s" x="-25%" y="-25%" width="150%" height="150%">
|
||||||
|
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<path d="M110 210 Q256 120 402 210 Q440 235 430 278 Q410 360 256 392 Q102 360 82 278 Q72 235 110 210 Z" fill="url(#g)"/>
|
||||||
|
<text x="256" y="300" font-size="72" text-anchor="middle" font-family="Arial Black, Arial" fill="#111">到手价</text>
|
||||||
|
<path d="M166 330h180" stroke="#111" stroke-width="16" stroke-linecap="round" opacity="0.35"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 839 B |
18
assets/stickers_builtin/new.svg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7C3AED"/>
|
||||||
|
<stop offset="1" stop-color="#00E5FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="s" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<path d="M120 150h272l40 60-40 60H120l-40-60z" fill="url(#g)"/>
|
||||||
|
<text x="256" y="238" font-size="84" text-anchor="middle" font-family="Arial Black, Arial" fill="#fff">新品</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 680 B |
16
assets/stickers_builtin/pointer.svg
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<filter id="s" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<path d="M220 72c22 0 40 18 40 40v152h22V150c0-20 16-36 36-36s36 16 36 36v114h20V182c0-18 14-32 32-32s32 14 32 32v160c0 52-42 94-94 94H260c-66 0-120-54-120-120v-8c0-22 18-40 40-40h40V112c0-22 18-40 40-40z" fill="#fff"/>
|
||||||
|
<path d="M220 72c22 0 40 18 40 40v152h22V150c0-20 16-36 36-36s36 16 36 36v114h20V182c0-18 14-32 32-32s32 14 32 32v160c0 52-42 94-94 94H260c-66 0-120-54-120-120v-8c0-22 18-40 40-40h40V112c0-22 18-40 40-40z" fill="none" stroke="#111" stroke-width="18" stroke-linejoin="round"/>
|
||||||
|
<circle cx="386" cy="92" r="32" fill="#FF2E88"/>
|
||||||
|
<path d="M386 76v32M370 92h32" stroke="#fff" stroke-width="12" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 970 B |
22
assets/stickers_builtin/sale.svg
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFD700"/>
|
||||||
|
<stop offset="1" stop-color="#FF3D00"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="s" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<path fill="url(#g)" d="M110 160 L300 70 L430 200 L240 290 Z"/>
|
||||||
|
<circle cx="170" cy="200" r="18" fill="#fff"/>
|
||||||
|
<circle cx="310" cy="140" r="18" fill="#fff"/>
|
||||||
|
<path d="M210 222 L320 112" stroke="#fff" stroke-width="18" stroke-linecap="round"/>
|
||||||
|
<rect x="120" y="292" width="320" height="120" rx="28" fill="#111"/>
|
||||||
|
<text x="280" y="372" font-size="68" text-anchor="middle" font-family="Arial Black, Arial" fill="#fff">SALE</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 942 B |
18
assets/stickers_builtin/tap.svg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FF2E88"/>
|
||||||
|
<stop offset="1" stop-color="#FFD700"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="s" x="-25%" y="-25%" width="150%" height="150%">
|
||||||
|
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<path d="M88 210h336l-48 86 48 86H88l48-86z" fill="url(#g)"/>
|
||||||
|
<text x="256" y="314" font-size="78" text-anchor="middle" font-family="Arial Black, Arial" fill="#111">戳这里</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 681 B |
16
assets/stickers_builtin/wow.svg
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<filter id="s" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#s)">
|
||||||
|
<path d="M90 140 Q256 40 422 140 Q470 170 450 230 Q420 320 256 360 Q92 320 62 230 Q42 170 90 140 Z" fill="#7C3AED"/>
|
||||||
|
<path d="M130 170 Q256 90 382 170" fill="none" stroke="#fff" stroke-width="14" stroke-linecap="round" opacity="0.35"/>
|
||||||
|
<text x="256" y="265" font-size="96" text-anchor="middle" font-family="Arial Black, Arial" fill="#fff">WOW</text>
|
||||||
|
<path d="M200 320 L172 380 L240 350 Z" fill="#7C3AED"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 743 B |
@@ -38,6 +38,10 @@ DOUBAO_IMG_MODEL = "ep-20251203231641-wg9nb"
|
|||||||
SHUBIAOBIAO_KEY = os.getenv("SHUBIAOBIAO_KEY", "sk-aL167A8sQEyvs40yBfC140Fc0fDa4c198f029aAcF0429108")
|
SHUBIAOBIAO_KEY = os.getenv("SHUBIAOBIAO_KEY", "sk-aL167A8sQEyvs40yBfC140Fc0fDa4c198f029aAcF0429108")
|
||||||
SHUBIAOBIAO_BASE_URL = os.getenv("SHUBIAOBIAO_BASE_URL", "https://api.shubiaobiao.cn/v1")
|
SHUBIAOBIAO_BASE_URL = os.getenv("SHUBIAOBIAO_BASE_URL", "https://api.shubiaobiao.cn/v1")
|
||||||
SHUBIAOBIAO_MODEL_TEXT = "gemini-3-pro-preview"
|
SHUBIAOBIAO_MODEL_TEXT = "gemini-3-pro-preview"
|
||||||
|
# ShuBiaoBiao (OpenAI-compatible) chat timeout/retries
|
||||||
|
# Note: OpenAI Python SDK default timeout is 10 minutes; we cap it to avoid UI "hang" feeling.
|
||||||
|
SHUBIAOBIAO_CHAT_TIMEOUT_S = float(os.getenv("SHUBIAOBIAO_CHAT_TIMEOUT_S", "180"))
|
||||||
|
SHUBIAOBIAO_CHAT_MAX_RETRIES = int(os.getenv("SHUBIAOBIAO_CHAT_MAX_RETRIES", "2"))
|
||||||
|
|
||||||
# Image Generation API (Updated)
|
# Image Generation API (Updated)
|
||||||
# Host: https://api.wuyinkeji.com/
|
# Host: https://api.wuyinkeji.com/
|
||||||
|
|||||||
52
docs/04-development/DEV-LEGACY-SCHEMA-20251215.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Legacy Project JSON Schema Scan Report
|
||||||
|
|
||||||
|
- temp_dir: `/opt/gloda-factory/temp`
|
||||||
|
- total_files: 18
|
||||||
|
- parsed_files: 18
|
||||||
|
- failed_files: 0
|
||||||
|
|
||||||
|
## Schema variants
|
||||||
|
|
||||||
|
- Schema_A: 13 (samples: 0b0f819a, 18470131, 26ed8fa0, 3ce8a4ee, 61e70d91)
|
||||||
|
- Unknown: 4 (samples: 01897830, 61a1e46d, 70663b6c, bf58ccd5)
|
||||||
|
- Schema_B: 1 (samples: 690b2c54)
|
||||||
|
|
||||||
|
## CTA type distribution
|
||||||
|
|
||||||
|
- str: 17
|
||||||
|
- dict: 1
|
||||||
|
|
||||||
|
## Top-level keys (top 30)
|
||||||
|
|
||||||
|
- id: 18/18
|
||||||
|
- created_at: 18/18
|
||||||
|
- status: 18/18
|
||||||
|
- input_mode: 18/18
|
||||||
|
- prompt: 18/18
|
||||||
|
- image_urls: 18/18
|
||||||
|
- video_url: 18/18
|
||||||
|
- asr_text: 18/18
|
||||||
|
- analysis: 18/18
|
||||||
|
- questions: 18/18
|
||||||
|
- answers: 18/18
|
||||||
|
- hook: 18/18
|
||||||
|
- scenes: 18/18
|
||||||
|
- cta: 18/18
|
||||||
|
- final_video_url: 18/18
|
||||||
|
- bgm_url: 18/18
|
||||||
|
|
||||||
|
## Scene keys (top 40)
|
||||||
|
|
||||||
|
- id: 74
|
||||||
|
- duration: 74
|
||||||
|
- timeline: 74
|
||||||
|
- camera_movement: 74
|
||||||
|
- story_beat: 74
|
||||||
|
- voiceover: 74
|
||||||
|
- rhythm: 74
|
||||||
|
- image_prompt: 69
|
||||||
|
- keyframe: 69
|
||||||
|
- sound_design: 69
|
||||||
|
- image_url: 47
|
||||||
|
- keyframes: 5
|
||||||
|
|
||||||
51
docs/07-user/USER-002-贴纸库数据源与授权.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
## 目标
|
||||||
|
为编辑器内置一个**适合抖音场景**的贴纸库(PNG/SVG),并保证:
|
||||||
|
- **可商用/可分发**(许可清晰)
|
||||||
|
- **可本地托管**(不依赖外部 CDN)
|
||||||
|
- **所见即所得**:预览与导出一致(贴纸叠加到成片)
|
||||||
|
|
||||||
|
## 推荐贴纸库(抖音场景友好)
|
||||||
|
### 方案 A:Microsoft Fluent UI Emoji(更“抖音感”)
|
||||||
|
- **风格**:高饱和、现代、偏 3D/大图标,适合“强调/氛围/卖点”
|
||||||
|
- **形态**:PNG/SVG(仓库提供多种风格/尺寸)
|
||||||
|
- **适用**:火/赞/心/星星/箭头/提示/表情等常用贴纸
|
||||||
|
- **风险**:请在引入前再次核对仓库 LICENSE(不同仓库/分支可能不同)
|
||||||
|
|
||||||
|
### 方案 B:Twemoji(稳定、覆盖全、但更像“emoji”)
|
||||||
|
- **风格**:标准 emoji
|
||||||
|
- **形态**:PNG/SVG
|
||||||
|
- **适用**:作为“基础补全库”非常合适
|
||||||
|
- **风险**:通常需要署名(CC-BY 类);引入前核对 LICENSE
|
||||||
|
|
||||||
|
### 不推荐(默认)
|
||||||
|
### OpenMoji
|
||||||
|
- **优点**:开源清晰、SVG 质量高
|
||||||
|
- **缺点**:常见为 CC BY-SA(“同协议分享”约束强),对商业产品和二次分发不友好
|
||||||
|
|
||||||
|
## 贴纸库落地方式(本项目)
|
||||||
|
本项目支持两类贴纸:
|
||||||
|
- **内置贴纸**:放在 `assets/stickers_builtin/`,通过 `assets/stickers_builtin/index.json` 声明分类/标签/授权信息。
|
||||||
|
- **自定义贴纸**:用户上传到 `assets/stickers_custom/`,可直接在 UI 里使用。
|
||||||
|
|
||||||
|
后端接口:
|
||||||
|
- `GET /api/assets/stickers`:返回贴纸列表(合并 builtin + custom)
|
||||||
|
- `POST /api/assets/stickers/upload`:上传 PNG/SVG/WEBP
|
||||||
|
|
||||||
|
前端能力:
|
||||||
|
- 左侧 **“贴纸”** Tab:搜索/分类/缩略图;**拖拽到时间轴**生成贴纸片段
|
||||||
|
- 时间轴新增 **“贴纸”轨道**:贴纸片段可移动/裁剪时长
|
||||||
|
- 右侧属性:贴纸 **大小/旋转/X/Y**
|
||||||
|
|
||||||
|
导出(WYSIWYG):
|
||||||
|
- FFmpeg 叠加贴纸:`overlay_multiple_images`
|
||||||
|
- SVG 会在导出侧被转换为 PNG(优先使用 `rsvg-convert`,Dockerfile 已加入 `librsvg2-bin`)
|
||||||
|
|
||||||
|
## 下一步:把“推荐贴纸库”真正导入到 assets(需要一次性下载)
|
||||||
|
由于贴纸库体积很大(数千~上万文件),建议用脚本把需要的子集同步到 `assets/stickers_builtin/`:
|
||||||
|
- 先挑“抖音高频类目”:点赞/关注/箭头/爆款/促销/emoji 表情/弹幕气泡
|
||||||
|
- 再逐步扩展
|
||||||
|
|
||||||
|
我建议你确认最终选用的库(Fluent vs Twemoji)后,我可以给你一个“按清单下载 + 生成 index.json”的脚本(可在服务器执行)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
164
docs/EDITOR_README.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# Video Flow Editor - 视频编辑器
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
Video Flow Editor 是一个基于浏览器的视频编辑器,支持多轨道时间轴编辑,与现有的 AI 视频生成工作流无缝集成。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ React Frontend (:3000) │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ Remotion │ │ Timeline │ │ Track Panel │ │
|
||||||
|
│ │ 预览播放器 │ │ 时间轴 │ │ 轨道属性面板 │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ FastAPI Backend (:8000) │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ 项目管理 │ │ 编辑器 API │ │ 合成 API │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Celery + Redis │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ 任务队列 │ │ Worker 1 │ │ Worker N... │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 编辑功能
|
||||||
|
- ✅ 视频裁剪 - 在时间轴上拖拽调整视频起止点
|
||||||
|
- ✅ 旁白编辑 - 修改文本后重新生成 TTS
|
||||||
|
- ✅ 花字编辑 - 修改花字内容和样式
|
||||||
|
- ✅ BGM 管理 - 选择和替换背景音乐
|
||||||
|
- ✅ 字幕编辑 - 修改字幕文本和时间
|
||||||
|
|
||||||
|
### 预览功能
|
||||||
|
- ✅ Remotion 实时预览 - 浏览器端实时渲染
|
||||||
|
- ✅ 多轨道显示 - 视频/音频/字幕/花字/BGM
|
||||||
|
- ✅ 播放头控制 - 拖拽快速定位
|
||||||
|
|
||||||
|
### 导出功能
|
||||||
|
- ✅ 异步合成 - 任务队列处理,不阻塞界面
|
||||||
|
- ✅ 进度查询 - 实时查看合成进度
|
||||||
|
- ✅ 下载成片 - 合成完成后直接下载
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 开发环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 安装依赖
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cd web && npm install && cd ..
|
||||||
|
|
||||||
|
# 2. 启动 Redis (需要 Docker)
|
||||||
|
docker run -d --name redis -p 6379:6379 redis:7-alpine
|
||||||
|
|
||||||
|
# 3. 启动后端
|
||||||
|
uvicorn api.main:app --reload --port 8000
|
||||||
|
|
||||||
|
# 4. 启动 Worker
|
||||||
|
celery -A api.celery_app worker --loglevel=info
|
||||||
|
|
||||||
|
# 5. 启动前端
|
||||||
|
cd web && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker 环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 一键启动所有服务
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 扩展 Worker 数量
|
||||||
|
docker-compose scale worker=3
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker-compose logs -f worker
|
||||||
|
```
|
||||||
|
|
||||||
|
## 端口规划
|
||||||
|
|
||||||
|
| 服务 | 端口 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| React Editor | 3000 | 视频编辑器前端 |
|
||||||
|
| FastAPI | 8000 | REST API |
|
||||||
|
| Streamlit | 8502 | 原有工作流调试界面 |
|
||||||
|
| Redis | 6379 | 任务队列 |
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 编辑器状态
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/editor/{project_id}/state - 获取编辑器状态
|
||||||
|
POST /api/editor/{project_id}/state - 保存编辑器状态
|
||||||
|
```
|
||||||
|
|
||||||
|
### TTS 生成
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/editor/generate-voiceover
|
||||||
|
{
|
||||||
|
"text": "要转换的文本",
|
||||||
|
"voice_type": "zh_female_santongyongns_saturn_bigtts",
|
||||||
|
"target_duration": 3.0 // 可选
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 视频合成
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/compose/render
|
||||||
|
{
|
||||||
|
"project_id": "PROJ-xxx",
|
||||||
|
"video_clips": [...],
|
||||||
|
"voiceover_clips": [...],
|
||||||
|
"subtitle_clips": [...],
|
||||||
|
"fancy_text_clips": [...],
|
||||||
|
"bgm_clip": {...}
|
||||||
|
}
|
||||||
|
|
||||||
|
GET /api/compose/status/{task_id} - 查询合成进度
|
||||||
|
```
|
||||||
|
|
||||||
|
## 扩展性
|
||||||
|
|
||||||
|
### 水平扩展
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 增加 Worker 数量
|
||||||
|
docker-compose scale worker=5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 性能监控
|
||||||
|
|
||||||
|
- Celery Flower: `celery -A api.celery_app flower`
|
||||||
|
- Redis 监控: `redis-cli monitor`
|
||||||
|
|
||||||
|
## 与工作流的集成
|
||||||
|
|
||||||
|
编辑器自动读取工作流生成的素材:
|
||||||
|
|
||||||
|
1. 用户在 Streamlit 完成视频生成工作流
|
||||||
|
2. 点击"编辑"按钮跳转到编辑器
|
||||||
|
3. 编辑器自动加载项目的所有素材到时间轴
|
||||||
|
4. 用户进行精细编辑
|
||||||
|
5. 导出最终成品
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **前端**: React 18 + TypeScript + Vite + Remotion
|
||||||
|
- **后端**: FastAPI + Celery + Redis
|
||||||
|
- **视频处理**: FFmpeg (Worker 中运行)
|
||||||
|
- **数据库**: SQLite / PostgreSQL
|
||||||
|
|
||||||
48
modules/auth.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Auth helpers: password hashing + cookie token hashing.
|
||||||
|
|
||||||
|
We intentionally avoid heavy dependencies. Password hashing uses PBKDF2-HMAC-SHA256.
|
||||||
|
Session tokens are random and stored server-side as SHA256(token) hashes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
PBKDF2_ITERS = 200_000
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str, salt_hex: Optional[str] = None) -> Tuple[str, str]:
|
||||||
|
salt = bytes.fromhex(salt_hex) if salt_hex else secrets.token_bytes(16)
|
||||||
|
dk = hashlib.pbkdf2_hmac("sha256", (password or "").encode("utf-8"), salt, PBKDF2_ITERS)
|
||||||
|
return dk.hex(), salt.hex()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, password_hash: str, salt_hex: str) -> bool:
|
||||||
|
cand, _ = hash_password(password, salt_hex=salt_hex)
|
||||||
|
return cand == (password_hash or "")
|
||||||
|
|
||||||
|
|
||||||
|
def new_session_token() -> str:
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
return hashlib.sha256((token or "").encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -502,11 +502,20 @@ class VideoComposer:
|
|||||||
current_video = fancy_path
|
current_video = fancy_path
|
||||||
|
|
||||||
# 7. 添加 BGM
|
# 7. 添加 BGM
|
||||||
|
# 说明:add_bgm 的 ducking=True 路径使用 sidechaincompress,但该滤镜本身不做“混音”,
|
||||||
|
# 在某些 ffmpeg 版本/参数组合下会导致 BGM 听起来像“没加上”。
|
||||||
|
# 我们在 compose() 里已禁用 ducking,这里保持一致,使用 amix 叠加并提高默认音量。
|
||||||
if bgm_path:
|
if bgm_path:
|
||||||
bgm_output = str(Path(temp_root) / f"{output_name}_bgm.mp4")
|
bgm_output = str(Path(temp_root) / f"{output_name}_bgm.mp4")
|
||||||
ffmpeg_utils.add_bgm(
|
ffmpeg_utils.add_bgm(
|
||||||
current_video, bgm_path, bgm_output,
|
current_video,
|
||||||
bgm_volume=0.15
|
bgm_path,
|
||||||
|
bgm_output,
|
||||||
|
bgm_volume=0.20,
|
||||||
|
ducking=False,
|
||||||
|
duck_gain_db=-6.0,
|
||||||
|
fade_in=1.0,
|
||||||
|
fade_out=1.0,
|
||||||
)
|
)
|
||||||
self._add_temp(bgm_output)
|
self._add_temp(bgm_output)
|
||||||
current_video = bgm_output
|
current_video = bgm_output
|
||||||
|
|||||||
@@ -6,14 +6,43 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Dict, List, Any, Optional
|
import secrets
|
||||||
|
from typing import Dict, List, Any, Optional, Tuple
|
||||||
|
|
||||||
from sqlalchemy import create_engine, Column, String, Integer, Text, Float, UniqueConstraint, func
|
from sqlalchemy import create_engine, Column, String, Integer, Text, Float, UniqueConstraint, func, text, inspect
|
||||||
from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base
|
from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
|
# NOTE: Some deployments do not ship `modules/auth.py`.
|
||||||
|
# Keep a minimal, local auth helper here to avoid hard dependency.
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_password(password: str, salt_hex: str = None) -> tuple[str, str]:
|
||||||
|
salt = bytes.fromhex(salt_hex) if salt_hex else secrets.token_bytes(16)
|
||||||
|
dk = hashlib.pbkdf2_hmac("sha256", (password or "").encode("utf-8"), salt, 120_000)
|
||||||
|
return dk.hex(), salt.hex()
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_password(password: str, pwd_hash_hex: str, salt_hex: str) -> bool:
|
||||||
|
try:
|
||||||
|
cand, _ = _hash_password(password or "", salt_hex=salt_hex)
|
||||||
|
return hmac.compare_digest(cand, pwd_hash_hex or "")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _new_session_token() -> str:
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_token(token: str) -> str:
|
||||||
|
# store only hash for sessions
|
||||||
|
return hashlib.sha256((token or "").encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
@@ -26,6 +55,7 @@ class Project(Base):
|
|||||||
status = Column(String) # created, script_generated, images_generated, videos_generated, completed
|
status = Column(String) # created, script_generated, images_generated, videos_generated, completed
|
||||||
product_info = Column(Text) # JSON string (SQLite) or JSONB (PG - using Text for compat)
|
product_info = Column(Text) # JSON string (SQLite) or JSONB (PG - using Text for compat)
|
||||||
script_data = Column(Text) # JSON string
|
script_data = Column(Text) # JSON string
|
||||||
|
owner_user_id = Column(String, index=True, nullable=True)
|
||||||
created_at = Column(Float, default=time.time)
|
created_at = Column(Float, default=time.time)
|
||||||
updated_at = Column(Float, default=time.time, onupdate=time.time)
|
updated_at = Column(Float, default=time.time, onupdate=time.time)
|
||||||
|
|
||||||
@@ -54,6 +84,62 @@ class AppConfig(Base):
|
|||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
updated_at = Column(Float, default=time.time, onupdate=time.time)
|
updated_at = Column(Float, default=time.time, onupdate=time.time)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
id = Column(String, primary_key=True) # uuid hex
|
||||||
|
username = Column(String, unique=True, index=True)
|
||||||
|
password_hash = Column(String)
|
||||||
|
password_salt = Column(String)
|
||||||
|
role = Column(String, default="user") # admin/user
|
||||||
|
is_active = Column(Integer, default=1) # 1/0 for portability
|
||||||
|
created_at = Column(Float, default=time.time)
|
||||||
|
updated_at = Column(Float, default=time.time, onupdate=time.time)
|
||||||
|
last_login_at = Column(Float, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UserSession(Base):
|
||||||
|
__tablename__ = "user_sessions"
|
||||||
|
id = Column(String, primary_key=True) # uuid hex
|
||||||
|
user_id = Column(String, index=True)
|
||||||
|
token_hash = Column(String, index=True)
|
||||||
|
expires_at = Column(Float)
|
||||||
|
created_at = Column(Float, default=time.time)
|
||||||
|
last_seen_at = Column(Float, default=time.time)
|
||||||
|
ip = Column(String, nullable=True)
|
||||||
|
user_agent = Column(String, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UserPrompt(Base):
|
||||||
|
__tablename__ = "user_prompts"
|
||||||
|
id = Column(String, primary_key=True) # uuid hex
|
||||||
|
user_id = Column(String, index=True)
|
||||||
|
key = Column(String, index=True)
|
||||||
|
value = Column(Text)
|
||||||
|
updated_at = Column(Float, default=time.time, onupdate=time.time)
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "key", name="uix_user_prompt"),)
|
||||||
|
|
||||||
|
|
||||||
|
class RenderJob(Base):
|
||||||
|
"""
|
||||||
|
Render job status table (async compose pipeline).
|
||||||
|
This is intentionally minimal and portable across SQLite/Postgres.
|
||||||
|
"""
|
||||||
|
__tablename__ = "render_jobs"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True) # task_id
|
||||||
|
project_id = Column(String, index=True)
|
||||||
|
status = Column(String, default="queued") # queued/running/success/failed/cancelled
|
||||||
|
progress = Column(Float, default=0.0)
|
||||||
|
message = Column(Text, default="")
|
||||||
|
output_path = Column(Text, nullable=True)
|
||||||
|
output_url = Column(Text, nullable=True)
|
||||||
|
error = Column(Text, nullable=True)
|
||||||
|
request_json = Column(Text, nullable=True) # JSON string of ComposeRequest
|
||||||
|
parent_id = Column(String, nullable=True, index=True)
|
||||||
|
created_at = Column(Float, default=time.time)
|
||||||
|
updated_at = Column(Float, default=time.time, onupdate=time.time)
|
||||||
|
|
||||||
class DBManager:
|
class DBManager:
|
||||||
def __init__(self, connection_string: str = None):
|
def __init__(self, connection_string: str = None):
|
||||||
if not connection_string:
|
if not connection_string:
|
||||||
@@ -62,17 +148,59 @@ class DBManager:
|
|||||||
self.engine = create_engine(connection_string, pool_recycle=3600)
|
self.engine = create_engine(connection_string, pool_recycle=3600)
|
||||||
self.Session = scoped_session(sessionmaker(bind=self.engine))
|
self.Session = scoped_session(sessionmaker(bind=self.engine))
|
||||||
self._init_db()
|
self._init_db()
|
||||||
|
# bootstrap admin (safe to call repeatedly)
|
||||||
|
try:
|
||||||
|
self._bootstrap_admin_id = self.ensure_admin_user("admin", "admin1234")
|
||||||
|
except Exception:
|
||||||
|
self._bootstrap_admin_id = None
|
||||||
|
|
||||||
def _init_db(self):
|
def _init_db(self):
|
||||||
"""初始化表结构"""
|
"""初始化表结构 + 轻量自迁移(不依赖 Alembic)"""
|
||||||
Base.metadata.create_all(self.engine)
|
Base.metadata.create_all(self.engine)
|
||||||
|
self._ensure_schema()
|
||||||
|
|
||||||
|
def _ensure_schema(self) -> None:
|
||||||
|
"""
|
||||||
|
Ensure newer columns/tables exist in both Postgres and SQLite.
|
||||||
|
create_all will not add columns to existing tables, so we do a minimal ALTER here.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
insp = inspect(self.engine)
|
||||||
|
# projects.owner_user_id
|
||||||
|
try:
|
||||||
|
cols = [c["name"] for c in insp.get_columns("projects")]
|
||||||
|
except Exception:
|
||||||
|
cols = []
|
||||||
|
if "owner_user_id" not in cols:
|
||||||
|
logger.info("Migrating: add projects.owner_user_id")
|
||||||
|
with self.engine.begin() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE projects ADD COLUMN owner_user_id VARCHAR"))
|
||||||
|
try:
|
||||||
|
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_projects_owner_user_id ON projects (owner_user_id)"))
|
||||||
|
except Exception:
|
||||||
|
# some engines (older sqlite) may not support IF NOT EXISTS
|
||||||
|
try:
|
||||||
|
conn.execute(text("CREATE INDEX ix_projects_owner_user_id ON projects (owner_user_id)"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Schema ensure skipped/failed (non-fatal): {e}")
|
||||||
|
|
||||||
|
# Ensure render_jobs table exists (create_all should handle this; keep for safety)
|
||||||
|
try:
|
||||||
|
insp = inspect(self.engine)
|
||||||
|
if "render_jobs" not in insp.get_table_names():
|
||||||
|
logger.info("Migrating: create render_jobs table")
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"render_jobs ensure skipped/failed (non-fatal): {e}")
|
||||||
|
|
||||||
def _get_session(self):
|
def _get_session(self):
|
||||||
return self.Session()
|
return self.Session()
|
||||||
|
|
||||||
# --- Project Operations ---
|
# --- Project Operations ---
|
||||||
|
|
||||||
def create_project(self, project_id: str, name: str, product_info: Dict[str, Any]):
|
def create_project(self, project_id: str, name: str, product_info: Dict[str, Any], owner_user_id: Optional[str] = None):
|
||||||
session = self._get_session()
|
session = self._get_session()
|
||||||
try:
|
try:
|
||||||
# Check if exists
|
# Check if exists
|
||||||
@@ -86,6 +214,7 @@ class DBManager:
|
|||||||
name=name,
|
name=name,
|
||||||
status="created",
|
status="created",
|
||||||
product_info=json.dumps(product_info, ensure_ascii=False),
|
product_info=json.dumps(product_info, ensure_ascii=False),
|
||||||
|
owner_user_id=owner_user_id,
|
||||||
created_at=time.time(),
|
created_at=time.time(),
|
||||||
updated_at=time.time()
|
updated_at=time.time()
|
||||||
)
|
)
|
||||||
@@ -157,6 +286,7 @@ class DBManager:
|
|||||||
"status": project.status,
|
"status": project.status,
|
||||||
"product_info": json.loads(project.product_info) if project.product_info else {},
|
"product_info": json.loads(project.product_info) if project.product_info else {},
|
||||||
"script_data": json.loads(project.script_data) if project.script_data else None,
|
"script_data": json.loads(project.script_data) if project.script_data else None,
|
||||||
|
"owner_user_id": getattr(project, "owner_user_id", None),
|
||||||
"created_at": project.created_at,
|
"created_at": project.created_at,
|
||||||
"updated_at": project.updated_at
|
"updated_at": project.updated_at
|
||||||
}
|
}
|
||||||
@@ -175,12 +305,288 @@ class DBManager:
|
|||||||
"id": p.id,
|
"id": p.id,
|
||||||
"name": p.name,
|
"name": p.name,
|
||||||
"status": p.status,
|
"status": p.status,
|
||||||
|
"owner_user_id": getattr(p, "owner_user_id", None),
|
||||||
"updated_at": p.updated_at
|
"updated_at": p.updated_at
|
||||||
})
|
})
|
||||||
return results
|
return results
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
# --- User/Auth Operations ---
|
||||||
|
|
||||||
|
def ensure_admin_user(self, username: str = "admin", password: str = "admin1234") -> str:
|
||||||
|
"""Create bootstrap admin user if missing. Returns admin user_id."""
|
||||||
|
session = self._get_session()
|
||||||
|
try:
|
||||||
|
u = session.query(User).filter_by(username=username).first()
|
||||||
|
if u:
|
||||||
|
return u.id
|
||||||
|
pwd_hash, salt_hex = _hash_password(password)
|
||||||
|
uid = secrets.token_hex(16)
|
||||||
|
u = User(
|
||||||
|
id=uid,
|
||||||
|
username=username,
|
||||||
|
password_hash=pwd_hash,
|
||||||
|
password_salt=salt_hex,
|
||||||
|
role="admin",
|
||||||
|
is_active=1,
|
||||||
|
created_at=time.time(),
|
||||||
|
updated_at=time.time(),
|
||||||
|
)
|
||||||
|
session.add(u)
|
||||||
|
session.commit()
|
||||||
|
logger.warning("Bootstrap admin created (please change password asap).")
|
||||||
|
return uid
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error(f"ensure_admin_user failed: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def authenticate_user(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||||
|
session = self._get_session()
|
||||||
|
try:
|
||||||
|
u = session.query(User).filter_by(username=username).first()
|
||||||
|
if not u or int(getattr(u, "is_active", 1) or 0) != 1:
|
||||||
|
return None
|
||||||
|
if not _verify_password(password or "", getattr(u, "password_hash", ""), getattr(u, "password_salt", "")):
|
||||||
|
return None
|
||||||
|
u.last_login_at = time.time()
|
||||||
|
session.commit()
|
||||||
|
return {"id": u.id, "username": u.username, "role": u.role, "is_active": int(u.is_active or 0)}
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error(f"authenticate_user error: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def create_session(self, user_id: str, *, ttl_seconds: int = 7 * 24 * 3600, ip: str = None, user_agent: str = None) -> str:
|
||||||
|
"""Returns raw session token (store hash in DB)."""
|
||||||
|
token = _new_session_token()
|
||||||
|
token_hash = _hash_token(token)
|
||||||
|
sid = secrets.token_hex(16)
|
||||||
|
session = self._get_session()
|
||||||
|
try:
|
||||||
|
now = time.time()
|
||||||
|
s = UserSession(
|
||||||
|
id=sid,
|
||||||
|
user_id=user_id,
|
||||||
|
token_hash=token_hash,
|
||||||
|
expires_at=now + int(ttl_seconds),
|
||||||
|
created_at=now,
|
||||||
|
last_seen_at=now,
|
||||||
|
ip=ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
)
|
||||||
|
session.add(s)
|
||||||
|
session.commit()
|
||||||
|
return token
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error(f"create_session error: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def validate_session(self, token: str) -> Optional[Dict[str, Any]]:
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
token_hash = _hash_token(token)
|
||||||
|
session = self._get_session()
|
||||||
|
try:
|
||||||
|
now = time.time()
|
||||||
|
s = session.query(UserSession).filter_by(token_hash=token_hash).first()
|
||||||
|
if not s or (s.expires_at and float(s.expires_at) < now):
|
||||||
|
return None
|
||||||
|
u = session.query(User).filter_by(id=s.user_id).first()
|
||||||
|
if not u or int(getattr(u, "is_active", 1) or 0) != 1:
|
||||||
|
return None
|
||||||
|
s.last_seen_at = now
|
||||||
|
session.commit()
|
||||||
|
return {"id": u.id, "username": u.username, "role": u.role, "is_active": int(u.is_active or 0)}
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error(f"validate_session error: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def revoke_session(self, token: str) -> None:
|
||||||
|
if not token:
|
||||||
|
return
|
||||||
|
token_hash = _hash_token(token)
|
||||||
|
session = self._get_session()
|
||||||
|
try:
|
||||||
|
s = session.query(UserSession).filter_by(token_hash=token_hash).first()
|
||||||
|
if s:
|
||||||
|
session.delete(s)
|
||||||
|
session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error(f"revoke_session error: {e}")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def list_users(self) -> List[Dict[str, Any]]:
|
||||||
|
session = self._get_session()
|
||||||
|
try:
|
||||||
|
users = session.query(User).order_by(User.created_at.asc()).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": u.id,
|
||||||
|
"username": u.username,
|
||||||
|
"role": u.role,
|
||||||
|
"is_active": int(u.is_active or 0),
|
||||||
|
"created_at": u.created_at,
|
||||||
|
"last_login_at": u.last_login_at,
|
||||||
|
}
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def upsert_user(self, username: str, password: str, *, role: str = "user", is_active: int = 1) -> str:
|
||||||
|
session = self._get_session()
|
||||||
|
try:
|
||||||
|
u = session.query(User).filter_by(username=username).first()
|
||||||
|
pwd_hash, salt_hex = _hash_password(password)
|
||||||
|
now = time.time()
|
||||||
|
if u:
|
||||||
|
u.password_hash = pwd_hash
|
||||||
|
u.password_salt = salt_hex
|
||||||
|
u.role = role
|
||||||
|
u.is_active = int(is_active)
|
||||||
|
u.updated_at = now
|
||||||
|
session.commit()
|
||||||
|
return u.id
|
||||||
|
uid = secrets.token_hex(16)
|
||||||
|
u = User(
|
||||||
|
id=uid,
|
||||||
|
username=username,
|
||||||
|
password_hash=pwd_hash,
|
||||||
|
password_salt=salt_hex,
|
||||||
|
role=role,
|
||||||
|
is_active=int(is_active),
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(u)
|
||||||
|
session.commit()
|
||||||
|
return uid
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error(f"upsert_user error: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def set_user_active(self, user_id: str, is_active: int) -> None:
|
||||||
|
session = self._get_session()
|
||||||
|
try:
|
||||||
|
u = session.query(User).filter_by(id=user_id).first()
|
||||||
|
if not u:
|
||||||
|
return
|
||||||
|
u.is_active = int(is_active)
|
||||||
|
u.updated_at = time.time()
|
||||||
|
session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error(f"set_user_active error: {e}")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def reset_user_password(self, user_id: str, new_password: str) -> None:
|
||||||
|
session = self._get_session()
|
||||||
|
try:
|
||||||
|
u = session.query(User).filter_by(id=user_id).first()
|
||||||
|
if not u:
|
||||||
|
return
|
||||||
|
pwd_hash, salt_hex = _hash_password(new_password)
|
||||||
|
u.password_hash = pwd_hash
|
||||||
|
u.password_salt = salt_hex
|
||||||
|
u.updated_at = time.time()
|
||||||
|
session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error(f"reset_user_password error: {e}")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_user_prompt(self, user_id: str, key: str) -> Optional[str]:
|
||||||
|
if not user_id or not key:
|
||||||
|
return None
|
||||||
|
session = self._get_session()
|
||||||
|
try:
|
||||||
|
p = session.query(UserPrompt).filter_by(user_id=user_id, key=key).first()
|
||||||
|
return p.value if p else None
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def set_user_prompt(self, user_id: str, key: str, value: Any) -> None:
|
||||||
|
if not user_id or not key:
|
||||||
|
return
|
||||||
|
session = self._get_session()
|
||||||
|
try:
|
||||||
|
now = time.time()
|
||||||
|
v = json.dumps(value, ensure_ascii=False) if not isinstance(value, str) else value
|
||||||
|
p = session.query(UserPrompt).filter_by(user_id=user_id, key=key).first()
|
||||||
|
if p:
|
||||||
|
p.value = v
|
||||||
|
p.updated_at = now
|
||||||
|
else:
|
||||||
|
p = UserPrompt(id=secrets.token_hex(16), user_id=user_id, key=key, value=v, updated_at=now)
|
||||||
|
session.add(p)
|
||||||
|
session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error(f"set_user_prompt error: {e}")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
# --- RBAC helpers ---
|
||||||
|
def list_projects_for_user(self, user: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
if not user:
|
||||||
|
return []
|
||||||
|
if user.get("role") == "admin":
|
||||||
|
return self.list_projects()
|
||||||
|
uid = user.get("id")
|
||||||
|
session = self._get_session()
|
||||||
|
try:
|
||||||
|
projects = session.query(Project).filter_by(owner_user_id=uid).order_by(Project.updated_at.desc()).all()
|
||||||
|
return [{"id": p.id, "name": p.name, "status": p.status, "owner_user_id": p.owner_user_id, "updated_at": p.updated_at} for p in projects]
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_project_for_user(self, project_id: str, user: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
data = self.get_project(project_id)
|
||||||
|
if not data or not user:
|
||||||
|
return None
|
||||||
|
if user.get("role") == "admin":
|
||||||
|
return data
|
||||||
|
if data.get("owner_user_id") != user.get("id"):
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
def migrate_projects_owner_to(self, owner_user_id: str) -> int:
|
||||||
|
"""Assign owner_user_id for legacy projects where it is NULL/empty."""
|
||||||
|
session = self._get_session()
|
||||||
|
try:
|
||||||
|
q = session.query(Project).filter((Project.owner_user_id == None) | (Project.owner_user_id == "")) # noqa: E711
|
||||||
|
rows = q.all()
|
||||||
|
for p in rows:
|
||||||
|
p.owner_user_id = owner_user_id
|
||||||
|
p.updated_at = time.time()
|
||||||
|
session.commit()
|
||||||
|
return len(rows)
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error(f"migrate_projects_owner_to error: {e}")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
# --- Asset/Task Operations ---
|
# --- Asset/Task Operations ---
|
||||||
|
|
||||||
def save_asset(self, project_id: str, scene_id: int, asset_type: str,
|
def save_asset(self, project_id: str, scene_id: int, asset_type: str,
|
||||||
@@ -253,6 +659,92 @@ class DBManager:
|
|||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
# --- Render Job Operations ---
|
||||||
|
|
||||||
|
def create_render_job(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
project_id: str,
|
||||||
|
*,
|
||||||
|
status: str = "queued",
|
||||||
|
progress: float = 0.0,
|
||||||
|
message: str = "",
|
||||||
|
request: Optional[Dict[str, Any]] = None,
|
||||||
|
parent_id: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
session = self._get_session()
|
||||||
|
try:
|
||||||
|
existing = session.query(RenderJob).filter_by(id=job_id).first()
|
||||||
|
if existing:
|
||||||
|
return
|
||||||
|
now = time.time()
|
||||||
|
rj = RenderJob(
|
||||||
|
id=job_id,
|
||||||
|
project_id=project_id,
|
||||||
|
status=status,
|
||||||
|
progress=float(progress or 0.0),
|
||||||
|
message=message or "",
|
||||||
|
request_json=json.dumps(request, ensure_ascii=False) if request is not None else None,
|
||||||
|
parent_id=parent_id,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(rj)
|
||||||
|
session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error(f"create_render_job error: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def update_render_job(self, job_id: str, patch: Dict[str, Any]) -> None:
|
||||||
|
if not job_id or not patch:
|
||||||
|
return
|
||||||
|
session = self._get_session()
|
||||||
|
try:
|
||||||
|
rj = session.query(RenderJob).filter_by(id=job_id).first()
|
||||||
|
if not rj:
|
||||||
|
return
|
||||||
|
for k, v in patch.items():
|
||||||
|
if not hasattr(rj, k):
|
||||||
|
continue
|
||||||
|
setattr(rj, k, v)
|
||||||
|
rj.updated_at = time.time()
|
||||||
|
session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error(f"update_render_job error: {e}")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_render_job(self, job_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
session = self._get_session()
|
||||||
|
try:
|
||||||
|
rj = session.query(RenderJob).filter_by(id=job_id).first()
|
||||||
|
if not rj:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
req = json.loads(rj.request_json) if rj.request_json else None
|
||||||
|
except Exception:
|
||||||
|
req = None
|
||||||
|
return {
|
||||||
|
"id": rj.id,
|
||||||
|
"project_id": rj.project_id,
|
||||||
|
"status": rj.status,
|
||||||
|
"progress": float(rj.progress or 0.0),
|
||||||
|
"message": rj.message or "",
|
||||||
|
"output_path": rj.output_path,
|
||||||
|
"output_url": rj.output_url,
|
||||||
|
"error": rj.error,
|
||||||
|
"request": req,
|
||||||
|
"parent_id": rj.parent_id,
|
||||||
|
"created_at": rj.created_at,
|
||||||
|
"updated_at": rj.updated_at,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
def get_asset(self, project_id: str, scene_id: int, asset_type: str) -> Optional[Dict[str, Any]]:
|
def get_asset(self, project_id: str, scene_id: int, asset_type: str) -> Optional[Dict[str, Any]]:
|
||||||
session = self._get_session()
|
session = self._get_session()
|
||||||
try:
|
try:
|
||||||
@@ -279,6 +771,30 @@ class DBManager:
|
|||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
def get_asset_by_id(self, asset_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""通过自增 id 获取素材记录(用于 file proxy)。"""
|
||||||
|
session = self._get_session()
|
||||||
|
try:
|
||||||
|
a = session.query(SceneAsset).filter_by(id=int(asset_id)).first()
|
||||||
|
if not a:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": a.id,
|
||||||
|
"project_id": a.project_id,
|
||||||
|
"scene_id": a.scene_id,
|
||||||
|
"asset_type": a.asset_type,
|
||||||
|
"status": a.status,
|
||||||
|
"local_path": a.local_path,
|
||||||
|
"remote_url": a.remote_url,
|
||||||
|
"task_id": a.task_id,
|
||||||
|
"metadata": json.loads(a.metadata_json) if a.metadata_json else {},
|
||||||
|
"updated_at": a.updated_at,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
def update_asset_metadata(self, project_id: str, scene_id: int, asset_type: str, patch: Dict[str, Any]) -> None:
|
def update_asset_metadata(self, project_id: str, scene_id: int, asset_type: str, patch: Dict[str, Any]) -> None:
|
||||||
"""Merge-patch asset.metadata JSON without overwriting other fields."""
|
"""Merge-patch asset.metadata JSON without overwriting other fields."""
|
||||||
if not patch:
|
if not patch:
|
||||||
|
|||||||
@@ -172,7 +172,8 @@ def get_video_info(video_path: str) -> Dict[str, Any]:
|
|||||||
def concat_videos(
|
def concat_videos(
|
||||||
video_paths: List[str],
|
video_paths: List[str],
|
||||||
output_path: str,
|
output_path: str,
|
||||||
target_size: Tuple[int, int] = (1080, 1920)
|
target_size: Tuple[int, int] = (1080, 1920),
|
||||||
|
fades: Optional[List[Dict[str, float]]] = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
使用 FFmpeg concat demuxer 拼接多段视频
|
使用 FFmpeg concat demuxer 拼接多段视频
|
||||||
@@ -197,10 +198,72 @@ def concat_videos(
|
|||||||
filter_parts = []
|
filter_parts = []
|
||||||
for i in range(len(video_paths)):
|
for i in range(len(video_paths)):
|
||||||
# scale 保持宽高比,pad 填充黑边居中
|
# scale 保持宽高比,pad 填充黑边居中
|
||||||
filter_parts.append(
|
chain = (
|
||||||
f"[{i}:v]scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
f"[{i}:v]scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
||||||
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[v{i}]"
|
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1"
|
||||||
)
|
)
|
||||||
|
# 可选:片段末尾“火山式转场”(不改时长、不重叠)
|
||||||
|
if fades and i < len(fades):
|
||||||
|
fx = fades[i] or {}
|
||||||
|
fi = float(fx.get("in", 0) or 0.0)
|
||||||
|
fo = float(fx.get("out", 0) or 0.0)
|
||||||
|
t_type = str(fx.get("type") or "")
|
||||||
|
t_dur = float(fx.get("dur") or 0.0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
dur = float(get_video_info(video_paths[i]).get("duration") or 0.0)
|
||||||
|
except Exception:
|
||||||
|
dur = 0.0
|
||||||
|
|
||||||
|
# 基础淡入/淡出
|
||||||
|
if fi > 0:
|
||||||
|
chain += f",fade=t=in:st=0:d={fi}"
|
||||||
|
if fo > 0 and dur > 0:
|
||||||
|
st = max(dur - fo, 0.0)
|
||||||
|
chain += f",fade=t=out:st={st}:d={fo}"
|
||||||
|
|
||||||
|
# 末尾动效(WYSIWYG:前端预览必须与此一致)
|
||||||
|
if t_type and t_dur > 0 and dur > 0:
|
||||||
|
st = max(dur - t_dur, 0.0)
|
||||||
|
td = max(t_dur, 0.001)
|
||||||
|
p = f"if(between(t\\,{st}\\,{dur})\\,(t-{st})/{td}\\,0)"
|
||||||
|
if t_type == "fade":
|
||||||
|
chain += f",fade=t=out:st={st}:d={t_dur}"
|
||||||
|
elif t_type == "fadeWhite":
|
||||||
|
chain += f",fade=t=out:st={st}:d={t_dur}:color=white"
|
||||||
|
elif t_type == "blurOut":
|
||||||
|
chain += f",gblur=sigma='10*{p}':steps=1"
|
||||||
|
elif t_type == "blurFade":
|
||||||
|
chain += f",gblur=sigma='8*{p}':steps=1,fade=t=out:st={st}:d={t_dur}"
|
||||||
|
elif t_type == "flash":
|
||||||
|
chain += f",eq=brightness='0.7*(1-abs(0.5-{p})*2)'"
|
||||||
|
elif t_type == "desaturate":
|
||||||
|
chain += f",hue=s='1-0.9*{p}'"
|
||||||
|
elif t_type == "colorPop":
|
||||||
|
chain += f",hue=s='1+0.8*{p}',eq=contrast='1+0.3*{p}'"
|
||||||
|
elif t_type == "hueShift":
|
||||||
|
chain += f",hue=h='60*{p}'"
|
||||||
|
elif t_type == "darken":
|
||||||
|
chain += f",eq=brightness='-0.4*{p}'"
|
||||||
|
elif t_type in ("slideLeft", "slideRight", "slideUp", "slideDown"):
|
||||||
|
off = 80
|
||||||
|
if t_type == "slideLeft":
|
||||||
|
chain += f",pad={width+off}:{height}:{off/2}-{off}*{p}:0:black,crop={width}:{height}:{off/2}:0"
|
||||||
|
if t_type == "slideRight":
|
||||||
|
chain += f",pad={width+off}:{height}:{off/2}+{off}*{p}:0:black,crop={width}:{height}:{off/2}:0"
|
||||||
|
if t_type == "slideUp":
|
||||||
|
chain += f",pad={width}:{height+off}:0:{off/2}-{off}*{p}:black,crop={width}:{height}:0:{off/2}"
|
||||||
|
if t_type == "slideDown":
|
||||||
|
chain += f",pad={width}:{height+off}:0:{off/2}+{off}*{p}:black,crop={width}:{height}:0:{off/2}"
|
||||||
|
elif t_type in ("zoomOut", "zoomIn"):
|
||||||
|
if t_type == "zoomOut":
|
||||||
|
chain += f",scale=w='{width}*(1-0.10*{p})':h='{height}*(1-0.10*{p})':eval=frame,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black"
|
||||||
|
else:
|
||||||
|
chain += f",scale=w='{width}*(1+0.10*{p})':h='{height}*(1+0.10*{p})':eval=frame,crop={width}:{height}"
|
||||||
|
elif t_type == "rotateOut":
|
||||||
|
chain += f",rotate=a='0.12*{p}':c=black@1:ow={width}:oh={height}"
|
||||||
|
chain += f"[v{i}]"
|
||||||
|
filter_parts.append(chain)
|
||||||
|
|
||||||
# 拼接所有视频流
|
# 拼接所有视频流
|
||||||
concat_inputs = "".join([f"[v{i}]" for i in range(len(video_paths))])
|
concat_inputs = "".join([f"[v{i}]" for i in range(len(video_paths))])
|
||||||
@@ -232,7 +295,8 @@ def concat_videos(
|
|||||||
def concat_videos_with_audio(
|
def concat_videos_with_audio(
|
||||||
video_paths: List[str],
|
video_paths: List[str],
|
||||||
output_path: str,
|
output_path: str,
|
||||||
target_size: Tuple[int, int] = (1080, 1920)
|
target_size: Tuple[int, int] = (1080, 1920),
|
||||||
|
fades: Optional[List[Dict[str, float]]] = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
拼接视频并保留音频轨道
|
拼接视频并保留音频轨道
|
||||||
@@ -250,10 +314,70 @@ def concat_videos_with_audio(
|
|||||||
|
|
||||||
# 视频处理
|
# 视频处理
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
filter_parts.append(
|
chain = (
|
||||||
f"[{i}:v]scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
f"[{i}:v]scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
||||||
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[v{i}]"
|
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1"
|
||||||
)
|
)
|
||||||
|
# 可选:片段末尾“火山式转场”(不改时长、不重叠)
|
||||||
|
if fades and i < len(fades):
|
||||||
|
fx = fades[i] or {}
|
||||||
|
fi = float(fx.get("in", 0) or 0.0)
|
||||||
|
fo = float(fx.get("out", 0) or 0.0)
|
||||||
|
t_type = str(fx.get("type") or "")
|
||||||
|
t_dur = float(fx.get("dur") or 0.0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
dur = float(get_video_info(video_paths[i]).get("duration") or 0.0)
|
||||||
|
except Exception:
|
||||||
|
dur = 0.0
|
||||||
|
|
||||||
|
if fi > 0:
|
||||||
|
chain += f",fade=t=in:st=0:d={fi}"
|
||||||
|
if fo > 0 and dur > 0:
|
||||||
|
st = max(dur - fo, 0.0)
|
||||||
|
chain += f",fade=t=out:st={st}:d={fo}"
|
||||||
|
|
||||||
|
if t_type and t_dur > 0 and dur > 0:
|
||||||
|
st = max(dur - t_dur, 0.0)
|
||||||
|
td = max(t_dur, 0.001)
|
||||||
|
p = f"if(between(t\\,{st}\\,{dur})\\,(t-{st})/{td}\\,0)"
|
||||||
|
if t_type == "fade":
|
||||||
|
chain += f",fade=t=out:st={st}:d={t_dur}"
|
||||||
|
elif t_type == "fadeWhite":
|
||||||
|
chain += f",fade=t=out:st={st}:d={t_dur}:color=white"
|
||||||
|
elif t_type == "blurOut":
|
||||||
|
chain += f",gblur=sigma='10*{p}':steps=1"
|
||||||
|
elif t_type == "blurFade":
|
||||||
|
chain += f",gblur=sigma='8*{p}':steps=1,fade=t=out:st={st}:d={t_dur}"
|
||||||
|
elif t_type == "flash":
|
||||||
|
chain += f",eq=brightness='0.7*(1-abs(0.5-{p})*2)'"
|
||||||
|
elif t_type == "desaturate":
|
||||||
|
chain += f",hue=s='1-0.9*{p}'"
|
||||||
|
elif t_type == "colorPop":
|
||||||
|
chain += f",hue=s='1+0.8*{p}',eq=contrast='1+0.3*{p}'"
|
||||||
|
elif t_type == "hueShift":
|
||||||
|
chain += f",hue=h='60*{p}'"
|
||||||
|
elif t_type == "darken":
|
||||||
|
chain += f",eq=brightness='-0.4*{p}'"
|
||||||
|
elif t_type in ("slideLeft", "slideRight", "slideUp", "slideDown"):
|
||||||
|
off = 80
|
||||||
|
if t_type == "slideLeft":
|
||||||
|
chain += f",pad={width+off}:{height}:{off/2}-{off}*{p}:0:black,crop={width}:{height}:{off/2}:0"
|
||||||
|
if t_type == "slideRight":
|
||||||
|
chain += f",pad={width+off}:{height}:{off/2}+{off}*{p}:0:black,crop={width}:{height}:{off/2}:0"
|
||||||
|
if t_type == "slideUp":
|
||||||
|
chain += f",pad={width}:{height+off}:0:{off/2}-{off}*{p}:black,crop={width}:{height}:0:{off/2}"
|
||||||
|
if t_type == "slideDown":
|
||||||
|
chain += f",pad={width}:{height+off}:0:{off/2}+{off}*{p}:black,crop={width}:{height}:0:{off/2}"
|
||||||
|
elif t_type in ("zoomOut", "zoomIn"):
|
||||||
|
if t_type == "zoomOut":
|
||||||
|
chain += f",scale=w='{width}*(1-0.10*{p})':h='{height}*(1-0.10*{p})':eval=frame,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black"
|
||||||
|
else:
|
||||||
|
chain += f",scale=w='{width}*(1+0.10*{p})':h='{height}*(1+0.10*{p})':eval=frame,crop={width}:{height}"
|
||||||
|
elif t_type == "rotateOut":
|
||||||
|
chain += f",rotate=a='0.12*{p}':c=black@1:ow={width}:oh={height}"
|
||||||
|
chain += f"[v{i}]"
|
||||||
|
filter_parts.append(chain)
|
||||||
|
|
||||||
# 音频处理(静音填充如果没有音频)
|
# 音频处理(静音填充如果没有音频)
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
@@ -469,6 +593,127 @@ def adjust_audio_duration(
|
|||||||
return output_path
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def _atempo_chain(speed: float) -> str:
|
||||||
|
"""
|
||||||
|
构造 atempo 链,支持 <0.5 或 >2.0 的倍速(通过链式 atempo)。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
s = float(speed)
|
||||||
|
except Exception:
|
||||||
|
s = 1.0
|
||||||
|
if s <= 0:
|
||||||
|
s = 1.0
|
||||||
|
parts = []
|
||||||
|
# atempo 支持 0.5~2.0
|
||||||
|
while s > 2.0:
|
||||||
|
parts.append("atempo=2.0")
|
||||||
|
s /= 2.0
|
||||||
|
while s < 0.5:
|
||||||
|
parts.append("atempo=0.5")
|
||||||
|
s /= 0.5
|
||||||
|
parts.append(f"atempo={s}")
|
||||||
|
return ",".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def change_audio_speed(input_path: str, speed: float, output_path: str) -> str:
|
||||||
|
"""改变音频播放倍速(纯播放倍速)。"""
|
||||||
|
if not os.path.exists(input_path):
|
||||||
|
return None
|
||||||
|
af = _atempo_chain(speed)
|
||||||
|
cmd = [FFMPEG_PATH, "-y", "-i", input_path, "-filter:a", af, output_path]
|
||||||
|
_run_ffmpeg(cmd)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def fit_audio_to_duration_by_speed(input_path: str, target_duration: float, output_path: str) -> str:
|
||||||
|
"""
|
||||||
|
通过“改变播放倍速”来贴合目标时长(可快可慢),并裁剪/补齐到严格时长。
|
||||||
|
适用于旁白:用户拉伸片段期望语速变化,而不是静音补齐。
|
||||||
|
"""
|
||||||
|
if not os.path.exists(input_path):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
td = float(target_duration or 0)
|
||||||
|
except Exception:
|
||||||
|
td = 0.0
|
||||||
|
if td <= 0:
|
||||||
|
import shutil
|
||||||
|
shutil.copy(input_path, output_path)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
cur = float(get_audio_info(input_path).get("duration") or 0.0)
|
||||||
|
if cur <= 0:
|
||||||
|
import shutil
|
||||||
|
shutil.copy(input_path, output_path)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
speed = cur / td
|
||||||
|
af_speed = _atempo_chain(speed)
|
||||||
|
# 贴合后仍做一次 atrim+apad 保证严格时长(避免累计误差)
|
||||||
|
af = f"{af_speed},atrim=0:{td},apad=pad_dur=0,atrim=0:{td}"
|
||||||
|
cmd = [FFMPEG_PATH, "-y", "-i", input_path, "-filter:a", af, output_path]
|
||||||
|
_run_ffmpeg(cmd)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def force_audio_duration(input_path: str, target_duration: float, output_path: str) -> str:
|
||||||
|
"""不改变倍速,仅裁剪/补齐到严格时长(用于倍速已在上游完成的场景)。"""
|
||||||
|
if not os.path.exists(input_path):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
td = float(target_duration or 0)
|
||||||
|
except Exception:
|
||||||
|
td = 0.0
|
||||||
|
if td <= 0:
|
||||||
|
import shutil
|
||||||
|
shutil.copy(input_path, output_path)
|
||||||
|
return output_path
|
||||||
|
af = f"atrim=0:{td},apad=pad_dur=0,atrim=0:{td}"
|
||||||
|
cmd = [FFMPEG_PATH, "-y", "-i", input_path, "-filter:a", af, output_path]
|
||||||
|
_run_ffmpeg(cmd)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def _which(cmd: str) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
return shutil.which(cmd)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_sticker_to_png(input_path: str, output_path: str) -> str:
|
||||||
|
"""
|
||||||
|
将贴纸规范化为 PNG(用于 ffmpeg overlay)。
|
||||||
|
- PNG/WEBP:直接返回原图或拷贝
|
||||||
|
- SVG:优先用 rsvg-convert 转 PNG;否则尝试 ffmpeg 直接解码
|
||||||
|
"""
|
||||||
|
if not input_path or not os.path.exists(input_path):
|
||||||
|
return None
|
||||||
|
ext = Path(input_path).suffix.lower()
|
||||||
|
if ext in [".png"]:
|
||||||
|
return input_path
|
||||||
|
if ext in [".webp"]:
|
||||||
|
# 转 PNG,避免某些 ffmpeg build 对 webp 支持不一致
|
||||||
|
cmd = [FFMPEG_PATH, "-y", "-i", input_path, output_path]
|
||||||
|
_run_ffmpeg(cmd)
|
||||||
|
return output_path
|
||||||
|
if ext == ".svg":
|
||||||
|
rsvg = _which("rsvg-convert")
|
||||||
|
if rsvg:
|
||||||
|
import subprocess
|
||||||
|
subprocess.check_call([rsvg, "-o", output_path, input_path])
|
||||||
|
return output_path
|
||||||
|
# fallback: ffmpeg decode svg(依赖 build)
|
||||||
|
cmd = [FFMPEG_PATH, "-y", "-i", input_path, output_path]
|
||||||
|
_run_ffmpeg(cmd)
|
||||||
|
return output_path
|
||||||
|
# 其他格式:尽量用 ffmpeg 转
|
||||||
|
cmd = [FFMPEG_PATH, "-y", "-i", input_path, output_path]
|
||||||
|
_run_ffmpeg(cmd)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
def get_audio_info(file_path: str) -> Dict[str, Any]:
|
def get_audio_info(file_path: str) -> Dict[str, Any]:
|
||||||
"""获取音频信息"""
|
"""获取音频信息"""
|
||||||
return get_video_info(file_path)
|
return get_video_info(file_path)
|
||||||
@@ -830,6 +1075,12 @@ def add_bgm(
|
|||||||
loop: bool = True,
|
loop: bool = True,
|
||||||
ducking: bool = True,
|
ducking: bool = True,
|
||||||
duck_gain_db: float = -6.0,
|
duck_gain_db: float = -6.0,
|
||||||
|
# 新增:按时间段闪避(更可控,和旁白时间轴严格对齐)
|
||||||
|
duck_volume: float = 0.25,
|
||||||
|
duck_ranges: Optional[List[Tuple[float, float]]] = None,
|
||||||
|
# 新增:BGM 片段可有起点/时长(不强制从 0 覆盖整段视频)
|
||||||
|
start_time: float = 0.0,
|
||||||
|
clip_duration: Optional[float] = None,
|
||||||
fade_in: float = 1.0,
|
fade_in: float = 1.0,
|
||||||
fade_out: float = 1.0
|
fade_out: float = 1.0
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -856,30 +1107,46 @@ def add_bgm(
|
|||||||
info = get_video_info(video_path)
|
info = get_video_info(video_path)
|
||||||
video_duration = info["duration"]
|
video_duration = info["duration"]
|
||||||
|
|
||||||
if loop:
|
# 片段时长:默认覆盖整段视频
|
||||||
bgm_chain = (
|
dur = float(clip_duration) if (clip_duration is not None and float(clip_duration) > 0) else float(video_duration)
|
||||||
f"[1:a]aloop=-1:size=2e+09,asetpts=N/SR/TB,"
|
st = max(0.0, float(start_time or 0.0))
|
||||||
f"atrim=0:{video_duration},"
|
end_for_fade = max(dur - float(fade_out or 0.0), 0.0)
|
||||||
f"afade=t=in:st=0:d={fade_in},"
|
|
||||||
f"afade=t=out:st={max(video_duration - fade_out, 0)}:d={fade_out},"
|
|
||||||
f"volume={bgm_volume}[bgm]"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
bgm_chain = (
|
|
||||||
f"[1:a]"
|
|
||||||
f"afade=t=in:st=0:d={fade_in},"
|
|
||||||
f"afade=t=out:st={max(video_duration - fade_out, 0)}:d={fade_out},"
|
|
||||||
f"volume={bgm_volume}[bgm]"
|
|
||||||
)
|
|
||||||
|
|
||||||
if ducking:
|
# 基础链:loop/trim -> fades -> base volume
|
||||||
# 使用安全参数的 sidechaincompress,避免 unsupported 参数
|
if loop:
|
||||||
|
bgm_chain = f"[1:a]aloop=-1:size=2e+09,asetpts=N/SR/TB,atrim=0:{dur}"
|
||||||
|
else:
|
||||||
|
bgm_chain = f"[1:a]atrim=0:{dur}"
|
||||||
|
|
||||||
|
bgm_chain += f",afade=t=in:st=0:d={float(fade_in or 0.0)},afade=t=out:st={end_for_fade}:d={float(fade_out or 0.0)},volume={bgm_volume}"
|
||||||
|
|
||||||
|
# 延迟到 start_time
|
||||||
|
if st > 1e-6:
|
||||||
|
ms = int(st * 1000)
|
||||||
|
bgm_chain += f",adelay={ms}|{ms}"
|
||||||
|
|
||||||
|
# 闪避(按时间段)
|
||||||
|
# 注意:使用 enable 让 filter 只在区间内生效(外部直接 passthrough)
|
||||||
|
if ducking and duck_ranges:
|
||||||
|
dv = max(0.05, min(1.0, float(duck_volume or 0.25)))
|
||||||
|
for (rs, re) in duck_ranges:
|
||||||
|
rsf = max(0.0, float(rs))
|
||||||
|
ref = max(rsf, float(re))
|
||||||
|
bgm_chain += f",volume={dv}:enable='between(t,{rsf},{ref})'"
|
||||||
|
|
||||||
|
bgm_chain += "[bgm]"
|
||||||
|
|
||||||
|
# 如果提供了 duck_ranges,就用确定性的 amix(ducking 已在 bgm_chain 内完成)
|
||||||
|
if ducking and duck_ranges:
|
||||||
|
filter_complex = f"{bgm_chain};[0:a][bgm]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[outa]"
|
||||||
|
elif ducking:
|
||||||
|
# 否则退回 sidechaincompress(对原视频音频进行侧链压缩)
|
||||||
filter_complex = (
|
filter_complex = (
|
||||||
f"{bgm_chain};"
|
f"{bgm_chain};"
|
||||||
f"[0:a][bgm]sidechaincompress=threshold=0.1:ratio=4:attack=5:release=250:makeup=1:mix=1:level_in=1:level_sc=1[outa]"
|
f"[0:a][bgm]sidechaincompress=threshold=0.1:ratio=4:attack=5:release=250:makeup=1:mix=1:level_in=1:level_sc=1[outa]"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
filter_complex = f"{bgm_chain};[0:a][bgm]amix=inputs=2:duration=first[outa]"
|
filter_complex = f"{bgm_chain};[0:a][bgm]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[outa]"
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
FFMPEG_PATH, "-y",
|
FFMPEG_PATH, "-y",
|
||||||
|
|||||||
@@ -246,3 +246,9 @@ def normalize_legacy_project(doc: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from typing import Optional, Tuple
|
|||||||
|
|
||||||
LEGACY_HOST_TEMP_PREFIX = "/root/video-flow/temp/"
|
LEGACY_HOST_TEMP_PREFIX = "/root/video-flow/temp/"
|
||||||
LEGACY_HOST_OUTPUT_PREFIX = "/root/video-flow/output/"
|
LEGACY_HOST_OUTPUT_PREFIX = "/root/video-flow/output/"
|
||||||
|
LEGACY_HOST_PREFIX = "/root/video-flow/"
|
||||||
|
|
||||||
# Container mount points (see docker-compose.yml)
|
# Container mount points (see docker-compose.yml)
|
||||||
LEGACY_CONTAINER_TEMP_DIR = "/legacy/temp"
|
LEGACY_CONTAINER_TEMP_DIR = "/legacy/temp"
|
||||||
@@ -42,18 +43,27 @@ def map_legacy_local_path(local_path: Optional[str]) -> Tuple[Optional[str], Opt
|
|||||||
if os.path.exists(local_path):
|
if os.path.exists(local_path):
|
||||||
return local_path, None
|
return local_path, None
|
||||||
|
|
||||||
# Legacy host -> container mapping by basename
|
# Legacy host path -> current container workspace path (same repo but different prefix)
|
||||||
|
# Example:
|
||||||
|
# /root/video-flow/temp/projects/... -> /app/temp/projects/...
|
||||||
|
# This covers cases where we don't mount /legacy/* but the files were copied into current stack.
|
||||||
|
if local_path.startswith(LEGACY_HOST_PREFIX):
|
||||||
|
rest = local_path[len(LEGACY_HOST_PREFIX):].lstrip("/")
|
||||||
|
candidate = str(Path("/app") / rest)
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
return candidate, None
|
||||||
|
|
||||||
|
# Legacy host -> container mapping (preserve relative path)
|
||||||
if local_path.startswith(LEGACY_HOST_TEMP_PREFIX):
|
if local_path.startswith(LEGACY_HOST_TEMP_PREFIX):
|
||||||
name = Path(local_path).name
|
rel = local_path[len(LEGACY_HOST_TEMP_PREFIX):].lstrip("/")
|
||||||
container_path = str(Path(LEGACY_CONTAINER_TEMP_DIR) / name)
|
container_path = str(Path(LEGACY_CONTAINER_TEMP_DIR) / rel)
|
||||||
url = f"{LEGACY_STATIC_TEMP_PREFIX}{name}"
|
# 静态路由通常只覆盖目录根(不包含子目录);这里交给 /api/assets/file 做 FileResponse 更稳
|
||||||
return container_path, url
|
return container_path, None
|
||||||
|
|
||||||
if local_path.startswith(LEGACY_HOST_OUTPUT_PREFIX):
|
if local_path.startswith(LEGACY_HOST_OUTPUT_PREFIX):
|
||||||
name = Path(local_path).name
|
rel = local_path[len(LEGACY_HOST_OUTPUT_PREFIX):].lstrip("/")
|
||||||
container_path = str(Path(LEGACY_CONTAINER_OUTPUT_DIR) / name)
|
container_path = str(Path(LEGACY_CONTAINER_OUTPUT_DIR) / rel)
|
||||||
url = f"{LEGACY_STATIC_OUTPUT_PREFIX}{name}"
|
return container_path, None
|
||||||
return container_path, url
|
|
||||||
|
|
||||||
# Unknown path: keep as-is
|
# Unknown path: keep as-is
|
||||||
return local_path, None
|
return local_path, None
|
||||||
@@ -64,3 +74,9 @@ def map_legacy_local_path(local_path: Optional[str]) -> Tuple[Optional[str], Opt
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
102
modules/preview_proxy.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
Generate and cache low-bitrate preview proxies for browser playback.
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
- Improve Remotion Player preview smoothness by serving smaller/faster-to-decode videos.
|
||||||
|
- Keep original `source_path` for accurate export; only swap `source_url` for preview.
|
||||||
|
|
||||||
|
Design:
|
||||||
|
- Deterministic cache key based on (path, mtime, size).
|
||||||
|
- Proxy lives under config.TEMP_DIR / "proxy".
|
||||||
|
- Generated with ffmpeg: scale/pad + fps downsample + faststart + no audio.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import config
|
||||||
|
from modules import ffmpeg_utils
|
||||||
|
|
||||||
|
|
||||||
|
def _key_for_file(path: str) -> str:
|
||||||
|
st = os.stat(path)
|
||||||
|
raw = f"{path}|{st.st_mtime_ns}|{st.st_size}".encode("utf-8")
|
||||||
|
return hashlib.sha1(raw).hexdigest() # short, deterministic
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_video_proxy(
|
||||||
|
source_path: str,
|
||||||
|
*,
|
||||||
|
target_w: int = 540,
|
||||||
|
target_h: int = 960,
|
||||||
|
target_fps: int = 24,
|
||||||
|
crf: int = 28,
|
||||||
|
preset: str = "veryfast",
|
||||||
|
) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Ensure a preview proxy exists for source_path.
|
||||||
|
|
||||||
|
Returns: (proxy_path, proxy_url) or (None, None) if source_path invalid.
|
||||||
|
"""
|
||||||
|
if not source_path or not os.path.exists(source_path):
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
proxy_dir = Path(config.TEMP_DIR) / "proxy"
|
||||||
|
proxy_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
key = _key_for_file(source_path)
|
||||||
|
out_name = f"proxy_{key}.mp4"
|
||||||
|
out_path = proxy_dir / out_name
|
||||||
|
|
||||||
|
if out_path.exists() and out_path.stat().st_size > 1024:
|
||||||
|
return str(out_path), f"/static/temp/proxy/{out_name}"
|
||||||
|
|
||||||
|
vf = (
|
||||||
|
f"scale={target_w}:{target_h}:force_original_aspect_ratio=decrease,"
|
||||||
|
f"pad={target_w}:{target_h}:(ow-iw)/2:(oh-ih)/2:black,"
|
||||||
|
f"fps={target_fps}"
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
ffmpeg_utils.FFMPEG_PATH,
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
source_path,
|
||||||
|
"-an", # preview 不要音轨,减少解码负担(旁白/BGM 走单独轨道)
|
||||||
|
"-vf",
|
||||||
|
vf,
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
preset,
|
||||||
|
"-crf",
|
||||||
|
str(crf),
|
||||||
|
"-tune",
|
||||||
|
"fastdecode",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
str(out_path),
|
||||||
|
]
|
||||||
|
|
||||||
|
ffmpeg_utils._run_ffmpeg(cmd)
|
||||||
|
if out_path.exists() and out_path.stat().st_size > 1024:
|
||||||
|
return str(out_path), f"/static/temp/proxy/{out_name}"
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import requests
|
import requests
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -27,7 +28,10 @@ class ScriptGenerator:
|
|||||||
# OpenAI-compatible client for ShuBiaoBiao (supports multiple models incl. GPT)
|
# OpenAI-compatible client for ShuBiaoBiao (supports multiple models incl. GPT)
|
||||||
self.shubiaobiao_client = OpenAI(
|
self.shubiaobiao_client = OpenAI(
|
||||||
api_key=config.SHUBIAOBIAO_KEY,
|
api_key=config.SHUBIAOBIAO_KEY,
|
||||||
base_url=config.SHUBIAOBIAO_BASE_URL
|
base_url=config.SHUBIAOBIAO_BASE_URL,
|
||||||
|
# IMPORTANT: OpenAI SDK default timeout is 10 minutes; cap it to keep UX responsive.
|
||||||
|
timeout=config.SHUBIAOBIAO_CHAT_TIMEOUT_S,
|
||||||
|
max_retries=config.SHUBIAOBIAO_CHAT_MAX_RETRIES,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default System Prompt
|
# Default System Prompt
|
||||||
@@ -139,15 +143,23 @@ class ScriptGenerator:
|
|||||||
product_name: str,
|
product_name: str,
|
||||||
product_info: Dict[str, Any],
|
product_info: Dict[str, Any],
|
||||||
image_paths: List[str] = None,
|
image_paths: List[str] = None,
|
||||||
model_provider: str = "shubiaobiao" # "shubiaobiao" or "doubao"
|
model_provider: str = "shubiaobiao", # "shubiaobiao" or "doubao"
|
||||||
|
user_id: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
生成分镜脚本
|
生成分镜脚本
|
||||||
"""
|
"""
|
||||||
logger.info(f"Generating script for: {product_name} (Provider: {model_provider})")
|
logger.info(f"Generating script for: {product_name} (Provider: {model_provider})")
|
||||||
|
|
||||||
# 1. 构造 Prompt (优先从数据库读取配置)
|
# 1. 构造 Prompt (优先按 user_id 读取;否则回退到全局配置,再回退默认)
|
||||||
system_prompt = db.get_config("prompt_script_gen", self.default_system_prompt)
|
system_prompt = None
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
system_prompt = db.get_user_prompt(user_id, "prompt_script_gen")
|
||||||
|
except Exception:
|
||||||
|
system_prompt = None
|
||||||
|
if not system_prompt:
|
||||||
|
system_prompt = db.get_config("prompt_script_gen", self.default_system_prompt)
|
||||||
user_prompt = self._build_user_prompt(product_name, product_info)
|
user_prompt = self._build_user_prompt(product_name, product_info)
|
||||||
|
|
||||||
# Branch for Doubao (Volcengine)
|
# Branch for Doubao (Volcengine)
|
||||||
@@ -293,21 +305,40 @@ class ScriptGenerator:
|
|||||||
ShuBiaoBiao OpenAI-compatible multimodal chat.
|
ShuBiaoBiao OpenAI-compatible multimodal chat.
|
||||||
IMPORTANT: For ShuBiaoBiao models, we pass image URLs (R2 public URLs), not base64.
|
IMPORTANT: For ShuBiaoBiao models, we pass image URLs (R2 public URLs), not base64.
|
||||||
"""
|
"""
|
||||||
|
t0 = time.time()
|
||||||
|
# Use WARNING level so it shows up even if Streamlit/root logger is not configured to INFO.
|
||||||
|
logger.warning(
|
||||||
|
f"[script_gen] start shubiaobiao chat model={model_name} images={len(image_paths or [])} "
|
||||||
|
f"timeout_s={getattr(config, 'SHUBIAOBIAO_CHAT_TIMEOUT_S', 'n/a')} "
|
||||||
|
f"max_retries={getattr(config, 'SHUBIAOBIAO_CHAT_MAX_RETRIES', 'n/a')}"
|
||||||
|
)
|
||||||
messages = [{"role": "system", "content": system_prompt}]
|
messages = [{"role": "system", "content": system_prompt}]
|
||||||
user_content: List[Dict[str, Any]] = []
|
user_content: List[Dict[str, Any]] = []
|
||||||
# Images first (URL), then text
|
# Images first (URL), then text
|
||||||
|
t_upload0 = time.time()
|
||||||
urls = self._upload_images_to_r2(image_paths or [], limit=10)
|
urls = self._upload_images_to_r2(image_paths or [], limit=10)
|
||||||
|
logger.warning(
|
||||||
|
f"[script_gen] r2_upload done urls={len(urls)} elapsed_s={time.time() - t_upload0:.2f}"
|
||||||
|
)
|
||||||
for url in urls:
|
for url in urls:
|
||||||
user_content.append({"type": "image_url", "image_url": {"url": url}})
|
user_content.append({"type": "image_url", "image_url": {"url": url}})
|
||||||
user_content.append({"type": "text", "text": user_prompt})
|
user_content.append({"type": "text", "text": user_prompt})
|
||||||
messages.append({"role": "user", "content": user_content})
|
messages.append({"role": "user", "content": user_content})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = self.shubiaobiao_client.chat.completions.create(
|
client = self.shubiaobiao_client.with_options(
|
||||||
|
timeout=config.SHUBIAOBIAO_CHAT_TIMEOUT_S,
|
||||||
|
max_retries=config.SHUBIAOBIAO_CHAT_MAX_RETRIES,
|
||||||
|
)
|
||||||
|
t_call0 = time.time()
|
||||||
|
resp = client.chat.completions.create(
|
||||||
model=model_name,
|
model=model_name,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=0.7,
|
temperature=0.7,
|
||||||
)
|
)
|
||||||
|
logger.warning(
|
||||||
|
f"[script_gen] shubiaobiao chat done elapsed_s={time.time() - t_call0:.2f} total_s={time.time() - t0:.2f}"
|
||||||
|
)
|
||||||
content_text = (resp.choices[0].message.content or "").strip()
|
content_text = (resp.choices[0].message.content or "").strip()
|
||||||
script_json = self._extract_json_from_response(content_text)
|
script_json = self._extract_json_from_response(content_text)
|
||||||
if script_json is None:
|
if script_json is None:
|
||||||
@@ -323,7 +354,9 @@ class ScriptGenerator:
|
|||||||
}
|
}
|
||||||
return final_script
|
return final_script
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"shubiaobiao script generation failed ({model_name}): {e}")
|
logger.error(
|
||||||
|
f"shubiaobiao script generation failed ({model_name}) after {time.time() - t0:.2f}s: {e}"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _postprocess_selling_points(self, product_info: Dict[str, Any], selling_points: Any) -> List[str]:
|
def _postprocess_selling_points(self, product_info: Dict[str, Any], selling_points: Any) -> List[str]:
|
||||||
@@ -582,7 +615,33 @@ class ScriptGenerator:
|
|||||||
|
|
||||||
def _validate_and_fix_script(self, script: Dict[str, Any]) -> Dict[str, Any]:
|
def _validate_and_fix_script(self, script: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""校验并修复脚本结构"""
|
"""校验并修复脚本结构"""
|
||||||
# 简单校验,确保必要字段存在
|
if not isinstance(script, dict):
|
||||||
if "scenes" not in script:
|
return {"scenes": []}
|
||||||
|
|
||||||
|
# Ensure fields exist
|
||||||
|
if "scenes" not in script or not isinstance(script.get("scenes"), list):
|
||||||
script["scenes"] = []
|
script["scenes"] = []
|
||||||
|
|
||||||
|
# Normalize: keep visual_anchor at top-level, but avoid repeating the full anchor in every scene.visual_prompt.
|
||||||
|
# Reason: repeating a long anchor 4-5 times explodes tokens and makes UI look like "only three sections",
|
||||||
|
# while image generation already supports passing visual_anchor separately and prepending it at runtime.
|
||||||
|
visual_anchor = script.get("visual_anchor") or ""
|
||||||
|
if isinstance(visual_anchor, str) and visual_anchor.strip() and script["scenes"]:
|
||||||
|
anchor = visual_anchor.strip()
|
||||||
|
prefix = f"[{anchor}]"
|
||||||
|
for scene in script["scenes"]:
|
||||||
|
if not isinstance(scene, dict):
|
||||||
|
continue
|
||||||
|
vp = scene.get("visual_prompt")
|
||||||
|
if not isinstance(vp, str) or not vp.strip():
|
||||||
|
continue
|
||||||
|
s = vp.strip()
|
||||||
|
# Strip exact "[anchor]" prefix if present
|
||||||
|
if s.startswith(prefix):
|
||||||
|
s = s[len(prefix):].lstrip()
|
||||||
|
# If the model output copied the raw anchor without brackets, strip it too
|
||||||
|
elif s.startswith(anchor):
|
||||||
|
s = s[len(anchor):].lstrip()
|
||||||
|
scene["visual_prompt"] = s
|
||||||
|
|
||||||
return script
|
return script
|
||||||
|
|||||||
@@ -90,6 +90,79 @@ class TextRenderer:
|
|||||||
return color
|
return color
|
||||||
return (0, 0, 0, 255)
|
return (0, 0, 0, 255)
|
||||||
|
|
||||||
|
def _wrap_text_to_width(self, text: str, font: ImageFont.FreeTypeFont, max_width: int) -> str:
|
||||||
|
"""
|
||||||
|
将文本按最大宽度自动换行(支持中英文混排)。
|
||||||
|
- 保留原始换行符为段落边界
|
||||||
|
- 英文优先按空格断词;中文按字符贪心换行
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
mw = int(max_width or 0)
|
||||||
|
except Exception:
|
||||||
|
mw = 0
|
||||||
|
if mw <= 0:
|
||||||
|
return text
|
||||||
|
|
||||||
|
# 兼容:去掉末尾多余空行
|
||||||
|
raw_paras = (text or "").split("\n")
|
||||||
|
out_lines: List[str] = []
|
||||||
|
|
||||||
|
# 1x1 dummy draw 用于测量
|
||||||
|
dummy_draw = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
|
||||||
|
|
||||||
|
def text_w(s: str) -> float:
|
||||||
|
try:
|
||||||
|
return float(dummy_draw.textlength(s, font=font))
|
||||||
|
except Exception:
|
||||||
|
bbox = dummy_draw.textbbox((0, 0), s, font=font)
|
||||||
|
return float((bbox[2] - bbox[0]) if bbox else 0)
|
||||||
|
|
||||||
|
for para in raw_paras:
|
||||||
|
p = (para or "").rstrip()
|
||||||
|
if not p:
|
||||||
|
out_lines.append("")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 英文/混排:尝试按空格分词,否则按字符
|
||||||
|
use_words = (" " in p)
|
||||||
|
tokens = p.split(" ") if use_words else list(p)
|
||||||
|
|
||||||
|
cur = ""
|
||||||
|
for tok in tokens:
|
||||||
|
cand = (cur + (" " if (use_words and cur) else "") + tok) if cur else tok
|
||||||
|
if text_w(cand) <= mw:
|
||||||
|
cur = cand
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 当前行放不下:先落一行
|
||||||
|
if cur:
|
||||||
|
out_lines.append(cur)
|
||||||
|
cur = tok
|
||||||
|
else:
|
||||||
|
# 单 token 超宽:强制按字符拆
|
||||||
|
if use_words:
|
||||||
|
chars = list(tok)
|
||||||
|
else:
|
||||||
|
chars = [tok]
|
||||||
|
buf = ""
|
||||||
|
for ch in chars:
|
||||||
|
cand2 = buf + ch
|
||||||
|
if text_w(cand2) <= mw or not buf:
|
||||||
|
buf = cand2
|
||||||
|
else:
|
||||||
|
out_lines.append(buf)
|
||||||
|
buf = ch
|
||||||
|
cur = buf
|
||||||
|
|
||||||
|
if cur:
|
||||||
|
out_lines.append(cur)
|
||||||
|
|
||||||
|
# 去掉尾部空行(保持中间空行)
|
||||||
|
while out_lines and out_lines[-1] == "":
|
||||||
|
out_lines.pop()
|
||||||
|
|
||||||
|
return "\n".join(out_lines)
|
||||||
|
|
||||||
def render(self, text: str, style: Union[Dict[str, Any], str], cache: bool = True) -> str:
|
def render(self, text: str, style: Union[Dict[str, Any], str], cache: bool = True) -> str:
|
||||||
"""
|
"""
|
||||||
渲染文本并返回图片路径
|
渲染文本并返回图片路径
|
||||||
@@ -122,14 +195,29 @@ class TextRenderer:
|
|||||||
font_size = style.get("font_size", 60)
|
font_size = style.get("font_size", 60)
|
||||||
font = self._get_font(font_path, font_size)
|
font = self._get_font(font_path, font_size)
|
||||||
font_color = self._parse_color(style.get("font_color", "#FFFFFF"))
|
font_color = self._parse_color(style.get("font_color", "#FFFFFF"))
|
||||||
|
bold = bool(style.get("bold", False))
|
||||||
|
italic = bool(style.get("italic", False))
|
||||||
|
underline = bool(style.get("underline", False))
|
||||||
|
|
||||||
# 3. 测量文本尺寸
|
# 3. 自动换行(可选)
|
||||||
|
max_width = style.get("max_width") or style.get("maxWidth") or style.get("text_box_width")
|
||||||
|
try:
|
||||||
|
max_width = int(max_width) if max_width is not None else 0
|
||||||
|
except Exception:
|
||||||
|
max_width = 0
|
||||||
|
if max_width > 0:
|
||||||
|
text = self._wrap_text_to_width(text, font, max_width)
|
||||||
|
|
||||||
|
# 4. 测量文本尺寸(支持多行)
|
||||||
dummy_draw = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
|
dummy_draw = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
|
||||||
bbox = dummy_draw.textbbox((0, 0), text, font=font)
|
try:
|
||||||
text_w = bbox[2] - bbox[0]
|
bbox = dummy_draw.multiline_textbbox((0, 0), text, font=font, spacing=int(font_size * 0.25), align="center")
|
||||||
text_h = bbox[3] - bbox[1]
|
except Exception:
|
||||||
|
bbox = dummy_draw.textbbox((0, 0), text, font=font)
|
||||||
|
text_w = (bbox[2] - bbox[0]) if bbox else 0
|
||||||
|
text_h = (bbox[3] - bbox[1]) if bbox else 0
|
||||||
|
|
||||||
# 4. 计算总尺寸 (包含 padding, stroke, shadow)
|
# 5. 计算总尺寸 (包含 padding, stroke, shadow)
|
||||||
strokes = style.get("stroke", [])
|
strokes = style.get("stroke", [])
|
||||||
if isinstance(strokes, dict): strokes = [strokes] # 兼容旧格式
|
if isinstance(strokes, dict): strokes = [strokes] # 兼容旧格式
|
||||||
|
|
||||||
@@ -156,7 +244,7 @@ class TextRenderer:
|
|||||||
canvas_w = content_w + extra_margin * 2
|
canvas_w = content_w + extra_margin * 2
|
||||||
canvas_h = content_h + extra_margin * 2
|
canvas_h = content_h + extra_margin * 2
|
||||||
|
|
||||||
# 5. 创建画布
|
# 6. 创建画布
|
||||||
img = Image.new("RGBA", (int(canvas_w), int(canvas_h)), (0, 0, 0, 0))
|
img = Image.new("RGBA", (int(canvas_w), int(canvas_h)), (0, 0, 0, 0))
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
@@ -164,7 +252,7 @@ class TextRenderer:
|
|||||||
center_x = canvas_w // 2
|
center_x = canvas_w // 2
|
||||||
center_y = canvas_h // 2
|
center_y = canvas_h // 2
|
||||||
|
|
||||||
# 6. 绘制顺序: 阴影 -> 背景 -> 描边 -> 文本
|
# 7. 绘制顺序: 阴影 -> 背景 -> 描边 -> 文本
|
||||||
|
|
||||||
# --- 绘制阴影 (针对整个块) ---
|
# --- 绘制阴影 (针对整个块) ---
|
||||||
if shadow:
|
if shadow:
|
||||||
@@ -183,7 +271,11 @@ class TextRenderer:
|
|||||||
# 文字阴影
|
# 文字阴影
|
||||||
txt_x = center_x - text_w / 2
|
txt_x = center_x - text_w / 2
|
||||||
txt_y = center_y - text_h / 2
|
txt_y = center_y - text_h / 2
|
||||||
shadow_draw.text((txt_x, txt_y), text, font=font, fill=shadow_color)
|
# 多行阴影
|
||||||
|
try:
|
||||||
|
shadow_draw.multiline_text((txt_x, txt_y), text, font=font, fill=shadow_color, spacing=int(font_size * 0.25), align="center")
|
||||||
|
except Exception:
|
||||||
|
shadow_draw.text((txt_x, txt_y), text, font=font, fill=shadow_color)
|
||||||
# 描边阴影
|
# 描边阴影
|
||||||
for s in strokes:
|
for s in strokes:
|
||||||
width = s.get("width", 0)
|
width = s.get("width", 0)
|
||||||
@@ -217,10 +309,55 @@ class TextRenderer:
|
|||||||
width = s.get("width", 0)
|
width = s.get("width", 0)
|
||||||
if width > 0:
|
if width > 0:
|
||||||
# 通过偏移模拟描边 (Pillow stroke_width 效果一般,但这里先用原生参数)
|
# 通过偏移模拟描边 (Pillow stroke_width 效果一般,但这里先用原生参数)
|
||||||
draw.text((txt_x, txt_y), text, font=font, fill=color, stroke_width=width, stroke_fill=color)
|
try:
|
||||||
|
draw.multiline_text((txt_x, txt_y), text, font=font, fill=color, spacing=int(font_size * 0.25), align="center", stroke_width=width, stroke_fill=color)
|
||||||
|
except Exception:
|
||||||
|
draw.text((txt_x, txt_y), text, font=font, fill=color, stroke_width=width, stroke_fill=color)
|
||||||
|
|
||||||
# --- 绘制文字 ---
|
# --- 绘制文字 ---
|
||||||
draw.text((txt_x, txt_y), text, font=font, fill=font_color)
|
# italic:通过仿射变换做简单斜体(先绘制到单独图层,再 shear)
|
||||||
|
# bold:通过多次微小偏移叠加模拟加粗(比改 stroke 更接近“字重”)
|
||||||
|
if italic:
|
||||||
|
text_layer = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
||||||
|
text_draw = ImageDraw.Draw(text_layer)
|
||||||
|
if bold:
|
||||||
|
for dx in (0, 1):
|
||||||
|
try:
|
||||||
|
text_draw.multiline_text((txt_x + dx, txt_y), text, font=font, fill=font_color, spacing=int(font_size * 0.25), align="center")
|
||||||
|
except Exception:
|
||||||
|
text_draw.text((txt_x + dx, txt_y), text, font=font, fill=font_color)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
text_draw.multiline_text((txt_x, txt_y), text, font=font, fill=font_color, spacing=int(font_size * 0.25), align="center")
|
||||||
|
except Exception:
|
||||||
|
text_draw.text((txt_x, txt_y), text, font=font, fill=font_color)
|
||||||
|
shear = 0.22 # 经验值:适中倾斜
|
||||||
|
text_layer = text_layer.transform(
|
||||||
|
text_layer.size,
|
||||||
|
Image.AFFINE,
|
||||||
|
(1, shear, 0, 0, 1, 0),
|
||||||
|
resample=Image.BICUBIC
|
||||||
|
)
|
||||||
|
img = Image.alpha_composite(img, text_layer)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
else:
|
||||||
|
if bold:
|
||||||
|
for dx in (0, 1):
|
||||||
|
try:
|
||||||
|
draw.multiline_text((txt_x + dx, txt_y), text, font=font, fill=font_color, spacing=int(font_size * 0.25), align="center")
|
||||||
|
except Exception:
|
||||||
|
draw.text((txt_x + dx, txt_y), text, font=font, fill=font_color)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
draw.multiline_text((txt_x, txt_y), text, font=font, fill=font_color, spacing=int(font_size * 0.25), align="center")
|
||||||
|
except Exception:
|
||||||
|
draw.text((txt_x, txt_y), text, font=font, fill=font_color)
|
||||||
|
|
||||||
|
# underline:在文本底部画线(与字号相关)
|
||||||
|
if underline:
|
||||||
|
line_y = txt_y + text_h + max(2, int(font_size * 0.08))
|
||||||
|
line_th = max(2, int(font_size * 0.06))
|
||||||
|
draw.rectangle([txt_x, line_y, txt_x + text_w, line_y + line_th], fill=font_color)
|
||||||
|
|
||||||
# 7. 裁剪多余透明区域
|
# 7. 裁剪多余透明区域
|
||||||
bbox = img.getbbox()
|
bbox = img.getbbox()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ numpy>=1.24.0
|
|||||||
|
|
||||||
# Web UI (Streamlit - 保留原有调试界面)
|
# Web UI (Streamlit - 保留原有调试界面)
|
||||||
streamlit>=1.29.0
|
streamlit>=1.29.0
|
||||||
|
extra-streamlit-components>=0.1.71
|
||||||
|
|
||||||
# FastAPI Backend (新增前后端分离)
|
# FastAPI Backend (新增前后端分离)
|
||||||
fastapi>=0.109.0
|
fastapi>=0.109.0
|
||||||
|
|||||||
95
scripts/import_stickers_manifest.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
根据 manifest 批量导入贴纸到 assets/stickers_builtin,并生成 index.json。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 scripts/import_stickers_manifest.py --manifest stickers_manifest.json
|
||||||
|
|
||||||
|
manifest 示例:
|
||||||
|
{
|
||||||
|
"pack": {
|
||||||
|
"id": "fluent-emoji-subset",
|
||||||
|
"name": "Fluent Emoji 子集(抖音常用)",
|
||||||
|
"license": "MIT (CHECK BEFORE PROD)",
|
||||||
|
"attribution": "Microsoft Fluent UI Emoji"
|
||||||
|
},
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"id": "douyin-basic",
|
||||||
|
"name": "抖音常用",
|
||||||
|
"items": [
|
||||||
|
{"id": "fire", "name": "火", "url": "https://.../fire.png", "tags": ["火","爆款"]},
|
||||||
|
{"id": "heart", "name": "爱心", "url": "https://.../heart.png", "tags": ["点赞","互动"]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.request import urlopen, Request
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_name(s: str) -> str:
|
||||||
|
return "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in (s or ""))[:80] or "item"
|
||||||
|
|
||||||
|
|
||||||
|
def download(url: str, out: Path) -> None:
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
req = Request(url, headers={"User-Agent": "video-flow-stickers/1.0"})
|
||||||
|
with urlopen(req, timeout=60) as r:
|
||||||
|
out.write_bytes(r.read())
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--manifest", required=True, help="manifest json path")
|
||||||
|
ap.add_argument("--out-dir", default="assets/stickers_builtin", help="output directory")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
manifest_path = Path(args.manifest)
|
||||||
|
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
out_dir = Path(args.out_dir)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
pack = data.get("pack") or {}
|
||||||
|
categories = data.get("categories") or []
|
||||||
|
|
||||||
|
# 下载并重写 file 字段(落地为本地文件)
|
||||||
|
for cat in categories:
|
||||||
|
for it in (cat.get("items") or []):
|
||||||
|
url = str(it.get("url") or "")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
ext = Path(url.split("?")[0]).suffix.lower()
|
||||||
|
if ext not in [".png", ".svg", ".webp"]:
|
||||||
|
ext = ".png"
|
||||||
|
fid = _safe_name(str(it.get("id") or it.get("name") or "item"))
|
||||||
|
fname = f"{fid}{ext}"
|
||||||
|
target = out_dir / fname
|
||||||
|
if not target.exists():
|
||||||
|
print(f"download: {url} -> {target}")
|
||||||
|
download(url, target)
|
||||||
|
it["file"] = fname
|
||||||
|
it.pop("url", None)
|
||||||
|
|
||||||
|
# 输出 index.json
|
||||||
|
out_index = out_dir / "index.json"
|
||||||
|
out_index.write_text(
|
||||||
|
json.dumps({"pack": pack, "categories": categories}, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
print(f"written: {out_index}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
128
scripts/migrate_projects.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
迁移脚本:将旧版 JSON 项目文件导入到 SQLite 数据库(用于 8503 调试)
|
||||||
|
|
||||||
|
关键点:
|
||||||
|
- 不假设 legacy JSON 与当前 Streamlit UI schema 一致
|
||||||
|
- 使用 `modules.legacy_normalizer.normalize_legacy_project()` 做纯规则规范化
|
||||||
|
- 保留 `_legacy`,确保信息不丢失
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 添加项目根目录到路径
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
import config
|
||||||
|
from modules.db_manager import db
|
||||||
|
from modules.legacy_normalizer import normalize_legacy_project
|
||||||
|
|
||||||
|
def migrate_json_projects(temp_dir: str = None, force: bool = False):
|
||||||
|
"""从 temp 目录读取 project_*.json 文件并导入数据库"""
|
||||||
|
|
||||||
|
if temp_dir is None:
|
||||||
|
temp_dir = config.TEMP_DIR
|
||||||
|
|
||||||
|
temp_path = Path(temp_dir)
|
||||||
|
|
||||||
|
if not temp_path.exists():
|
||||||
|
print(f"❌ temp 目录不存在: {temp_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 查找所有项目 JSON 文件
|
||||||
|
json_files = list(temp_path.glob("project_*.json"))
|
||||||
|
|
||||||
|
if not json_files:
|
||||||
|
print(f"⚠️ 未找到项目文件: {temp_path}/project_*.json")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"📂 找到 {len(json_files)} 个项目文件")
|
||||||
|
|
||||||
|
imported = 0
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
for json_file in json_files:
|
||||||
|
try:
|
||||||
|
project_id = json_file.stem.replace("project_", "")
|
||||||
|
|
||||||
|
# 读取 JSON 文件
|
||||||
|
with open(json_file, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# 检查是否已存在
|
||||||
|
existing = db.get_project(project_id)
|
||||||
|
if existing and not force:
|
||||||
|
print(f" ⏭️ 跳过已存在: {project_id}")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 产品信息:用于 Step1 回显与留存
|
||||||
|
# 注意:legacy 的 image_urls 多为远端 URL;当前 Streamlit Step1 使用 uploaded_images(本地路径)。
|
||||||
|
product_info = {
|
||||||
|
"prompt": data.get("prompt", ""),
|
||||||
|
"image_urls": data.get("image_urls", []),
|
||||||
|
"analysis": data.get("analysis", ""),
|
||||||
|
"questions": data.get("questions", []),
|
||||||
|
"answers": data.get("answers", {}),
|
||||||
|
"uploaded_images": [], # legacy 无本地上传图路径
|
||||||
|
"_legacy": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 获取项目名称
|
||||||
|
name = data.get("prompt", "")[:50] if data.get("prompt") else f"项目 {project_id}"
|
||||||
|
|
||||||
|
# 规范化脚本数据:对齐当前 UI schema(并保留 legacy)
|
||||||
|
script_data = normalize_legacy_project(data)
|
||||||
|
|
||||||
|
if existing and force:
|
||||||
|
# 更新现有项目
|
||||||
|
if script_data:
|
||||||
|
db.update_project_script(project_id, script_data)
|
||||||
|
status = data.get("status", "draft")
|
||||||
|
db.update_project_status(project_id, status)
|
||||||
|
print(f" 🔄 更新成功: {project_id} ({name[:30]}...)")
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
# 创建新项目
|
||||||
|
db.create_project(project_id, name, product_info)
|
||||||
|
|
||||||
|
# 更新脚本
|
||||||
|
if script_data:
|
||||||
|
db.update_project_script(project_id, script_data)
|
||||||
|
|
||||||
|
# 更新状态
|
||||||
|
status = data.get("status", "draft")
|
||||||
|
db.update_project_status(project_id, status)
|
||||||
|
|
||||||
|
print(f" ✅ 导入成功: {project_id} ({name[:30]}...)")
|
||||||
|
imported += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ 导入失败 {json_file.name}: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
print(f"\n📊 迁移完成:")
|
||||||
|
print(f" ✅ 新导入: {imported}")
|
||||||
|
print(f" 🔄 已更新: {updated}")
|
||||||
|
print(f" ⏭️ 已跳过: {skipped}")
|
||||||
|
print(f" ❌ 失败: {errors}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="迁移旧版项目到数据库")
|
||||||
|
parser.add_argument("--temp-dir", type=str, default=None,
|
||||||
|
help="temp 目录路径 (默认使用 config.TEMP_DIR)")
|
||||||
|
parser.add_argument("--force", action="store_true",
|
||||||
|
help="强制更新已存在的项目")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print("🚀 开始迁移项目数据...")
|
||||||
|
migrate_json_projects(args.temp_dir, args.force)
|
||||||
30
scripts/migrate_users_and_owner.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
One-off migration:
|
||||||
|
- Ensure admin exists
|
||||||
|
- Backfill projects.owner_user_id to admin for legacy projects
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/migrate_users_and_owner.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Ensure repo root is on sys.path when executed from scripts/ directory
|
||||||
|
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
if REPO_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, REPO_ROOT)
|
||||||
|
|
||||||
|
from modules.db_manager import db
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
admin_id = db.ensure_admin_user("admin", "admin1234")
|
||||||
|
n = db.migrate_projects_owner_to(admin_id)
|
||||||
|
print(f"admin_id={admin_id} backfilled_projects={n}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
|
|
||||||
232
scripts/scan_legacy_schema.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Scan legacy project JSON schemas under temp dir.
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
- Identify schema variants for /opt/gloda-factory/temp/project_*.json
|
||||||
|
- Produce a machine-readable summary + a markdown report
|
||||||
|
|
||||||
|
This script is READ-ONLY.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_load_json(path: Path) -> Dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _type_name(v: Any) -> str:
|
||||||
|
if v is None:
|
||||||
|
return "null"
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return "bool"
|
||||||
|
if isinstance(v, int):
|
||||||
|
return "int"
|
||||||
|
if isinstance(v, float):
|
||||||
|
return "float"
|
||||||
|
if isinstance(v, str):
|
||||||
|
return "str"
|
||||||
|
if isinstance(v, list):
|
||||||
|
return "list"
|
||||||
|
if isinstance(v, dict):
|
||||||
|
return "dict"
|
||||||
|
return type(v).__name__
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_schema_variant(doc: Dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
Heuristic:
|
||||||
|
- Schema_A: scenes contain prompt-like fields (image_prompt/visual_prompt/video_prompt)
|
||||||
|
- Schema_B: scenes do NOT contain these, but contain keyframe/story_beat/camera_movement/image_url
|
||||||
|
"""
|
||||||
|
scenes = doc.get("scenes") or []
|
||||||
|
if not isinstance(scenes, list):
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
prompt_keys = {"image_prompt", "visual_prompt", "video_prompt"}
|
||||||
|
seen_prompt = False
|
||||||
|
for s in scenes:
|
||||||
|
if isinstance(s, dict) and (set(s.keys()) & prompt_keys):
|
||||||
|
seen_prompt = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if seen_prompt:
|
||||||
|
return "Schema_A"
|
||||||
|
|
||||||
|
# If no prompt keys, but has typical B keys, call Schema_B
|
||||||
|
typical_b = {"keyframe", "story_beat", "camera_movement", "image_url"}
|
||||||
|
seen_b = False
|
||||||
|
for s in scenes:
|
||||||
|
if isinstance(s, dict) and (set(s.keys()) & typical_b):
|
||||||
|
seen_b = True
|
||||||
|
break
|
||||||
|
|
||||||
|
return "Schema_B" if seen_b else "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanResult:
|
||||||
|
total_files: int
|
||||||
|
parsed_files: int
|
||||||
|
failed_files: int
|
||||||
|
schema_counts: Counter
|
||||||
|
top_level_key_counts: Counter
|
||||||
|
scene_key_counts: Counter
|
||||||
|
cta_type_counts: Counter
|
||||||
|
sample_by_schema: Dict[str, List[str]]
|
||||||
|
|
||||||
|
|
||||||
|
def scan_dir(temp_dir: Path) -> ScanResult:
|
||||||
|
files = sorted(temp_dir.glob("project_*.json"))
|
||||||
|
schema_counts: Counter = Counter()
|
||||||
|
top_level_key_counts: Counter = Counter()
|
||||||
|
scene_key_counts: Counter = Counter()
|
||||||
|
cta_type_counts: Counter = Counter()
|
||||||
|
sample_by_schema: Dict[str, List[str]] = defaultdict(list)
|
||||||
|
|
||||||
|
parsed = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
doc = _safe_load_json(f)
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
parsed += 1
|
||||||
|
|
||||||
|
schema = _detect_schema_variant(doc)
|
||||||
|
schema_counts[schema] += 1
|
||||||
|
if len(sample_by_schema[schema]) < 5:
|
||||||
|
pid = str(doc.get("id") or f.stem.replace("project_", ""))
|
||||||
|
sample_by_schema[schema].append(pid)
|
||||||
|
|
||||||
|
# top-level keys
|
||||||
|
for k in doc.keys():
|
||||||
|
top_level_key_counts[k] += 1
|
||||||
|
|
||||||
|
# scenes keys
|
||||||
|
scenes = doc.get("scenes") or []
|
||||||
|
if isinstance(scenes, list):
|
||||||
|
for s in scenes:
|
||||||
|
if isinstance(s, dict):
|
||||||
|
for k in s.keys():
|
||||||
|
scene_key_counts[k] += 1
|
||||||
|
|
||||||
|
# cta type
|
||||||
|
cta_type_counts[_type_name(doc.get("cta"))] += 1
|
||||||
|
|
||||||
|
return ScanResult(
|
||||||
|
total_files=len(files),
|
||||||
|
parsed_files=parsed,
|
||||||
|
failed_files=failed,
|
||||||
|
schema_counts=schema_counts,
|
||||||
|
top_level_key_counts=top_level_key_counts,
|
||||||
|
scene_key_counts=scene_key_counts,
|
||||||
|
cta_type_counts=cta_type_counts,
|
||||||
|
sample_by_schema=dict(sample_by_schema),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_jsonable(sr: ScanResult) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"total_files": sr.total_files,
|
||||||
|
"parsed_files": sr.parsed_files,
|
||||||
|
"failed_files": sr.failed_files,
|
||||||
|
"schema_counts": dict(sr.schema_counts),
|
||||||
|
"cta_type_counts": dict(sr.cta_type_counts),
|
||||||
|
"top_level_key_counts": dict(sr.top_level_key_counts),
|
||||||
|
"scene_key_counts": dict(sr.scene_key_counts),
|
||||||
|
"sample_by_schema": sr.sample_by_schema,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_markdown(sr: ScanResult, temp_dir: Path) -> str:
|
||||||
|
lines: List[str] = []
|
||||||
|
lines.append("# Legacy Project JSON Schema Scan Report\n")
|
||||||
|
lines.append(f"- temp_dir: `{temp_dir}`")
|
||||||
|
lines.append(f"- total_files: {sr.total_files}")
|
||||||
|
lines.append(f"- parsed_files: {sr.parsed_files}")
|
||||||
|
lines.append(f"- failed_files: {sr.failed_files}\n")
|
||||||
|
|
||||||
|
lines.append("## Schema variants\n")
|
||||||
|
for k, v in sr.schema_counts.most_common():
|
||||||
|
samples = ", ".join(sr.sample_by_schema.get(k, [])[:5])
|
||||||
|
lines.append(f"- {k}: {v} (samples: {samples})")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("## CTA type distribution\n")
|
||||||
|
for k, v in sr.cta_type_counts.most_common():
|
||||||
|
lines.append(f"- {k}: {v}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
def _topn(counter: Counter, n: int = 30) -> List[Tuple[str, int]]:
|
||||||
|
return counter.most_common(n)
|
||||||
|
|
||||||
|
lines.append("## Top-level keys (top 30)\n")
|
||||||
|
for k, v in _topn(sr.top_level_key_counts, 30):
|
||||||
|
lines.append(f"- {k}: {v}/{sr.parsed_files}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("## Scene keys (top 40)\n")
|
||||||
|
for k, v in _topn(sr.scene_key_counts, 40):
|
||||||
|
lines.append(f"- {k}: {v}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Scan legacy project JSON schemas")
|
||||||
|
parser.add_argument("--temp-dir", required=True, help="Directory containing project_*.json")
|
||||||
|
parser.add_argument("--out-json", required=False, help="Write summary json to path")
|
||||||
|
parser.add_argument("--out-md", required=False, help="Write markdown report to path")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
temp_dir = Path(args.temp_dir)
|
||||||
|
if not temp_dir.exists():
|
||||||
|
raise SystemExit(f"temp dir not found: {temp_dir}")
|
||||||
|
|
||||||
|
sr = scan_dir(temp_dir)
|
||||||
|
payload = _to_jsonable(sr)
|
||||||
|
|
||||||
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
if args.out_json:
|
||||||
|
out_json = Path(args.out_json)
|
||||||
|
out_json.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
if args.out_md:
|
||||||
|
out_md = Path(args.out_md)
|
||||||
|
out_md.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_md.write_text(_render_markdown(sr, temp_dir), encoding="utf-8")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
47
web/Dockerfile
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Video Flow - React 前端 Dockerfile
|
||||||
|
# 多阶段构建
|
||||||
|
|
||||||
|
# 构建阶段
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY package.json package-lock.json* pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 生产阶段
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# 复制构建产物
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# 复制 nginx 配置
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
30
web/index.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Video Flow Editor</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
59
web/nginx.conf
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip 压缩
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||||
|
gzip_min_length 1000;
|
||||||
|
|
||||||
|
# SPA 路由支持
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API 代理
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_read_timeout 300;
|
||||||
|
proxy_connect_timeout 300;
|
||||||
|
proxy_send_timeout 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 静态资源代理(必须用 ^~ 防止被后面的 regex 缓存规则抢走,导致 /static/* 返回 404)
|
||||||
|
location ^~ /static/ {
|
||||||
|
proxy_pass http://api:8000/static/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 缓存前端构建产物(仅 /assets/,避免影响 /static/ 代理)
|
||||||
|
location ~* ^/assets/.*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
38
web/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "video-flow-editor",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.21.0",
|
||||||
|
"@tanstack/react-query": "^5.17.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"zustand": "^4.4.0",
|
||||||
|
"immer": "^10.0.0",
|
||||||
|
"lucide-react": "^0.303.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"tailwind-merge": "^2.2.0",
|
||||||
|
"remotion": "^4.0.0",
|
||||||
|
"@remotion/player": "^4.0.0",
|
||||||
|
"@remotion/cli": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.0.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
20
web/postcss.config.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
31
web/src/App.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { EditorPage } from './pages/EditorPage'
|
||||||
|
import { ProjectsPage } from './pages/ProjectsPage'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/projects" replace />} />
|
||||||
|
<Route path="/projects" element={<ProjectsPage />} />
|
||||||
|
<Route path="/editor/:projectId" element={<EditorPage />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
6
web/src/ClipItem.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Back-compat re-export
|
||||||
|
export { ClipItem } from './components/Timeline/ClipItem'
|
||||||
|
export { default } from './components/Timeline/ClipItem'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
6
web/src/EditorPage.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Back-compat re-export (some deployments may reference src/EditorPage.tsx)
|
||||||
|
export { EditorPage } from './pages/EditorPage'
|
||||||
|
export { default } from './pages/EditorPage'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
6
web/src/Timeline.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Back-compat re-export
|
||||||
|
export { Timeline } from './components/Timeline/Timeline'
|
||||||
|
export { default } from './components/Timeline/Timeline'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
6
web/src/TrackPanel.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Back-compat re-export
|
||||||
|
export { TrackPanel } from './components/Timeline/TrackPanel'
|
||||||
|
export { default } from './components/Timeline/TrackPanel'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
6
web/src/TrackRow.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Back-compat re-export
|
||||||
|
export { TrackRow } from './components/Timeline/TrackRow'
|
||||||
|
export { default } from './components/Timeline/TrackRow'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
6
web/src/VideoComposition.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Back-compat re-export
|
||||||
|
export { VideoComposition } from './remotion/VideoComposition'
|
||||||
|
export { default } from './remotion/VideoComposition'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
366
web/src/components/Timeline/ClipItem.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
/**
|
||||||
|
* 时间轴片段组件
|
||||||
|
*/
|
||||||
|
import React, { useRef, useCallback, useEffect, useState } from 'react'
|
||||||
|
import { type TimelineClip } from '@/store/editorStore'
|
||||||
|
import { cn, timeToPixels, pixelsToTime } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface ClipItemProps {
|
||||||
|
clip: TimelineClip
|
||||||
|
trackType: string
|
||||||
|
pixelsPerSecond: number
|
||||||
|
trackHeight: number
|
||||||
|
isSelected: boolean
|
||||||
|
isLocked: boolean
|
||||||
|
onSelect: (e: React.MouseEvent) => void
|
||||||
|
onDrag: (deltaX: number, deltaTime: number) => void
|
||||||
|
onResize: (edge: 'start' | 'end', deltaTime: number) => void
|
||||||
|
onCommit?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClipItem: React.FC<ClipItemProps> = ({
|
||||||
|
clip,
|
||||||
|
trackType,
|
||||||
|
pixelsPerSecond,
|
||||||
|
trackHeight,
|
||||||
|
isSelected,
|
||||||
|
isLocked,
|
||||||
|
onSelect,
|
||||||
|
onDrag,
|
||||||
|
onResize,
|
||||||
|
onCommit,
|
||||||
|
}) => {
|
||||||
|
const clipRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [isResizing, setIsResizing] = useState<'start' | 'end' | null>(null)
|
||||||
|
const [thumbUrl, setThumbUrl] = useState<string | null>(null)
|
||||||
|
const [audioPeaks, setAudioPeaks] = useState<number[] | null>(null)
|
||||||
|
|
||||||
|
const left = timeToPixels(clip.start, pixelsPerSecond)
|
||||||
|
const width = timeToPixels(clip.duration, pixelsPerSecond)
|
||||||
|
|
||||||
|
// 胶片条:对视频片段生成多帧缩略图(MVP),提升辨识度
|
||||||
|
useEffect(() => {
|
||||||
|
if (trackType !== 'video') return
|
||||||
|
if (!clip.sourceUrl) return
|
||||||
|
if (thumbUrl) return
|
||||||
|
// 宽度太小就不抽帧,避免性能压力
|
||||||
|
if (width < 90) return
|
||||||
|
let cancelled = false
|
||||||
|
const src = clip.sourceUrl
|
||||||
|
const minTile = 110
|
||||||
|
const frameCount = Math.max(3, Math.min(8, Math.floor(width / minTile)))
|
||||||
|
const key = `${src}@@${Math.round((clip.trimStart || 0) * 1000)}@@${Math.round((clip.duration || 0) * 1000)}@@${frameCount}`
|
||||||
|
const cache = (globalThis as any).__vfThumbCache as Map<string, string> | undefined
|
||||||
|
if (cache?.has(key)) {
|
||||||
|
setThumbUrl(cache.get(key) || null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const v = document.createElement('video')
|
||||||
|
v.muted = true
|
||||||
|
v.playsInline = true
|
||||||
|
;(v as any).crossOrigin = 'anonymous'
|
||||||
|
v.preload = 'metadata'
|
||||||
|
v.src = src
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const onMeta = () => resolve()
|
||||||
|
const onErr = () => reject(new Error('video load error'))
|
||||||
|
v.addEventListener('loadedmetadata', onMeta, { once: true })
|
||||||
|
v.addEventListener('error', onErr, { once: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const dur = Number.isFinite(v.duration) ? v.duration : 0
|
||||||
|
const t0 = clip.trimStart || 0
|
||||||
|
const clipDur = clip.duration || 0
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const tileW = 120
|
||||||
|
canvas.width = tileW * frameCount
|
||||||
|
canvas.height = 90
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
ctx.fillStyle = '#000'
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
for (let i = 0; i < frameCount; i++) {
|
||||||
|
if (cancelled) return
|
||||||
|
const ratio = frameCount === 1 ? 0 : i / (frameCount - 1)
|
||||||
|
const rawT = t0 + ratio * Math.max(0, clipDur - 0.1)
|
||||||
|
const seekT = dur > 0 ? Math.min(Math.max(0, rawT + 0.03), Math.max(0, dur - 0.08)) : Math.max(0, rawT + 0.03)
|
||||||
|
v.currentTime = seekT
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const onSeek = () => resolve()
|
||||||
|
const onErr = () => reject(new Error('video seek error'))
|
||||||
|
v.addEventListener('seeked', onSeek, { once: true })
|
||||||
|
v.addEventListener('error', onErr, { once: true })
|
||||||
|
})
|
||||||
|
ctx.drawImage(v, i * tileW, 0, tileW, canvas.height)
|
||||||
|
// film-strip separators
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.22)'
|
||||||
|
ctx.fillRect(i * tileW, 0, 1, canvas.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.72)
|
||||||
|
if (cancelled) return
|
||||||
|
setThumbUrl(dataUrl)
|
||||||
|
const c = (globalThis as any).__vfThumbCache || new Map<string, string>()
|
||||||
|
c.set(key, dataUrl)
|
||||||
|
;(globalThis as any).__vfThumbCache = c
|
||||||
|
} catch {
|
||||||
|
// ignore thumbnail failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 尽量把重活放到空闲时间
|
||||||
|
const ric = (globalThis as any).requestIdleCallback as ((cb: () => void) => number) | undefined
|
||||||
|
ric ? ric(() => run()) : setTimeout(() => run(), 0)
|
||||||
|
return () => { cancelled = true }
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [trackType, clip.sourceUrl, clip.trimStart, width])
|
||||||
|
|
||||||
|
// 音频波形(MVP):对 audio/bgm 片段抽样波形(decodeAudioData -> peaks)
|
||||||
|
useEffect(() => {
|
||||||
|
if (trackType !== 'audio' && trackType !== 'bgm') return
|
||||||
|
if (!clip.sourceUrl) return
|
||||||
|
if (audioPeaks) return
|
||||||
|
// 旁白通常片段较短,阈值太大则很难看到波形
|
||||||
|
if (width < 50) return
|
||||||
|
let cancelled = false
|
||||||
|
const src = clip.sourceUrl
|
||||||
|
const points = Math.max(80, Math.min(320, Math.floor(width)))
|
||||||
|
const trimStart = clip.trimStart ?? 0
|
||||||
|
const durReq = clip.duration ?? 0
|
||||||
|
const key = `${src}@@${points}@@${Math.round(trimStart * 1000)}@@${Math.round(durReq * 1000)}`
|
||||||
|
const cache = (globalThis as any).__vfWaveCache as Map<string, number[]> | undefined
|
||||||
|
if (cache?.has(key)) {
|
||||||
|
setAudioPeaks(cache.get(key) || null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(src)
|
||||||
|
const buf = await res.arrayBuffer()
|
||||||
|
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||||
|
const audio = await ctx.decodeAudioData(buf.slice(0))
|
||||||
|
const ch = audio.numberOfChannels ? audio.getChannelData(0) : new Float32Array()
|
||||||
|
const sr = audio.sampleRate || 44100
|
||||||
|
const fullLen = ch.length
|
||||||
|
const startS = Math.max(0, trimStart)
|
||||||
|
const endS = Math.max(startS, startS + Math.max(0.01, durReq || audio.duration || 0))
|
||||||
|
const startIdx = Math.min(fullLen, Math.floor(startS * sr))
|
||||||
|
const endIdx = Math.min(fullLen, Math.floor(endS * sr))
|
||||||
|
const len = Math.max(1, endIdx - startIdx)
|
||||||
|
const block = Math.max(1, Math.floor(len / points))
|
||||||
|
const peaks: number[] = new Array(points).fill(0)
|
||||||
|
for (let i = 0; i < points; i++) {
|
||||||
|
const start = startIdx + i * block
|
||||||
|
const end = Math.min(endIdx, start + block)
|
||||||
|
let max = 0
|
||||||
|
for (let j = start; j < end; j++) {
|
||||||
|
const v = Math.abs(ch[j])
|
||||||
|
if (v > max) max = v
|
||||||
|
}
|
||||||
|
peaks[i] = max
|
||||||
|
}
|
||||||
|
// normalize
|
||||||
|
const m = Math.max(...peaks, 1e-6)
|
||||||
|
const norm = peaks.map(p => p / m)
|
||||||
|
if (cancelled) return
|
||||||
|
setAudioPeaks(norm)
|
||||||
|
const c = (globalThis as any).__vfWaveCache || new Map<string, number[]>()
|
||||||
|
c.set(key, norm)
|
||||||
|
;(globalThis as any).__vfWaveCache = c
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ric = (globalThis as any).requestIdleCallback as ((cb: () => void) => number) | undefined
|
||||||
|
ric ? ric(() => run()) : setTimeout(() => run(), 0)
|
||||||
|
return () => { cancelled = true }
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [trackType, clip.sourceUrl, width])
|
||||||
|
|
||||||
|
// 获取轨道颜色
|
||||||
|
const getTrackColor = () => {
|
||||||
|
switch (trackType) {
|
||||||
|
case 'video': return 'bg-track-video'
|
||||||
|
case 'audio': return 'bg-track-voiceover'
|
||||||
|
case 'subtitle': return 'bg-track-subtitle'
|
||||||
|
case 'fancy_text': return 'bg-track-fancy'
|
||||||
|
case 'bgm': return 'bg-track-bgm'
|
||||||
|
default: return 'bg-gray-500'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理拖拽开始
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (isResizing) return
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
// 锁定轨道:仍允许选中查看属性,但禁止拖拽移动
|
||||||
|
onSelect(e)
|
||||||
|
if (isLocked) return
|
||||||
|
|
||||||
|
setIsDragging(true)
|
||||||
|
|
||||||
|
const startX = e.clientX
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
const deltaX = moveEvent.clientX - startX
|
||||||
|
const deltaTime = pixelsToTime(deltaX, pixelsPerSecond)
|
||||||
|
onDrag(deltaX, deltaTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false)
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
onCommit?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
}, [clip.start, pixelsPerSecond, isLocked, isResizing, onSelect, onDrag])
|
||||||
|
|
||||||
|
// 处理调整大小开始
|
||||||
|
const handleResizeStart = useCallback((
|
||||||
|
e: React.MouseEvent,
|
||||||
|
edge: 'start' | 'end'
|
||||||
|
) => {
|
||||||
|
if (isLocked) return
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
setIsResizing(edge)
|
||||||
|
const startX = e.clientX
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
const deltaX = moveEvent.clientX - startX
|
||||||
|
const deltaTime = pixelsToTime(edge === 'start' ? deltaX : deltaX, pixelsPerSecond)
|
||||||
|
onResize(edge, deltaTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsResizing(null)
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
onCommit?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
}, [pixelsPerSecond, isLocked, onResize])
|
||||||
|
|
||||||
|
// 获取显示内容
|
||||||
|
const getClipContent = () => {
|
||||||
|
if (clip.text) {
|
||||||
|
return clip.text.slice(0, 20) + (clip.text.length > 20 ? '...' : '')
|
||||||
|
}
|
||||||
|
if (clip.sourceUrl) {
|
||||||
|
return (clip.sourceUrl || '').split('/').pop()?.slice(0, 15) || 'Video'
|
||||||
|
}
|
||||||
|
return clip.type
|
||||||
|
}
|
||||||
|
|
||||||
|
const transitionBadge = (() => {
|
||||||
|
if (trackType !== 'video') return null
|
||||||
|
const s: any = clip.style || {}
|
||||||
|
const t = String(s.vTransitionType ?? s.v_transition_type ?? '')
|
||||||
|
if (!t) return null
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
fade: '淡出',
|
||||||
|
fadeWhite: '淡到白',
|
||||||
|
flash: '闪白',
|
||||||
|
zoomOut: '缩小',
|
||||||
|
zoomIn: '推进',
|
||||||
|
slideLeft: '左滑',
|
||||||
|
slideRight: '右滑',
|
||||||
|
slideUp: '上滑',
|
||||||
|
slideDown: '下滑',
|
||||||
|
rotateOut: '旋转',
|
||||||
|
blurOut: '模糊',
|
||||||
|
blurFade: '模糊淡出',
|
||||||
|
desaturate: '去饱和',
|
||||||
|
colorPop: '色彩增强',
|
||||||
|
hueShift: '色相偏移',
|
||||||
|
darken: '变暗',
|
||||||
|
}
|
||||||
|
const name = map[t] || t
|
||||||
|
return (
|
||||||
|
<div className="absolute left-1 top-1 z-10 px-1.5 py-0.5 rounded bg-black/45 text-[10px] text-white/90 pointer-events-none">
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={clipRef}
|
||||||
|
className={cn(
|
||||||
|
'vf-clip',
|
||||||
|
'absolute rounded overflow-hidden cursor-grab select-none',
|
||||||
|
'transition-shadow',
|
||||||
|
getTrackColor(),
|
||||||
|
isSelected && 'ring-2 ring-white ring-offset-1 ring-offset-editor-bg',
|
||||||
|
isDragging && 'cursor-grabbing opacity-90 shadow-lg',
|
||||||
|
isLocked && 'cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left,
|
||||||
|
width: Math.max(width, 20),
|
||||||
|
top: 4,
|
||||||
|
height: trackHeight - 8,
|
||||||
|
backgroundImage: thumbUrl ? `linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.35)), url(${thumbUrl})` : undefined,
|
||||||
|
backgroundSize: thumbUrl ? 'auto 100%' : undefined,
|
||||||
|
backgroundRepeat: thumbUrl ? 'repeat-x' : undefined,
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
>
|
||||||
|
{transitionBadge}
|
||||||
|
|
||||||
|
{/* 左边调整手柄 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize',
|
||||||
|
'hover:bg-white/20'
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => handleResizeStart(e, 'start')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
<div className="absolute inset-0 px-2 flex items-center overflow-hidden pointer-events-none">
|
||||||
|
<span className="text-xs text-white/90 font-medium truncate">
|
||||||
|
{getClipContent()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 音频波形(MVP) */}
|
||||||
|
{(trackType === 'audio' || trackType === 'bgm') && audioPeaks && (
|
||||||
|
<div className="absolute left-0 right-0 bottom-1 h-6 px-1 opacity-90 pointer-events-none">
|
||||||
|
<svg width="100%" height="100%" viewBox={`0 0 ${audioPeaks.length} 20`} preserveAspectRatio="none">
|
||||||
|
{audioPeaks.map((p, i) => {
|
||||||
|
const h = Math.max(1, Math.round(p * 18))
|
||||||
|
const y = 10 - h / 2
|
||||||
|
return <rect key={i} x={i} y={y} width={0.9} height={h} fill="rgba(255,255,255,0.72)" />
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 右边调整手柄 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize',
|
||||||
|
'hover:bg-white/20'
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => handleResizeStart(e, 'end')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClipItem
|
||||||
|
|
||||||
44
web/src/components/Timeline/Playhead.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 播放头组件
|
||||||
|
*/
|
||||||
|
import React from 'react'
|
||||||
|
import { timeToPixels } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface PlayheadProps {
|
||||||
|
currentTime: number
|
||||||
|
pixelsPerSecond: number
|
||||||
|
height: number
|
||||||
|
onPointerDown?: (e: React.PointerEvent<HTMLDivElement>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Playhead: React.FC<PlayheadProps> = ({
|
||||||
|
currentTime,
|
||||||
|
pixelsPerSecond,
|
||||||
|
height,
|
||||||
|
onPointerDown,
|
||||||
|
}) => {
|
||||||
|
const left = timeToPixels(currentTime, pixelsPerSecond)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="playhead" style={{ left, height }}>
|
||||||
|
{/* 增大命中区域,提升“拖拽跟手” */}
|
||||||
|
<div className="absolute -left-2 top-0 bottom-0 w-4 cursor-ew-resize" onPointerDown={onPointerDown} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Playhead
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
72
web/src/components/Timeline/TimeRuler.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* 时间标尺组件
|
||||||
|
*/
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { formatTimeShort } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface TimeRulerProps {
|
||||||
|
duration: number
|
||||||
|
pixelsPerSecond: number
|
||||||
|
scrollX?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TimeRuler: React.FC<TimeRulerProps> = ({
|
||||||
|
duration,
|
||||||
|
pixelsPerSecond,
|
||||||
|
}) => {
|
||||||
|
// 计算刻度间隔
|
||||||
|
const { majorInterval, minorInterval } = useMemo(() => {
|
||||||
|
if (pixelsPerSecond >= 100) {
|
||||||
|
return { majorInterval: 1, minorInterval: 0.5 }
|
||||||
|
} else if (pixelsPerSecond >= 50) {
|
||||||
|
return { majorInterval: 2, minorInterval: 1 }
|
||||||
|
} else if (pixelsPerSecond >= 25) {
|
||||||
|
return { majorInterval: 5, minorInterval: 1 }
|
||||||
|
} else {
|
||||||
|
return { majorInterval: 10, minorInterval: 5 }
|
||||||
|
}
|
||||||
|
}, [pixelsPerSecond])
|
||||||
|
|
||||||
|
// 生成刻度
|
||||||
|
const ticks = useMemo(() => {
|
||||||
|
const result: { time: number; isMajor: boolean }[] = []
|
||||||
|
|
||||||
|
for (let t = 0; t <= duration; t += minorInterval) {
|
||||||
|
const isMajor = t % majorInterval === 0
|
||||||
|
result.push({ time: t, isMajor })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}, [duration, majorInterval, minorInterval])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full bg-editor-panel">
|
||||||
|
{ticks.map(({ time, isMajor }) => {
|
||||||
|
const x = time * pixelsPerSecond
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={time}
|
||||||
|
className="absolute top-0 flex flex-col items-center"
|
||||||
|
style={{ left: x }}
|
||||||
|
>
|
||||||
|
{/* 刻度线 */}
|
||||||
|
<div
|
||||||
|
className={isMajor ? 'w-px h-3 bg-editor-text-muted' : 'w-px h-2 bg-editor-border'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 时间标签 */}
|
||||||
|
{isMajor && (
|
||||||
|
<span className="text-[10px] text-editor-text-muted mt-0.5">
|
||||||
|
{formatTimeShort(time)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimeRuler
|
||||||
|
|
||||||
408
web/src/components/Timeline/Timeline.tsx
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* 时间轴编辑器组件
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useMemo, useRef, useCallback, useState } from 'react'
|
||||||
|
import { useEditorStore } from '@/store/editorStore'
|
||||||
|
import { TrackRow } from './TrackRow'
|
||||||
|
import { TimeRuler } from './TimeRuler'
|
||||||
|
import { Playhead } from './Playhead'
|
||||||
|
import { cn, timeToPixels, pixelsToTime } from '@/lib/utils'
|
||||||
|
import { Scissors, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
const PIXELS_PER_SECOND_BASE = 50
|
||||||
|
const TRACK_HEIGHT = 48
|
||||||
|
const RULER_HEIGHT = 28
|
||||||
|
|
||||||
|
export const Timeline: React.FC = () => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
tracks,
|
||||||
|
totalDuration,
|
||||||
|
currentTime,
|
||||||
|
zoom,
|
||||||
|
scrollX,
|
||||||
|
snapGuideTime,
|
||||||
|
setSelectedClips,
|
||||||
|
clearSelection,
|
||||||
|
setCurrentTime,
|
||||||
|
setScrollX,
|
||||||
|
setZoom,
|
||||||
|
pushHistory,
|
||||||
|
splitAtTime,
|
||||||
|
deleteAtPlayhead,
|
||||||
|
addClip,
|
||||||
|
} = useEditorStore()
|
||||||
|
|
||||||
|
const visibleTracks = useMemo(() => tracks.filter(t => !t.collapsed), [tracks])
|
||||||
|
|
||||||
|
const [isDraggingPlayhead, setIsDraggingPlayhead] = useState(false)
|
||||||
|
const [viewportWidth, setViewportWidth] = useState(800)
|
||||||
|
const [boxSel, setBoxSel] = useState<null | { x1: number; y1: number; x2: number; y2: number }>(null)
|
||||||
|
const dragRafRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const pixelsPerSecond = PIXELS_PER_SECOND_BASE * zoom
|
||||||
|
const timelineWidth = Math.max(totalDuration * pixelsPerSecond, 800)
|
||||||
|
|
||||||
|
// 可视窗口(用于虚拟化渲染,减少大量 clip 节点导致的重排/卡顿)
|
||||||
|
const viewWindow = useMemo(() => {
|
||||||
|
const startPx = scrollX
|
||||||
|
const endPx = scrollX + viewportWidth
|
||||||
|
const bufferPx = Math.max(200, viewportWidth * 0.5)
|
||||||
|
const viewStartTime = pixelsToTime(Math.max(0, startPx - bufferPx), pixelsPerSecond)
|
||||||
|
const viewEndTime = pixelsToTime(endPx + bufferPx, pixelsPerSecond)
|
||||||
|
return { viewStartTime, viewEndTime }
|
||||||
|
}, [scrollX, viewportWidth, pixelsPerSecond])
|
||||||
|
|
||||||
|
// 监听容器宽度变化(ResizeObserver)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollContainerRef.current
|
||||||
|
if (!el) return
|
||||||
|
const update = () => setViewportWidth(el.clientWidth || 800)
|
||||||
|
update()
|
||||||
|
const ro = new ResizeObserver(update)
|
||||||
|
ro.observe(el)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 处理时间轴点击 - 移动播放头
|
||||||
|
const handleTimelineClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (isDraggingPlayhead) return
|
||||||
|
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left + scrollX
|
||||||
|
const time = pixelsToTime(x, pixelsPerSecond)
|
||||||
|
setCurrentTime(Math.max(0, Math.min(time, totalDuration)))
|
||||||
|
}, [pixelsPerSecond, scrollX, totalDuration, isDraggingPlayhead])
|
||||||
|
|
||||||
|
const updatePlayheadFromClientX = useCallback((clientX: number) => {
|
||||||
|
const scrollEl = scrollContainerRef.current
|
||||||
|
const rulerEl = containerRef.current
|
||||||
|
if (!scrollEl || !rulerEl) return
|
||||||
|
// 以“标尺区域的可视窗口”为基准计算 x,并叠加实时 scrollLeft(不要用滞后的 store.scrollX)
|
||||||
|
const rulerRect = rulerEl.getBoundingClientRect()
|
||||||
|
const x = clientX - rulerRect.left - 128 /* track label width */ + (scrollEl.scrollLeft || 0)
|
||||||
|
const time = pixelsToTime(x, pixelsPerSecond)
|
||||||
|
const next = Math.max(0, Math.min(time, totalDuration))
|
||||||
|
// raf 合并高频更新,避免拖拽卡顿
|
||||||
|
if (dragRafRef.current) cancelAnimationFrame(dragRafRef.current)
|
||||||
|
dragRafRef.current = requestAnimationFrame(() => {
|
||||||
|
setCurrentTime(next)
|
||||||
|
dragRafRef.current = null
|
||||||
|
})
|
||||||
|
}, [pixelsPerSecond, totalDuration, setCurrentTime])
|
||||||
|
|
||||||
|
// 处理播放头拖拽(Pointer capture:跟手、不会丢事件)
|
||||||
|
const handlePlayheadPointerDown = useCallback((e: React.PointerEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDraggingPlayhead(true)
|
||||||
|
;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
|
||||||
|
updatePlayheadFromClientX(e.clientX)
|
||||||
|
}, [updatePlayheadFromClientX])
|
||||||
|
|
||||||
|
const handlePlayheadPointerMove = useCallback((e: React.PointerEvent) => {
|
||||||
|
if (!isDraggingPlayhead) return
|
||||||
|
e.preventDefault()
|
||||||
|
updatePlayheadFromClientX(e.clientX)
|
||||||
|
}, [isDraggingPlayhead, updatePlayheadFromClientX])
|
||||||
|
|
||||||
|
const handlePlayheadPointerUp = useCallback((e: React.PointerEvent) => {
|
||||||
|
if (!isDraggingPlayhead) return
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDraggingPlayhead(false)
|
||||||
|
try { (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId) } catch {}
|
||||||
|
}, [isDraggingPlayhead])
|
||||||
|
|
||||||
|
// 滚轮缩放
|
||||||
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
const delta = e.deltaY > 0 ? 0.9 : 1.1
|
||||||
|
setZoom(zoom * delta)
|
||||||
|
} else {
|
||||||
|
// 水平滚动
|
||||||
|
setScrollX(scrollX + e.deltaX)
|
||||||
|
}
|
||||||
|
}, [zoom, scrollX])
|
||||||
|
|
||||||
|
// 同步滚动
|
||||||
|
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
setScrollX(e.currentTarget.scrollLeft)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 框选(只在空白区域拖拽触发)
|
||||||
|
const handleContentMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
if (isDraggingPlayhead) return
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (target?.closest?.('.vf-clip')) return
|
||||||
|
if (!scrollContainerRef.current || !contentRef.current) return
|
||||||
|
|
||||||
|
const scrollEl = scrollContainerRef.current
|
||||||
|
const rect = contentRef.current.getBoundingClientRect()
|
||||||
|
const startX = e.clientX - rect.left + scrollEl.scrollLeft
|
||||||
|
const startY = e.clientY - rect.top + scrollEl.scrollTop
|
||||||
|
setBoxSel({ x1: startX, y1: startY, x2: startX, y2: startY })
|
||||||
|
|
||||||
|
const onMove = (me: MouseEvent) => {
|
||||||
|
const x = me.clientX - rect.left + scrollEl.scrollLeft
|
||||||
|
const y = me.clientY - rect.top + scrollEl.scrollTop
|
||||||
|
setBoxSel((cur) => cur ? ({ ...cur, x2: x, y2: y }) : null)
|
||||||
|
}
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMove)
|
||||||
|
document.removeEventListener('mouseup', onUp)
|
||||||
|
setBoxSel((cur) => {
|
||||||
|
if (!cur) return null
|
||||||
|
const xMin = Math.min(cur.x1, cur.x2)
|
||||||
|
const xMax = Math.max(cur.x1, cur.x2)
|
||||||
|
const yMin = Math.min(cur.y1, cur.y2)
|
||||||
|
const yMax = Math.max(cur.y1, cur.y2)
|
||||||
|
const timeMin = pixelsToTime(xMin, pixelsPerSecond)
|
||||||
|
const timeMax = pixelsToTime(xMax, pixelsPerSecond)
|
||||||
|
|
||||||
|
// 小拖拽视为“点空白取消选择”
|
||||||
|
if (Math.abs(xMax - xMin) < 4 && Math.abs(yMax - yMin) < 4) {
|
||||||
|
clearSelection()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const picked: string[] = []
|
||||||
|
visibleTracks.forEach((t, idx) => {
|
||||||
|
const top = idx * TRACK_HEIGHT
|
||||||
|
const bottom = top + TRACK_HEIGHT
|
||||||
|
if (bottom < yMin || top > yMax) return
|
||||||
|
for (const c of t.clips) {
|
||||||
|
const start = c.start ?? 0
|
||||||
|
const end = start + (c.duration ?? 0)
|
||||||
|
if (end < timeMin || start > timeMax) continue
|
||||||
|
picked.push(c.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setSelectedClips(picked)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove)
|
||||||
|
document.addEventListener('mouseup', onUp)
|
||||||
|
}, [visibleTracks, pixelsPerSecond, isDraggingPlayhead, clearSelection, setSelectedClips])
|
||||||
|
|
||||||
|
const handleDropAsset = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const raw = e.dataTransfer.getData('application/json') || ''
|
||||||
|
if (!raw) return
|
||||||
|
let payload: any = null
|
||||||
|
try { payload = JSON.parse(raw) } catch { payload = null }
|
||||||
|
if (!payload) return
|
||||||
|
|
||||||
|
const vTrack = tracks.find(t => t.type === 'video')
|
||||||
|
const stickerTrack = tracks.find(t => t.type === 'sticker')
|
||||||
|
|
||||||
|
const scrollEl = scrollContainerRef.current
|
||||||
|
const rect = contentRef.current?.getBoundingClientRect()
|
||||||
|
const x = rect ? (e.clientX - rect.left + (scrollEl?.scrollLeft || 0)) : (scrollEl?.scrollLeft || 0)
|
||||||
|
const t = Math.max(0, pixelsToTime(x, pixelsPerSecond))
|
||||||
|
|
||||||
|
if (payload.kind === 'asset' && payload.assetType === 'video') {
|
||||||
|
if (!vTrack) return
|
||||||
|
const url = String(payload.url || '')
|
||||||
|
if (!url) return
|
||||||
|
pushHistory({ label: '添加素材', icon: 'media' })
|
||||||
|
addClip(vTrack.id, {
|
||||||
|
type: 'video',
|
||||||
|
start: t,
|
||||||
|
duration: 3,
|
||||||
|
trimStart: 0,
|
||||||
|
trimEnd: 3,
|
||||||
|
sourceUrl: url,
|
||||||
|
sourcePath: payload.localPath ? String(payload.localPath) : undefined,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.kind === 'sticker') {
|
||||||
|
if (!stickerTrack) return
|
||||||
|
const url = String(payload.url || '')
|
||||||
|
if (!url) return
|
||||||
|
pushHistory({ label: '添加贴纸', icon: 'sticker' })
|
||||||
|
addClip(stickerTrack.id, {
|
||||||
|
type: 'sticker',
|
||||||
|
start: t,
|
||||||
|
duration: 2,
|
||||||
|
sourceUrl: url,
|
||||||
|
text: String(payload.name || ''),
|
||||||
|
position: { x: 0.8, y: 0.2 },
|
||||||
|
style: { scale: 1.0, rotate: 0 },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}, [tracks, pixelsPerSecond, pushHistory, addClip])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="h-full flex flex-col bg-editor-bg select-none"
|
||||||
|
onWheel={handleWheel}
|
||||||
|
>
|
||||||
|
{/* 时间标尺 */}
|
||||||
|
<div
|
||||||
|
className="shrink-0 bg-editor-panel border-b border-editor-border overflow-hidden"
|
||||||
|
style={{ height: RULER_HEIGHT }}
|
||||||
|
>
|
||||||
|
<div className="flex">
|
||||||
|
{/* 轨道标签占位 */}
|
||||||
|
<div className="w-32 shrink-0 bg-editor-panel" />
|
||||||
|
|
||||||
|
{/* 标尺 */}
|
||||||
|
<div
|
||||||
|
className="relative cursor-pointer"
|
||||||
|
style={{ width: timelineWidth }}
|
||||||
|
onClick={handleTimelineClick}
|
||||||
|
onPointerMove={handlePlayheadPointerMove}
|
||||||
|
onPointerUp={handlePlayheadPointerUp}
|
||||||
|
onPointerCancel={handlePlayheadPointerUp}
|
||||||
|
>
|
||||||
|
<TimeRuler
|
||||||
|
duration={totalDuration}
|
||||||
|
pixelsPerSecond={pixelsPerSecond}
|
||||||
|
scrollX={scrollX}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 播放头顶部标记 */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 w-4 h-4 -ml-2 cursor-ew-resize"
|
||||||
|
style={{ left: timeToPixels(currentTime, pixelsPerSecond) - scrollX }}
|
||||||
|
onPointerDown={handlePlayheadPointerDown}
|
||||||
|
>
|
||||||
|
<div className="w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[8px] border-t-red-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 红线附近快捷操作:剪切/删除(不跟着素材 hover 走) */}
|
||||||
|
<div
|
||||||
|
className="absolute top-1 -ml-2 flex gap-1 z-20"
|
||||||
|
style={{ left: timeToPixels(currentTime, pixelsPerSecond) - scrollX }}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-7 h-7 rounded bg-black/55 hover:bg-black/70 flex items-center justify-center"
|
||||||
|
title="剪切(以红线为准)"
|
||||||
|
onClick={() => {
|
||||||
|
pushHistory({ label: '剪切', icon: 'scissors' })
|
||||||
|
splitAtTime(currentTime)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Scissors className="w-4 h-4 text-white/90" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-7 h-7 rounded bg-black/55 hover:bg-black/70 flex items-center justify-center"
|
||||||
|
title="删除(红线所在片段)"
|
||||||
|
onClick={() => {
|
||||||
|
pushHistory({ label: '删除片段', icon: 'trash' })
|
||||||
|
deleteAtPlayhead()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-white/90" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 轨道区域 */}
|
||||||
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className="flex-1 overflow-auto"
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
<div className="flex min-h-full">
|
||||||
|
{/* 轨道标签 */}
|
||||||
|
<div className="w-32 shrink-0 bg-editor-panel border-r border-editor-border sticky left-0 z-10">
|
||||||
|
{visibleTracks.map(track => (
|
||||||
|
<div
|
||||||
|
key={track.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center px-3 border-b border-editor-border',
|
||||||
|
'text-sm text-editor-text-muted'
|
||||||
|
)}
|
||||||
|
style={{ height: TRACK_HEIGHT }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-1.5 h-4 rounded-sm mr-2',
|
||||||
|
track.type === 'video' && 'bg-track-video',
|
||||||
|
track.type === 'audio' && 'bg-track-voiceover',
|
||||||
|
track.type === 'subtitle' && 'bg-track-subtitle',
|
||||||
|
track.type === 'fancy_text' && 'bg-track-fancy',
|
||||||
|
track.type === 'bgm' && 'bg-track-bgm',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{track.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 轨道内容 */}
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{ width: timelineWidth, minHeight: visibleTracks.length * TRACK_HEIGHT }}
|
||||||
|
ref={contentRef}
|
||||||
|
onMouseDown={handleContentMouseDown}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }}
|
||||||
|
onDrop={handleDropAsset}
|
||||||
|
>
|
||||||
|
{visibleTracks.map((track, index) => (
|
||||||
|
<TrackRow
|
||||||
|
key={track.id}
|
||||||
|
track={track}
|
||||||
|
pixelsPerSecond={pixelsPerSecond}
|
||||||
|
trackHeight={TRACK_HEIGHT}
|
||||||
|
top={index * TRACK_HEIGHT}
|
||||||
|
viewStartTime={viewWindow.viewStartTime}
|
||||||
|
viewEndTime={viewWindow.viewEndTime}
|
||||||
|
onClipChange={() => pushHistory()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 播放头线 */}
|
||||||
|
<Playhead
|
||||||
|
currentTime={currentTime}
|
||||||
|
pixelsPerSecond={pixelsPerSecond}
|
||||||
|
height={visibleTracks.length * TRACK_HEIGHT}
|
||||||
|
onPointerDown={handlePlayheadPointerDown}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 吸附/对齐辅助线 */}
|
||||||
|
{typeof snapGuideTime === 'number' && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-px bg-yellow-400/80 pointer-events-none"
|
||||||
|
style={{ left: timeToPixels(snapGuideTime, pixelsPerSecond) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 框选矩形 */}
|
||||||
|
{boxSel && (
|
||||||
|
<div
|
||||||
|
className="absolute border border-editor-accent bg-editor-accent/10 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: Math.min(boxSel.x1, boxSel.x2),
|
||||||
|
top: Math.min(boxSel.y1, boxSel.y2),
|
||||||
|
width: Math.abs(boxSel.x2 - boxSel.x1),
|
||||||
|
height: Math.abs(boxSel.y2 - boxSel.y1),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Timeline
|
||||||
|
|
||||||
872
web/src/components/Timeline/TrackPanel.tsx
Normal file
@@ -0,0 +1,872 @@
|
|||||||
|
/**
|
||||||
|
* 轨道属性面板
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
Lock,
|
||||||
|
Unlock,
|
||||||
|
Trash2,
|
||||||
|
RefreshCw,
|
||||||
|
Headphones,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useEditorStore } from '@/store/editorStore'
|
||||||
|
import { editorApi, assetsApi } from '@/lib/api'
|
||||||
|
import { cn, formatTimeShort, generateId, parseTimeInput } from '@/lib/utils'
|
||||||
|
|
||||||
|
export const TrackPanel: React.FC = () => {
|
||||||
|
const {
|
||||||
|
tracks,
|
||||||
|
selectedClipId,
|
||||||
|
selectedClipIds,
|
||||||
|
soloTrackIds,
|
||||||
|
toggleTrackSolo,
|
||||||
|
toggleTrackMute,
|
||||||
|
toggleTrackLock,
|
||||||
|
toggleTrackCollapse,
|
||||||
|
updateClip,
|
||||||
|
deleteClip,
|
||||||
|
groupSelected,
|
||||||
|
ungroupSelected,
|
||||||
|
pushHistory,
|
||||||
|
} = useEditorStore()
|
||||||
|
|
||||||
|
const { data: fontList } = useQuery({
|
||||||
|
queryKey: ['font-list'],
|
||||||
|
queryFn: assetsApi.getFonts,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 找到选中的片段
|
||||||
|
const selectedClip = tracks
|
||||||
|
.flatMap(t => t.clips.map(c => ({ ...c, trackId: t.id, trackType: t.type })))
|
||||||
|
.find(c => c.id === selectedClipId)
|
||||||
|
|
||||||
|
// TTS 重新生成
|
||||||
|
const ttsMutation = useMutation({
|
||||||
|
mutationFn: (params: { text: string; targetDuration?: number }) =>
|
||||||
|
editorApi.generateVoiceover(params.text, undefined, params.targetDuration),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (selectedClip && data.url) {
|
||||||
|
updateClip(selectedClip.trackId, selectedClip.id, {
|
||||||
|
sourceUrl: data.url,
|
||||||
|
sourcePath: data.path,
|
||||||
|
sourceDuration: typeof data.duration === 'number' && Number.isFinite(data.duration) ? data.duration : (selectedClip as any).sourceDuration,
|
||||||
|
needsVoiceoverRegenerate: false,
|
||||||
|
})
|
||||||
|
pushHistory()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 花字重新生成
|
||||||
|
const fancyTextMutation = useMutation({
|
||||||
|
mutationFn: (params: { text: string; style?: Record<string, unknown> }) =>
|
||||||
|
editorApi.generateFancyText(params.text, params.style),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (selectedClip && data.url) {
|
||||||
|
updateClip(selectedClip.trackId, selectedClip.id, {
|
||||||
|
sourceUrl: data.url,
|
||||||
|
sourcePath: data.path,
|
||||||
|
})
|
||||||
|
pushHistory()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [editingText, setEditingText] = useState('')
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||||
|
|
||||||
|
// 基础:更友好的时间输入(M:SS)
|
||||||
|
const [basicTime, setBasicTime] = useState({ start: '', end: '' })
|
||||||
|
// 高级:精确输入(秒)
|
||||||
|
const [timing, setTiming] = useState({ start: '', duration: '', trimStart: '', trimEnd: '' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedClip) return
|
||||||
|
const s = Number(selectedClip.start ?? 0)
|
||||||
|
const d = Number(selectedClip.duration ?? 0)
|
||||||
|
setBasicTime({
|
||||||
|
start: formatTimeShort(Math.max(0, s)),
|
||||||
|
end: formatTimeShort(Math.max(0, s + d)),
|
||||||
|
})
|
||||||
|
setTiming({
|
||||||
|
start: String(selectedClip.start ?? 0),
|
||||||
|
duration: String(selectedClip.duration ?? 0),
|
||||||
|
trimStart: String(selectedClip.trimStart ?? 0),
|
||||||
|
trimEnd: String(selectedClip.trimEnd ?? ((selectedClip.trimStart ?? 0) + (selectedClip.duration ?? 0))),
|
||||||
|
})
|
||||||
|
}, [selectedClipId, selectedClip?.start, selectedClip?.duration, selectedClip?.trimStart, selectedClip?.trimEnd])
|
||||||
|
|
||||||
|
// 开始编辑文本
|
||||||
|
const startEditing = () => {
|
||||||
|
if (selectedClip?.text) {
|
||||||
|
setEditingText(selectedClip.text)
|
||||||
|
setIsEditing(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存文本
|
||||||
|
const saveText = () => {
|
||||||
|
if (!selectedClip) return
|
||||||
|
|
||||||
|
updateClip(selectedClip.trackId, selectedClip.id, { text: editingText })
|
||||||
|
setIsEditing(false)
|
||||||
|
pushHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveStyle = (patch: Record<string, unknown>) => {
|
||||||
|
if (!selectedClip) return
|
||||||
|
updateClip(selectedClip.trackId, selectedClip.id, { style: { ...(selectedClip.style || {}), ...patch } })
|
||||||
|
pushHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveTiming = () => {
|
||||||
|
if (!selectedClip) return
|
||||||
|
const toNum = (v: string) => {
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? n : 0
|
||||||
|
}
|
||||||
|
const minDur = 0.5
|
||||||
|
|
||||||
|
let start = Math.max(0, toNum(timing.start))
|
||||||
|
let duration = Math.max(minDur, toNum(timing.duration))
|
||||||
|
let trimStart = Math.max(0, toNum(timing.trimStart))
|
||||||
|
let trimEnd = toNum(timing.trimEnd)
|
||||||
|
if (!Number.isFinite(trimEnd) || trimEnd <= 0) trimEnd = trimStart + duration
|
||||||
|
|
||||||
|
// normalize trimEnd to duration
|
||||||
|
trimEnd = trimStart + duration
|
||||||
|
|
||||||
|
updateClip(selectedClip.trackId, selectedClip.id, { start, duration, trimStart, trimEnd })
|
||||||
|
pushHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveBasicTime = () => {
|
||||||
|
if (!selectedClip) return
|
||||||
|
const minDur = 0.5
|
||||||
|
const start = parseTimeInput(basicTime.start)
|
||||||
|
const end = parseTimeInput(basicTime.end)
|
||||||
|
if (start == null || end == null) return
|
||||||
|
const s = Math.max(0, start)
|
||||||
|
const d = Math.max(minDur, end - s)
|
||||||
|
updateClip(selectedClip.trackId, selectedClip.id, { start: s, duration: d })
|
||||||
|
pushHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveAudioParams = (params: { volume?: number; fadeIn?: number; fadeOut?: number; ducking?: boolean; duckVolume?: number; playbackRate?: number }) => {
|
||||||
|
if (!selectedClip) return
|
||||||
|
updateClip(selectedClip.trackId, selectedClip.id, {
|
||||||
|
...(typeof params.volume === 'number' && Number.isFinite(params.volume) ? { volume: Math.max(0, Math.min(1, params.volume)) } : {}),
|
||||||
|
...(typeof params.fadeIn === 'number' && Number.isFinite(params.fadeIn) ? { fadeIn: Math.max(0, params.fadeIn) } : {}),
|
||||||
|
...(typeof params.fadeOut === 'number' && Number.isFinite(params.fadeOut) ? { fadeOut: Math.max(0, params.fadeOut) } : {}),
|
||||||
|
...(typeof params.ducking === 'boolean' ? { ducking: params.ducking } : {}),
|
||||||
|
...(typeof params.duckVolume === 'number' && Number.isFinite(params.duckVolume) ? { duckVolume: Math.max(0.05, Math.min(1, params.duckVolume)) } : {}),
|
||||||
|
...(typeof params.playbackRate === 'number' && Number.isFinite(params.playbackRate) ? { playbackRate: Math.max(0.5, Math.min(2.0, params.playbackRate)) } : {}),
|
||||||
|
})
|
||||||
|
pushHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新生成 TTS
|
||||||
|
const regenerateTTS = () => {
|
||||||
|
if (!selectedClip?.text) return
|
||||||
|
ttsMutation.mutate({
|
||||||
|
text: selectedClip.text,
|
||||||
|
targetDuration: selectedClip.duration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新生成花字
|
||||||
|
const regenerateFancyText = () => {
|
||||||
|
if (!selectedClip?.text) return
|
||||||
|
fancyTextMutation.mutate({
|
||||||
|
text: selectedClip.text,
|
||||||
|
style: selectedClip.style,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除片段
|
||||||
|
const handleDeleteClip = () => {
|
||||||
|
if (!selectedClip) return
|
||||||
|
deleteClip(selectedClip.trackId, selectedClip.id)
|
||||||
|
pushHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定到视频片段(用于字幕/旁白联动)
|
||||||
|
const bindToVideo = () => {
|
||||||
|
if (!selectedClip) return
|
||||||
|
// 只对字幕/旁白有意义
|
||||||
|
const isSubtitle = selectedClip.trackType === 'subtitle'
|
||||||
|
const isVoice = selectedClip.trackId === 'audio-voiceover'
|
||||||
|
if (!isSubtitle && !isVoice) return
|
||||||
|
|
||||||
|
const videoTrack = tracks.find(t => t.type === 'video')
|
||||||
|
if (!videoTrack) return
|
||||||
|
const s = selectedClip.start ?? 0
|
||||||
|
const e = s + (selectedClip.duration ?? 0)
|
||||||
|
|
||||||
|
// 选 overlap 最大的视频片段
|
||||||
|
let best: any = null
|
||||||
|
let bestOverlap = 0
|
||||||
|
for (const v of videoTrack.clips) {
|
||||||
|
const vs = v.start ?? 0
|
||||||
|
const ve = vs + (v.duration ?? 0)
|
||||||
|
const overlap = Math.max(0, Math.min(e, ve) - Math.max(s, vs))
|
||||||
|
if (overlap > bestOverlap) {
|
||||||
|
bestOverlap = overlap
|
||||||
|
best = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!best) return
|
||||||
|
|
||||||
|
const gid = best.groupId || `grp_${generateId()}`
|
||||||
|
if (!best.groupId) {
|
||||||
|
updateClip(videoTrack.id, best.id, { groupId: gid })
|
||||||
|
}
|
||||||
|
updateClip(selectedClip.trackId, selectedClip.id, { groupId: gid })
|
||||||
|
pushHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* 轨道列表 */}
|
||||||
|
<div className="p-4 border-b border-editor-border">
|
||||||
|
<h3 className="text-sm font-medium text-editor-text mb-2">轨道</h3>
|
||||||
|
<p className="text-xs text-editor-text-muted mb-3">
|
||||||
|
剪切:移动红线到想切的位置,点“剪切”或按 <span className="font-mono">S</span>。拖动片段会自动把后面的片段推开。
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tracks.map(track => (
|
||||||
|
<div
|
||||||
|
key={track.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between p-2 rounded-lg',
|
||||||
|
'bg-editor-surface hover:bg-editor-hover transition-colors'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleTrackCollapse(track.id)}
|
||||||
|
className="p-1 rounded text-editor-text-muted hover:text-editor-text"
|
||||||
|
title={track.collapsed ? '展开轨道' : '折叠轨道'}
|
||||||
|
>
|
||||||
|
{track.collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-editor-text truncate">{track.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{showAdvanced && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleTrackSolo(track.id)}
|
||||||
|
className={cn(
|
||||||
|
'p-1 rounded',
|
||||||
|
(soloTrackIds || []).includes(track.id) ? 'text-green-400' : 'text-editor-text-muted hover:text-editor-text'
|
||||||
|
)}
|
||||||
|
title="只听这一条轨道(高级)"
|
||||||
|
>
|
||||||
|
<Headphones className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleTrackMute(track.id)}
|
||||||
|
className={cn(
|
||||||
|
'p-1 rounded',
|
||||||
|
track.muted ? 'text-red-400' : 'text-editor-text-muted hover:text-editor-text'
|
||||||
|
)}
|
||||||
|
title={track.muted ? '取消静音' : '静音'}
|
||||||
|
>
|
||||||
|
{track.muted ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleTrackLock(track.id)}
|
||||||
|
className={cn(
|
||||||
|
'p-1 rounded',
|
||||||
|
track.locked ? 'text-yellow-400' : 'text-editor-text-muted hover:text-editor-text'
|
||||||
|
)}
|
||||||
|
title={track.locked ? '已锁定:不可拖动/修改' : '锁定:防误触'}
|
||||||
|
>
|
||||||
|
{track.locked ? <Lock className="w-4 h-4" /> : <Unlock className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAdvanced(v => !v)}
|
||||||
|
className="mt-3 w-full py-1.5 rounded bg-editor-surface text-editor-text text-sm hover:bg-editor-hover flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
{showAdvanced ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
{showAdvanced ? '收起高级选项' : '显示高级选项'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 选中片段属性 */}
|
||||||
|
{selectedClip && (
|
||||||
|
<div className="p-4 border-b border-editor-border">
|
||||||
|
<h3 className="text-sm font-medium text-editor-text mb-3">片段</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 基础时间(新手友好) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs text-editor-text-muted">时间(M:SS)</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs">开始</label>
|
||||||
|
<input
|
||||||
|
value={basicTime.start}
|
||||||
|
onChange={(e) => setBasicTime({ ...basicTime, start: e.target.value })}
|
||||||
|
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
|
||||||
|
placeholder="0:00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs">结束</label>
|
||||||
|
<input
|
||||||
|
value={basicTime.end}
|
||||||
|
onChange={(e) => setBasicTime({ ...basicTime, end: e.target.value })}
|
||||||
|
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
|
||||||
|
placeholder="0:02"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={saveBasicTime}
|
||||||
|
className="w-full py-1.5 rounded bg-editor-surface text-editor-text text-sm hover:bg-editor-hover"
|
||||||
|
>
|
||||||
|
应用
|
||||||
|
</button>
|
||||||
|
<div className="text-xs text-editor-text-muted">
|
||||||
|
提示:更常用的是直接在时间轴拖动片段和边缘。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 高级:精确时间/裁剪(秒) */}
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="space-y-2 pt-2 border-t border-editor-border">
|
||||||
|
<div className="text-xs text-editor-text-muted">精确调整(秒)</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs">开始(s)</label>
|
||||||
|
<input
|
||||||
|
value={timing.start}
|
||||||
|
onChange={(e) => setTiming({ ...timing, start: e.target.value })}
|
||||||
|
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs">时长(s)</label>
|
||||||
|
<input
|
||||||
|
value={timing.duration}
|
||||||
|
onChange={(e) => setTiming({ ...timing, duration: e.target.value })}
|
||||||
|
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedClip.trackType === 'video' && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs">trimStart(s)</label>
|
||||||
|
<input
|
||||||
|
value={timing.trimStart}
|
||||||
|
onChange={(e) => setTiming({ ...timing, trimStart: e.target.value })}
|
||||||
|
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs">trimEnd(s)</label>
|
||||||
|
<input
|
||||||
|
value={timing.trimEnd}
|
||||||
|
onChange={(e) => setTiming({ ...timing, trimEnd: e.target.value })}
|
||||||
|
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={saveTiming}
|
||||||
|
className="w-full py-1.5 rounded bg-editor-surface text-editor-text text-sm hover:bg-editor-hover"
|
||||||
|
>
|
||||||
|
应用精确参数
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 文本编辑 */}
|
||||||
|
{selectedClip.text !== undefined && (
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs block mb-1">文本</label>
|
||||||
|
{(selectedClip.trackType === 'subtitle' || selectedClip.trackType === 'fancy_text') && !isEditing && (
|
||||||
|
<div className="mb-2 text-[11px] text-editor-text-muted">
|
||||||
|
建议:在画面上<strong>双击文字</strong>直接编辑(更像抖音/剪映)。这里只保留为“高级兜底”。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
value={editingText}
|
||||||
|
onChange={(e) => setEditingText(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-lg text-sm',
|
||||||
|
'bg-editor-bg border border-editor-border',
|
||||||
|
'text-editor-text focus:outline-none focus:border-editor-accent'
|
||||||
|
)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={saveText}
|
||||||
|
className="flex-1 py-1.5 rounded bg-editor-accent text-white text-sm font-medium"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(false)}
|
||||||
|
className="flex-1 py-1.5 rounded bg-editor-surface text-editor-text text-sm"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className={cn('px-3 py-2 rounded-lg text-sm', 'bg-editor-bg border border-editor-border', 'text-editor-text')}>
|
||||||
|
{selectedClip.text || '(空)'}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={startEditing}
|
||||||
|
className="w-full py-1.5 rounded bg-editor-surface text-editor-text text-sm hover:bg-editor-hover"
|
||||||
|
>
|
||||||
|
在右侧编辑(高级)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 字幕/花字/贴纸 */}
|
||||||
|
{(selectedClip.trackType === 'subtitle' || selectedClip.trackType === 'fancy_text' || selectedClip.trackType === 'sticker') && (
|
||||||
|
<div className="space-y-2 pt-2 border-t border-editor-border">
|
||||||
|
<div className="text-xs text-editor-text-muted">
|
||||||
|
{selectedClip.trackType === 'sticker' ? '贴纸(位置 / 大小)' : '样式(字体 / 颜色 / B I U)'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedClip.trackType === 'sticker' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs block mb-1">大小</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={50}
|
||||||
|
max={200}
|
||||||
|
step={5}
|
||||||
|
value={Math.round((Number((selectedClip.style as any)?.scale ?? 1) || 1) * 100)}
|
||||||
|
onChange={(e) => saveStyle({ scale: Math.max(0.5, Math.min(2.0, Number(e.target.value) / 100)) })}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-editor-text-muted text-right">
|
||||||
|
{(Number((selectedClip.style as any)?.scale ?? 1) || 1).toFixed(2)}x
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs block mb-1">旋转</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={-180}
|
||||||
|
max={180}
|
||||||
|
step={5}
|
||||||
|
value={Math.round(Number((selectedClip.style as any)?.rotate ?? 0) || 0)}
|
||||||
|
onChange={(e) => saveStyle({ rotate: Number(e.target.value) || 0 })}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-editor-text-muted text-right">
|
||||||
|
{Math.round(Number((selectedClip.style as any)?.rotate ?? 0) || 0)}°
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs block mb-1">X(0~1)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={0.01}
|
||||||
|
value={String(Number((selectedClip.position as any)?.x ?? 0.8))}
|
||||||
|
onChange={(e) => {
|
||||||
|
const base = (selectedClip.position || { x: 0.8, y: 0.2 }) as any
|
||||||
|
updateClip(selectedClip.trackId, selectedClip.id, { position: { x: Math.max(0, Math.min(1, Number(e.target.value) || 0)), y: base.y ?? 0.2 } })
|
||||||
|
}}
|
||||||
|
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs block mb-1">Y(0~1)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={0.01}
|
||||||
|
value={String(Number((selectedClip.position as any)?.y ?? 0.2))}
|
||||||
|
onChange={(e) => {
|
||||||
|
const base = (selectedClip.position || { x: 0.8, y: 0.2 }) as any
|
||||||
|
updateClip(selectedClip.trackId, selectedClip.id, { position: { x: base.x ?? 0.8, y: Math.max(0, Math.min(1, Number(e.target.value) || 0)) } })
|
||||||
|
}}
|
||||||
|
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-editor-text-muted">
|
||||||
|
提示:后续会支持在画面中直接拖拽贴纸定位(和字幕一致)。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedClip.trackType !== 'sticker' && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="text-editor-text-muted text-xs block mb-1">字体</label>
|
||||||
|
<select
|
||||||
|
value={String(((selectedClip.style as any)?.font_family ?? '') as any)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
saveStyle({ font_family: v || undefined })
|
||||||
|
}}
|
||||||
|
className={cn('w-full px-2 py-1 rounded text-sm', 'bg-editor-bg border border-editor-border text-editor-text')}
|
||||||
|
>
|
||||||
|
<option value="">默认</option>
|
||||||
|
{(Array.isArray(fontList) ? fontList : []).map((f: any) => {
|
||||||
|
// custom font:用 filename 作为 font_family(renderer 可解析,预览也能 FontFace 加载)
|
||||||
|
// system font:用绝对 path 便于导出;预览侧会自动回退为 PingFang
|
||||||
|
const val = f.url ? (f.css_family || f.id) : (f.path || f.id)
|
||||||
|
return (
|
||||||
|
<option key={String(f.id)} value={String(val)}>
|
||||||
|
{String(f.name || f.id)}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs block mb-1">字号</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
max={160}
|
||||||
|
step={1}
|
||||||
|
value={String(Number((selectedClip.style as any)?.font_size ?? (selectedClip.trackType === 'subtitle' ? 60 : 72)))}
|
||||||
|
onChange={(e) => saveStyle({ font_size: Math.max(10, Math.min(160, Number(e.target.value) || 60)) })}
|
||||||
|
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs block mb-1">颜色</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={String((selectedClip.style as any)?.font_color ?? '#ffffff')}
|
||||||
|
onChange={(e) => saveStyle({ font_color: e.target.value })}
|
||||||
|
className="w-full h-9 rounded bg-editor-bg border border-editor-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="text-editor-text-muted text-xs block mb-1">文本框宽度(控制换行)</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={20}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={Math.round((Number((selectedClip.style as any)?.box_w ?? (selectedClip.trackType === 'subtitle' ? 0.8 : 0.7)) || 0.8) * 100)}
|
||||||
|
onChange={(e) => saveStyle({ box_w: Math.max(0.2, Math.min(1, Number(e.target.value) / 100)) })}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-editor-text-muted text-right">
|
||||||
|
{Math.round((Number((selectedClip.style as any)?.box_w ?? (selectedClip.trackType === 'subtitle' ? 0.8 : 0.7)) || 0.8) * 100)}%
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-editor-text-muted">
|
||||||
|
也可以直接在画面上选中文字,拖右侧手柄调整宽度。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className={cn('px-2 py-1 rounded text-sm', (selectedClip.style as any)?.bold ? 'bg-editor-accent text-white' : 'bg-editor-surface text-editor-text')}
|
||||||
|
onClick={() => saveStyle({ bold: !((selectedClip.style as any)?.bold) })}
|
||||||
|
title="加粗"
|
||||||
|
>
|
||||||
|
B
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn('px-2 py-1 rounded text-sm italic', (selectedClip.style as any)?.italic ? 'bg-editor-accent text-white' : 'bg-editor-surface text-editor-text')}
|
||||||
|
onClick={() => saveStyle({ italic: !((selectedClip.style as any)?.italic) })}
|
||||||
|
title="斜体"
|
||||||
|
>
|
||||||
|
I
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn('px-2 py-1 rounded text-sm underline', (selectedClip.style as any)?.underline ? 'bg-editor-accent text-white' : 'bg-editor-surface text-editor-text')}
|
||||||
|
onClick={() => saveStyle({ underline: !((selectedClip.style as any)?.underline) })}
|
||||||
|
title="下划线"
|
||||||
|
>
|
||||||
|
U
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="text-xs text-editor-text-muted">提示:选中后可在预览里拖动位置</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 视频转场(基础:淡入/淡出到黑,不需要重叠) */}
|
||||||
|
{showAdvanced && selectedClip.trackType === 'video' && (
|
||||||
|
<div className="space-y-2 pt-2 border-t border-editor-border">
|
||||||
|
<div className="text-xs text-editor-text-muted">转场(基础)</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs">淡入(s)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.05"
|
||||||
|
value={String(Number((selectedClip.style as any)?.vFadeIn ?? (selectedClip.style as any)?.v_fade_in ?? 0))}
|
||||||
|
onChange={(e) => saveStyle({ vFadeIn: Math.max(0, Number(e.target.value) || 0) })}
|
||||||
|
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs">淡出(s)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.05"
|
||||||
|
value={String(Number((selectedClip.style as any)?.vFadeOut ?? (selectedClip.style as any)?.v_fade_out ?? 0))}
|
||||||
|
onChange={(e) => saveStyle({ vFadeOut: Math.max(0, Number(e.target.value) || 0) })}
|
||||||
|
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-editor-text-muted">
|
||||||
|
说明:这是“淡入/淡出到黑”的基础转场(不需要片段重叠)。更高级的“交叉溶解”以后再加。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 音频参数(旁白/BGM/视频原声) */}
|
||||||
|
{(selectedClip.trackType === 'audio' || selectedClip.trackType === 'bgm' || selectedClip.trackType === 'video') && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs text-editor-text-muted">声音</div>
|
||||||
|
{selectedClip.trackId === 'audio-voiceover' && (
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs">旁白倍速(纯播放)</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={50}
|
||||||
|
max={200}
|
||||||
|
step={5}
|
||||||
|
value={Math.round((Number((selectedClip as any).playbackRate ?? 1) || 1) * 100)}
|
||||||
|
onChange={(e) => saveAudioParams({ playbackRate: Math.max(0.5, Math.min(2.0, Number(e.target.value) / 100)) })}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-editor-text-muted w-12 text-right">
|
||||||
|
{(Number((selectedClip as any).playbackRate ?? 1) || 1).toFixed(2)}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-editor-text-muted">
|
||||||
|
提示:拖拽旁白片段时长会自动计算倍速;也可手动调倍速(预览/导出一致)。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs">音量</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={Math.round((((selectedClip as any).volume ?? (selectedClip.trackType === 'bgm' ? 0.15 : selectedClip.trackType === 'video' ? 0.2 : 1)) as number) * 100)}
|
||||||
|
onChange={(e) => saveAudioParams({ volume: Number(e.target.value) / 100 })}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-editor-text-muted w-10 text-right">
|
||||||
|
{Math.round((((selectedClip as any).volume ?? (selectedClip.trackType === 'bgm' ? 0.15 : selectedClip.trackType === 'video' ? 0.2 : 1)) as number) * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs">淡入(s)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.05"
|
||||||
|
value={String((selectedClip as any).fadeIn ?? (selectedClip.trackType === 'bgm' ? 0.8 : 0.05))}
|
||||||
|
onChange={(e) => saveAudioParams({ fadeIn: Number(e.target.value) })}
|
||||||
|
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs">淡出(s)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.05"
|
||||||
|
value={String((selectedClip as any).fadeOut ?? (selectedClip.trackType === 'bgm' ? 0.8 : 0.05))}
|
||||||
|
onChange={(e) => saveAudioParams({ fadeOut: Number(e.target.value) })}
|
||||||
|
className={cn('w-full px-2 py-1 rounded text-sm font-mono', 'bg-editor-bg border border-editor-border text-editor-text')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* BGM 自动压低(高级) */}
|
||||||
|
{showAdvanced && selectedClip.trackType === 'bgm' && (
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<label className="flex items-center justify-between text-sm text-editor-text">
|
||||||
|
<span>自动压低背景音乐</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={typeof (selectedClip as any).ducking === 'boolean' ? (selectedClip as any).ducking : true}
|
||||||
|
onChange={(e) => saveAudioParams({ ducking: e.target.checked })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<label className="text-editor-text-muted text-xs">压低到</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={5}
|
||||||
|
max={100}
|
||||||
|
value={Math.round((((selectedClip as any).duckVolume ?? 0.25) as number) * 100)}
|
||||||
|
onChange={(e) => saveAudioParams({ duckVolume: Number(e.target.value) / 100 })}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-editor-text-muted text-right">
|
||||||
|
{Math.round((((selectedClip as any).duckVolume ?? 0.25) as number) * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{selectedClip.trackId === 'audio-voiceover' && (
|
||||||
|
<button
|
||||||
|
onClick={regenerateTTS}
|
||||||
|
disabled={ttsMutation.isPending}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-sm',
|
||||||
|
'bg-editor-surface text-editor-text hover:bg-editor-hover transition-colors',
|
||||||
|
ttsMutation.isPending && 'opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-4 h-4', ttsMutation.isPending && 'animate-spin')} />
|
||||||
|
{(selectedClip as any).needsVoiceoverRegenerate ? '重新生成(适配时长)' : '生成配音'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedClip.trackType === 'fancy_text' && (
|
||||||
|
<button
|
||||||
|
onClick={regenerateFancyText}
|
||||||
|
disabled={fancyTextMutation.isPending}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-sm',
|
||||||
|
'bg-editor-surface text-editor-text hover:bg-editor-hover transition-colors',
|
||||||
|
fancyTextMutation.isPending && 'opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-4 h-4', fancyTextMutation.isPending && 'animate-spin')} />
|
||||||
|
重新生成
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteClip}
|
||||||
|
className={cn(
|
||||||
|
'p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分组/联动 */}
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="pt-2 border-t border-editor-border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-editor-text-muted">组</span>
|
||||||
|
<span className="text-xs text-editor-text">
|
||||||
|
{(selectedClip as any).groupId ? String((selectedClip as any).groupId) : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { groupSelected(); pushHistory() }}
|
||||||
|
disabled={(selectedClipIds || []).length < 2}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 py-1.5 rounded text-sm',
|
||||||
|
'bg-editor-surface text-editor-text hover:bg-editor-hover',
|
||||||
|
(selectedClipIds || []).length < 2 && 'opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
分组(多选)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { ungroupSelected(); pushHistory() }}
|
||||||
|
className={cn('flex-1 py-1.5 rounded text-sm', 'bg-editor-surface text-editor-text hover:bg-editor-hover')}
|
||||||
|
>
|
||||||
|
取消分组
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={bindToVideo}
|
||||||
|
className={cn(
|
||||||
|
'w-full mt-2 py-1.5 rounded text-sm',
|
||||||
|
'bg-editor-surface text-editor-text hover:bg-editor-hover'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
绑定到视频片段(字幕/旁白跟随)
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-editor-text-muted mt-1">
|
||||||
|
绑定后拖动视频片段会带着同组字幕/旁白一起移动。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 空状态 */}
|
||||||
|
{!selectedClip && (
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="text-sm font-medium text-editor-text mb-2">片段属性</h3>
|
||||||
|
<p className="text-sm text-editor-text-muted">
|
||||||
|
点击时间轴上的片段后,这里会出现“文本 / 音量 / 时间”等常用设置。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 旁白时长/文本变更提示(新手避免误解“拖拉=变速”) */}
|
||||||
|
{selectedClip && selectedClip.trackId === 'audio-voiceover' && (selectedClip as any).needsVoiceoverRegenerate && (
|
||||||
|
<div className="p-4 border-t border-editor-border">
|
||||||
|
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm text-editor-text font-medium">旁白时长已改变</div>
|
||||||
|
<div className="text-xs text-editor-text-muted mt-1">
|
||||||
|
提示:拖动/拉伸旁白片段不会自动改变语速。要让旁白“变慢/变快并贴合当前时长”,请点击上方“重新生成(适配时长)”。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrackPanel
|
||||||
|
|
||||||
171
web/src/components/Timeline/TrackRow.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* 轨道行组件
|
||||||
|
*/
|
||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { useEditorStore, type Track } from '@/store/editorStore'
|
||||||
|
import { ClipItem } from './ClipItem'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { applyDragRules, applyResizeEndRules, applyResizeStartRules } from '@/lib/timelineRules'
|
||||||
|
|
||||||
|
interface TrackRowProps {
|
||||||
|
track: Track
|
||||||
|
pixelsPerSecond: number
|
||||||
|
trackHeight: number
|
||||||
|
top: number
|
||||||
|
viewStartTime?: number
|
||||||
|
viewEndTime?: number
|
||||||
|
onClipChange?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TrackRow: React.FC<TrackRowProps> = ({
|
||||||
|
track,
|
||||||
|
pixelsPerSecond,
|
||||||
|
trackHeight,
|
||||||
|
top,
|
||||||
|
viewStartTime,
|
||||||
|
viewEndTime,
|
||||||
|
onClipChange,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
selectedClipIds,
|
||||||
|
toggleClipSelection,
|
||||||
|
updateClip,
|
||||||
|
currentTime,
|
||||||
|
rippleMode,
|
||||||
|
rippleShiftFrom,
|
||||||
|
setSnapGuideTime,
|
||||||
|
shiftGroup,
|
||||||
|
} = useEditorStore()
|
||||||
|
|
||||||
|
// 处理片段拖拽
|
||||||
|
const handleClipDrag = useCallback((
|
||||||
|
clipId: string,
|
||||||
|
_deltaX: number,
|
||||||
|
deltaTime: number
|
||||||
|
) => {
|
||||||
|
const clip = track.clips.find(c => c.id === clipId)
|
||||||
|
if (!clip || track.locked) return
|
||||||
|
|
||||||
|
const desiredStart = Math.max(0, clip.start + deltaTime)
|
||||||
|
// 分组联动:同 groupId 的片段跨轨一起移动(不参与 ripple 规则)
|
||||||
|
if (clip.groupId) {
|
||||||
|
const nextStart = applyDragRules(track.clips, clipId, desiredStart, currentTime)
|
||||||
|
const delta = nextStart - clip.start
|
||||||
|
if (Math.abs(delta) > 1e-6) {
|
||||||
|
setSnapGuideTime(nextStart)
|
||||||
|
shiftGroup(clip.groupId, delta)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (track.type === 'video' && rippleMode) {
|
||||||
|
// ripple: move this clip and all following clips together (preserve spacing)
|
||||||
|
const ordered = track.clips.slice().sort((a, b) => a.start - b.start)
|
||||||
|
const idx = ordered.findIndex(c => c.id === clipId)
|
||||||
|
if (idx === -1) return
|
||||||
|
const prev = idx > 0 ? ordered[idx - 1] : null
|
||||||
|
const minStart = prev ? (prev.start + prev.duration) : 0
|
||||||
|
const nextStart = Math.max(minStart, desiredStart)
|
||||||
|
const delta = nextStart - clip.start
|
||||||
|
if (Math.abs(delta) > 1e-6) {
|
||||||
|
rippleShiftFrom(track.id, clipId, delta)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStart = applyDragRules(track.clips, clipId, desiredStart, currentTime)
|
||||||
|
// 若发生吸附/修正,显示辅助线
|
||||||
|
if (Math.abs(nextStart - desiredStart) > 1e-4) setSnapGuideTime(nextStart)
|
||||||
|
updateClip(track.id, clipId, { start: nextStart })
|
||||||
|
}, [track, updateClip, currentTime, rippleMode, rippleShiftFrom, setSnapGuideTime, shiftGroup])
|
||||||
|
|
||||||
|
// 处理片段调整大小
|
||||||
|
const handleClipResize = useCallback((
|
||||||
|
clipId: string,
|
||||||
|
edge: 'start' | 'end',
|
||||||
|
deltaTime: number
|
||||||
|
) => {
|
||||||
|
const clip = track.clips.find(c => c.id === clipId)
|
||||||
|
if (!clip || track.locked) return
|
||||||
|
|
||||||
|
if (edge === 'start') {
|
||||||
|
const desiredStart = clip.start + deltaTime
|
||||||
|
const next = applyResizeStartRules(track.clips, clipId, desiredStart, currentTime)
|
||||||
|
updateClip(track.id, clipId, next)
|
||||||
|
} else {
|
||||||
|
const desiredEnd = clip.start + clip.duration + deltaTime
|
||||||
|
const next = applyResizeEndRules(track.clips, clipId, desiredEnd, currentTime)
|
||||||
|
// sourceDuration clamp (if known)
|
||||||
|
const sd = clip.sourceDuration
|
||||||
|
const trimStart = clip.trimStart ?? 0
|
||||||
|
let duration = next.duration
|
||||||
|
if (typeof sd === 'number' && sd > 0) {
|
||||||
|
duration = Math.min(duration, Math.max(0.5, sd - trimStart))
|
||||||
|
}
|
||||||
|
const trimEnd = trimStart + duration
|
||||||
|
|
||||||
|
// ripple push following clips when extending
|
||||||
|
if (track.type === 'video' && rippleMode) {
|
||||||
|
const ordered = track.clips.slice().sort((a, b) => a.start - b.start)
|
||||||
|
const idx = ordered.findIndex(c => c.id === clipId)
|
||||||
|
const oldEnd = clip.start + clip.duration
|
||||||
|
const newEnd = clip.start + duration
|
||||||
|
if (idx !== -1 && newEnd > oldEnd + 1e-6 && idx + 1 < ordered.length) {
|
||||||
|
const nextClip = ordered[idx + 1]
|
||||||
|
const overlap = newEnd - nextClip.start
|
||||||
|
if (overlap > 1e-6) {
|
||||||
|
rippleShiftFrom(track.id, nextClip.id, overlap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateClip(track.id, clipId, { duration, trimEnd })
|
||||||
|
}
|
||||||
|
}, [track, updateClip, currentTime, rippleMode, rippleShiftFrom])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute left-0 right-0 border-b border-editor-border',
|
||||||
|
track.locked && 'opacity-50',
|
||||||
|
track.muted && 'opacity-60'
|
||||||
|
)}
|
||||||
|
style={{ top, height: trackHeight }}
|
||||||
|
>
|
||||||
|
{/* 背景网格 */}
|
||||||
|
<div className="absolute inset-0 bg-editor-bg" />
|
||||||
|
|
||||||
|
{/* 片段 */}
|
||||||
|
{track.clips
|
||||||
|
.filter((clip) => {
|
||||||
|
if (typeof viewStartTime !== 'number' || typeof viewEndTime !== 'number') return true
|
||||||
|
const start = clip.start ?? 0
|
||||||
|
const end = start + (clip.duration ?? 0)
|
||||||
|
return end >= viewStartTime && start <= viewEndTime
|
||||||
|
})
|
||||||
|
.map(clip => (
|
||||||
|
<ClipItem
|
||||||
|
key={clip.id}
|
||||||
|
clip={clip}
|
||||||
|
trackType={track.type}
|
||||||
|
pixelsPerSecond={pixelsPerSecond}
|
||||||
|
trackHeight={trackHeight}
|
||||||
|
isSelected={selectedClipIds.includes(clip.id)}
|
||||||
|
isLocked={track.locked}
|
||||||
|
onSelect={(e) => {
|
||||||
|
const isToggle = e.shiftKey || e.metaKey || e.ctrlKey
|
||||||
|
toggleClipSelection(clip.id, isToggle ? 'toggle' : 'replace')
|
||||||
|
}}
|
||||||
|
onDrag={(deltaX, deltaTime) => handleClipDrag(clip.id, deltaX, deltaTime)}
|
||||||
|
onResize={(edge, deltaTime) => handleClipResize(clip.id, edge, deltaTime)}
|
||||||
|
onCommit={() => {
|
||||||
|
setSnapGuideTime(null)
|
||||||
|
onClipChange?.()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrackRow
|
||||||
|
|
||||||
23
web/src/components/Timeline/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Timeline 组件导出
|
||||||
|
*/
|
||||||
|
export { Timeline } from './Timeline'
|
||||||
|
export { TrackRow } from './TrackRow'
|
||||||
|
export { ClipItem } from './ClipItem'
|
||||||
|
export { TimeRuler } from './TimeRuler'
|
||||||
|
export { Playhead } from './Playhead'
|
||||||
|
export { TrackPanel } from './TrackPanel'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
5
web/src/editorStore.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Back-compat re-export
|
||||||
|
export * from './store/editorStore'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
118
web/src/index.css
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
color: #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自定义滚动条 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #242424;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #404040;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #525252;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 时间轴拖拽样式 */
|
||||||
|
.timeline-clip {
|
||||||
|
cursor: grab;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-clip:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-clip.dragging {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1.02);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 轨道颜色条 */
|
||||||
|
.track-indicator {
|
||||||
|
width: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 播放头 */
|
||||||
|
.playhead {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: #ef4444;
|
||||||
|
z-index: 50;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playhead::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 6px solid transparent;
|
||||||
|
border-right: 6px solid transparent;
|
||||||
|
border-top: 8px solid #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 波形容器 */
|
||||||
|
.waveform-container {
|
||||||
|
height: 48px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画 */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-slow {
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
283
web/src/lib/api.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
// API 客户端配置
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 60000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 类型定义
|
||||||
|
export interface Project {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
product_info: Record<string, unknown>
|
||||||
|
script_data?: ScriptData
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectListItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptData {
|
||||||
|
scenes: Scene[]
|
||||||
|
voiceover_timeline: VoiceoverItem[]
|
||||||
|
selling_points?: string[]
|
||||||
|
target_audience?: string
|
||||||
|
bgm_style?: string
|
||||||
|
visual_anchor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Scene {
|
||||||
|
id: number
|
||||||
|
visual_prompt?: string
|
||||||
|
video_prompt?: string
|
||||||
|
fancy_text?: {
|
||||||
|
text: string
|
||||||
|
start_time?: number
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceoverItem {
|
||||||
|
text: string
|
||||||
|
subtitle?: string
|
||||||
|
start_time: number
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineClip {
|
||||||
|
id: string
|
||||||
|
type: 'video' | 'audio' | 'subtitle' | 'fancy_text' | 'bgm' | 'sticker'
|
||||||
|
start: number
|
||||||
|
duration: number
|
||||||
|
source_path?: string
|
||||||
|
source_url?: string
|
||||||
|
trim_start?: number
|
||||||
|
trim_end?: number
|
||||||
|
source_duration?: number
|
||||||
|
text?: string
|
||||||
|
style?: Record<string, unknown>
|
||||||
|
position?: { x: string | number; y: string | number }
|
||||||
|
volume?: number
|
||||||
|
fade_in?: number
|
||||||
|
fade_out?: number
|
||||||
|
ducking?: boolean
|
||||||
|
duck_volume?: number
|
||||||
|
playback_rate?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Track {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
clips: TimelineClip[]
|
||||||
|
locked: boolean
|
||||||
|
visible: boolean
|
||||||
|
muted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorState {
|
||||||
|
project_id: string
|
||||||
|
total_duration: number
|
||||||
|
tracks: Track[]
|
||||||
|
current_time: number
|
||||||
|
zoom: number
|
||||||
|
ripple_mode?: boolean
|
||||||
|
subtitle_style?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BGMItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StickerItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
kind?: 'builtin' | 'custom'
|
||||||
|
tags?: string[]
|
||||||
|
category?: string
|
||||||
|
license?: string | null
|
||||||
|
attribution?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceOption {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 方法
|
||||||
|
|
||||||
|
// 将前端 store 的 clip/track(camelCase)转换为后端接口期望的 snake_case
|
||||||
|
const toApiClip = (clip: any) => ({
|
||||||
|
...clip,
|
||||||
|
source_path: clip.source_path ?? clip.sourcePath ?? clip.source_path,
|
||||||
|
source_url: clip.source_url ?? clip.sourceUrl ?? clip.source_url,
|
||||||
|
trim_start: clip.trim_start ?? clip.trimStart ?? clip.trim_start ?? 0,
|
||||||
|
trim_end: clip.trim_end ?? clip.trimEnd ?? clip.trim_end ?? null,
|
||||||
|
source_duration: clip.source_duration ?? clip.sourceDuration ?? clip.source_duration ?? null,
|
||||||
|
fade_in: clip.fade_in ?? clip.fadeIn ?? null,
|
||||||
|
fade_out: clip.fade_out ?? clip.fadeOut ?? null,
|
||||||
|
ducking: typeof clip.ducking === 'boolean' ? clip.ducking : null,
|
||||||
|
duck_volume: clip.duck_volume ?? clip.duckVolume ?? null,
|
||||||
|
playback_rate: clip.playback_rate ?? clip.playbackRate ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const toApiTrack = (track: any) => ({
|
||||||
|
...track,
|
||||||
|
clips: Array.isArray(track.clips) ? track.clips.map(toApiClip) : [],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
export const projectsApi = {
|
||||||
|
list: () => api.get<ProjectListItem[]>('/projects').then(r => r.data),
|
||||||
|
|
||||||
|
get: (id: string) => api.get<Project>(`/projects/${id}`).then(r => r.data),
|
||||||
|
|
||||||
|
create: (name: string, productInfo: Record<string, string>) =>
|
||||||
|
api.post('/projects', { name, product_info: productInfo }).then(r => r.data),
|
||||||
|
|
||||||
|
getAssets: (id: string) =>
|
||||||
|
api.get(`/projects/${id}/assets`).then(r => r.data),
|
||||||
|
|
||||||
|
generateScript: (id: string, modelProvider = 'shubiaobiao') =>
|
||||||
|
api.post(`/projects/${id}/generate-script`, null, { params: { model_provider: modelProvider } }).then(r => r.data),
|
||||||
|
|
||||||
|
generateImages: (id: string, modelProvider = 'shubiaobiao') =>
|
||||||
|
api.post(`/projects/${id}/generate-images`, null, { params: { model_provider: modelProvider } }).then(r => r.data),
|
||||||
|
|
||||||
|
generateVideos: (id: string) =>
|
||||||
|
api.post(`/projects/${id}/generate-videos`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editor
|
||||||
|
export const editorApi = {
|
||||||
|
getState: (projectId: string) =>
|
||||||
|
api.get<EditorState>(`/editor/${projectId}/state`).then(r => r.data),
|
||||||
|
|
||||||
|
saveState: (projectId: string, state: EditorState) =>
|
||||||
|
api.post(`/editor/${projectId}/state`, {
|
||||||
|
...state,
|
||||||
|
tracks: (state as any).tracks?.map(toApiTrack) ?? [],
|
||||||
|
}).then(r => r.data),
|
||||||
|
|
||||||
|
generateVoiceover: (text: string, voiceType?: string, targetDuration?: number) =>
|
||||||
|
api.post('/editor/generate-voiceover', {
|
||||||
|
text,
|
||||||
|
voice_type: voiceType,
|
||||||
|
target_duration: targetDuration,
|
||||||
|
}).then(r => r.data),
|
||||||
|
|
||||||
|
generateFancyText: (text: string, style?: Record<string, unknown>) =>
|
||||||
|
api.post('/editor/generate-fancy-text', { text, style }).then(r => r.data),
|
||||||
|
|
||||||
|
trimVideo: (sourcePath: string, startTime: number, endTime: number) =>
|
||||||
|
api.post('/editor/trim-video', {
|
||||||
|
source_path: sourcePath,
|
||||||
|
start_time: startTime,
|
||||||
|
end_time: endTime,
|
||||||
|
}).then(r => r.data),
|
||||||
|
|
||||||
|
deleteClip: (projectId: string, clipId: string) =>
|
||||||
|
api.delete(`/editor/${projectId}/clip/${clipId}`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose
|
||||||
|
export const composeApi = {
|
||||||
|
render: (projectId: string, tracks: Track[], options?: {
|
||||||
|
voiceType?: string
|
||||||
|
bgmVolume?: number
|
||||||
|
outputName?: string
|
||||||
|
}) => {
|
||||||
|
const videoClips = (tracks.find(t => t.type === 'video')?.clips || []).map(toApiClip)
|
||||||
|
const voiceoverClips = (tracks.find(t => t.id === 'audio-voiceover')?.clips || []).map(toApiClip)
|
||||||
|
const subtitleClips = (tracks.find(t => t.type === 'subtitle')?.clips || []).map(toApiClip)
|
||||||
|
const fancyTextClips = (tracks.find(t => t.type === 'fancy_text')?.clips || []).map(toApiClip)
|
||||||
|
const stickerClips = (tracks.find(t => t.type === 'sticker')?.clips || []).map(toApiClip)
|
||||||
|
const bgmClipRaw = tracks.find(t => t.type === 'bgm')?.clips[0] || null
|
||||||
|
const bgmClip = bgmClipRaw ? toApiClip(bgmClipRaw) : null
|
||||||
|
|
||||||
|
return api.post('/compose/render', {
|
||||||
|
project_id: projectId,
|
||||||
|
video_clips: videoClips,
|
||||||
|
voiceover_clips: voiceoverClips,
|
||||||
|
subtitle_clips: subtitleClips,
|
||||||
|
fancy_text_clips: fancyTextClips,
|
||||||
|
sticker_clips: stickerClips,
|
||||||
|
bgm_clip: bgmClip,
|
||||||
|
voice_type: options?.voiceType || 'zh_female_santongyongns_saturn_bigtts',
|
||||||
|
bgm_volume: options?.bgmVolume || 0.15,
|
||||||
|
output_name: options?.outputName,
|
||||||
|
}).then(r => r.data)
|
||||||
|
},
|
||||||
|
|
||||||
|
quickCompose: (projectId: string, bgmId?: string) =>
|
||||||
|
api.post('/compose/quick', null, { params: { project_id: projectId, bgm_id: bgmId } }).then(r => r.data),
|
||||||
|
|
||||||
|
getStatus: (taskId: string) =>
|
||||||
|
api.get(`/compose/status/${taskId}`).then(r => r.data),
|
||||||
|
|
||||||
|
retry: (taskId: string) =>
|
||||||
|
api.post(`/compose/retry/${taskId}`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assets
|
||||||
|
export const assetsApi = {
|
||||||
|
list: (projectId: string, assetType?: string) =>
|
||||||
|
api.get('/assets/list/' + projectId, { params: { asset_type: assetType } }).then(r => r.data),
|
||||||
|
|
||||||
|
upload: (file: File, assetType = 'custom') => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('asset_type', assetType)
|
||||||
|
return api.post('/assets/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
}).then(r => r.data)
|
||||||
|
},
|
||||||
|
|
||||||
|
getBGM: () => api.get<BGMItem[]>('/assets/bgm').then(r => r.data),
|
||||||
|
|
||||||
|
getFonts: () => api.get('/assets/fonts').then(r => r.data),
|
||||||
|
|
||||||
|
uploadFont: (file: File) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
return api.post('/assets/fonts/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
}).then(r => r.data)
|
||||||
|
},
|
||||||
|
|
||||||
|
getStickers: () => api.get<StickerItem[]>('/assets/stickers').then(r => r.data),
|
||||||
|
|
||||||
|
uploadSticker: (file: File) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
return api.post('/assets/stickers/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
}).then(r => r.data)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config
|
||||||
|
export const configApi = {
|
||||||
|
get: () => api.get('/config').then(r => r.data),
|
||||||
|
health: () => api.get('/health').then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
export default api
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
86
web/src/lib/templates.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* 轻量模板系统(MVP)
|
||||||
|
* - 目标:把“模板=一组默认样式+可选片段生成规则”固化,后续可演进为 zip 包
|
||||||
|
*/
|
||||||
|
import type { Track, TimelineClip } from '@/store/editorStore'
|
||||||
|
import { generateId } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type TemplateApplyResult = {
|
||||||
|
subtitleStyle?: Record<string, unknown>
|
||||||
|
addClips?: Array<{ trackType: string; clip: Omit<TimelineClip, 'id'> }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorTemplate {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
apply: (ctx: { tracks: Track[]; totalDuration: number }) => TemplateApplyResult
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureTrack = (tracks: Track[], type: string) => tracks.find(t => t.type === type)
|
||||||
|
|
||||||
|
export const templates: EditorTemplate[] = [
|
||||||
|
{
|
||||||
|
id: 'tpl_subtitle_box',
|
||||||
|
name: '字幕-底部半透明底',
|
||||||
|
description: '适合口播:底部字幕带半透明底,描边较轻。',
|
||||||
|
apply: () => ({
|
||||||
|
subtitleStyle: {
|
||||||
|
fontsize: 56,
|
||||||
|
fontcolor: 'white',
|
||||||
|
borderw: 3,
|
||||||
|
bordercolor: 'black',
|
||||||
|
box: 1,
|
||||||
|
boxcolor: 'black@0.45',
|
||||||
|
boxborderw: 18,
|
||||||
|
x: '(w-text_w)/2',
|
||||||
|
y: 'h-220',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl_intro_outro_fancy',
|
||||||
|
name: '花字-片头片尾',
|
||||||
|
description: '自动加一个片头/片尾花字(可后续手动改文本/位置)。',
|
||||||
|
apply: ({ tracks, totalDuration }) => {
|
||||||
|
const fancy = ensureTrack(tracks, 'fancy_text')
|
||||||
|
if (!fancy) return {}
|
||||||
|
const d = Math.max(0.8, Math.min(1.6, totalDuration / 8))
|
||||||
|
const intro: Omit<TimelineClip, 'id'> = {
|
||||||
|
type: 'fancy_text',
|
||||||
|
start: 0,
|
||||||
|
duration: d,
|
||||||
|
text: '片头标题',
|
||||||
|
style: { font_size: 80, font_color: '#FFFFFF' },
|
||||||
|
position: { x: '50%', y: '160px' },
|
||||||
|
}
|
||||||
|
const outro: Omit<TimelineClip, 'id'> = {
|
||||||
|
type: 'fancy_text',
|
||||||
|
start: Math.max(0, totalDuration - d),
|
||||||
|
duration: d,
|
||||||
|
text: '片尾收束',
|
||||||
|
style: { font_size: 72, font_color: '#FFFFFF' },
|
||||||
|
position: { x: '50%', y: '160px' },
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
addClips: [
|
||||||
|
{ trackType: 'fancy_text', clip: intro },
|
||||||
|
{ trackType: 'fancy_text', clip: outro },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const applyTemplateToTracks = (tracks: Track[], res: TemplateApplyResult) => {
|
||||||
|
const ops: Array<{ trackId: string; clip: TimelineClip }> = []
|
||||||
|
for (const add of res.addClips || []) {
|
||||||
|
const t = tracks.find(x => x.type === add.trackType)
|
||||||
|
if (!t) continue
|
||||||
|
ops.push({ trackId: t.id, clip: { ...add.clip, id: generateId() } as TimelineClip })
|
||||||
|
}
|
||||||
|
return ops
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
210
web/src/lib/timelineRules.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* Timeline rules layer (MVP)
|
||||||
|
* - snapping: playhead, other clip edges, grid
|
||||||
|
* - collision: disallow overlap in same track
|
||||||
|
* - normalize: keep trimEnd = trimStart + duration
|
||||||
|
*/
|
||||||
|
import type { TimelineClip } from '@/store/editorStore'
|
||||||
|
|
||||||
|
export interface TimelineRuleOptions {
|
||||||
|
gridSeconds?: number
|
||||||
|
snapThresholdSeconds?: number
|
||||||
|
minDurationSeconds?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS: Required<TimelineRuleOptions> = {
|
||||||
|
gridSeconds: 0.5,
|
||||||
|
snapThresholdSeconds: 0.12,
|
||||||
|
minDurationSeconds: 0.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v))
|
||||||
|
|
||||||
|
export const normalizeTrim = (clip: TimelineClip): TimelineClip => {
|
||||||
|
const trimStart = clip.trimStart ?? 0
|
||||||
|
const duration = clip.duration
|
||||||
|
const trimEnd = trimStart + duration
|
||||||
|
return { ...clip, trimStart, trimEnd }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildSnapAnchors = (
|
||||||
|
clips: TimelineClip[],
|
||||||
|
excludeClipId: string,
|
||||||
|
playheadTime: number,
|
||||||
|
opts?: TimelineRuleOptions
|
||||||
|
) => {
|
||||||
|
const o = { ...DEFAULTS, ...(opts || {}) }
|
||||||
|
const anchors = new Set<number>()
|
||||||
|
anchors.add(playheadTime)
|
||||||
|
|
||||||
|
// grid near playhead, and general
|
||||||
|
const grid = o.gridSeconds
|
||||||
|
anchors.add(Math.round(playheadTime / grid) * grid)
|
||||||
|
|
||||||
|
// other clip edges
|
||||||
|
for (const c of clips) {
|
||||||
|
if (c.id === excludeClipId) continue
|
||||||
|
anchors.add(c.start)
|
||||||
|
anchors.add(c.start + c.duration)
|
||||||
|
}
|
||||||
|
return { anchors: Array.from(anchors), opts: o }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const snapTime = (time: number, anchors: number[], opts?: TimelineRuleOptions) => {
|
||||||
|
const o = { ...DEFAULTS, ...(opts || {}) }
|
||||||
|
let best = time
|
||||||
|
let bestDist = Infinity
|
||||||
|
for (const a of anchors) {
|
||||||
|
const d = Math.abs(a - time)
|
||||||
|
if (d < bestDist) {
|
||||||
|
bestDist = d
|
||||||
|
best = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestDist <= o.snapThresholdSeconds ? best : time
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveNoOverlapStart = (
|
||||||
|
clips: TimelineClip[],
|
||||||
|
clipId: string,
|
||||||
|
desiredStart: number,
|
||||||
|
duration: number
|
||||||
|
) => {
|
||||||
|
const others = clips.filter(c => c.id !== clipId).slice().sort((a, b) => a.start - b.start)
|
||||||
|
const desiredEnd = desiredStart + duration
|
||||||
|
|
||||||
|
// find nearest prev/next boundaries
|
||||||
|
let prevEnd = 0
|
||||||
|
let nextStart = Number.POSITIVE_INFINITY
|
||||||
|
for (const c of others) {
|
||||||
|
const cStart = c.start
|
||||||
|
const cEnd = c.start + c.duration
|
||||||
|
if (cEnd <= desiredStart) {
|
||||||
|
prevEnd = Math.max(prevEnd, cEnd)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (cStart >= desiredEnd) {
|
||||||
|
nextStart = Math.min(nextStart, cStart)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// overlap with c
|
||||||
|
if (cStart < desiredEnd && cEnd > desiredStart) {
|
||||||
|
// clamp to nearest edge (prefer not moving backward too much)
|
||||||
|
// If desiredStart is before cStart, clamp end to cStart; else clamp start to cEnd.
|
||||||
|
const clampToBefore = cStart - duration
|
||||||
|
const clampToAfter = cEnd
|
||||||
|
const distBefore = Math.abs(desiredStart - clampToBefore)
|
||||||
|
const distAfter = Math.abs(desiredStart - clampToAfter)
|
||||||
|
return distBefore <= distAfter ? Math.max(0, clampToBefore) : clampToAfter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const maxStart = nextStart === Number.POSITIVE_INFINITY ? Number.POSITIVE_INFINITY : nextStart - duration
|
||||||
|
return clamp(desiredStart, prevEnd, maxStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const applyDragRules = (
|
||||||
|
clips: TimelineClip[],
|
||||||
|
clipId: string,
|
||||||
|
desiredStart: number,
|
||||||
|
playheadTime: number,
|
||||||
|
opts?: TimelineRuleOptions
|
||||||
|
) => {
|
||||||
|
const clip = clips.find(c => c.id === clipId)
|
||||||
|
if (!clip) return desiredStart
|
||||||
|
const { anchors, opts: o } = buildSnapAnchors(clips, clipId, playheadTime, opts)
|
||||||
|
let nextStart = Math.max(0, desiredStart)
|
||||||
|
nextStart = snapTime(nextStart, anchors, o)
|
||||||
|
nextStart = resolveNoOverlapStart(clips, clipId, nextStart, clip.duration)
|
||||||
|
return nextStart
|
||||||
|
}
|
||||||
|
|
||||||
|
export const applyResizeStartRules = (
|
||||||
|
clips: TimelineClip[],
|
||||||
|
clipId: string,
|
||||||
|
desiredStart: number,
|
||||||
|
playheadTime: number,
|
||||||
|
opts?: TimelineRuleOptions
|
||||||
|
) => {
|
||||||
|
const o = { ...DEFAULTS, ...(opts || {}) }
|
||||||
|
const clip = clips.find(c => c.id === clipId)
|
||||||
|
if (!clip) {
|
||||||
|
const duration = o.minDurationSeconds
|
||||||
|
const trimStart = 0
|
||||||
|
const trimEnd = trimStart + duration
|
||||||
|
return { start: Math.max(0, desiredStart), duration, trimStart, trimEnd }
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldEnd = clip.start + clip.duration
|
||||||
|
const minStart = Math.max(0, oldEnd - o.minDurationSeconds)
|
||||||
|
|
||||||
|
const { anchors } = buildSnapAnchors(clips, clipId, playheadTime, o)
|
||||||
|
let start = clamp(desiredStart, 0, minStart)
|
||||||
|
start = snapTime(start, anchors, o)
|
||||||
|
|
||||||
|
// collision: ensure new [start, oldEnd] doesn't overlap others
|
||||||
|
// For start-resize we clamp start to be >= prevEnd and <= nextStart - minDuration
|
||||||
|
const others = clips.filter(c => c.id !== clipId).slice().sort((a, b) => a.start - b.start)
|
||||||
|
let prevEnd = 0
|
||||||
|
let nextStart = Number.POSITIVE_INFINITY
|
||||||
|
for (const c of others) {
|
||||||
|
const cEnd = c.start + c.duration
|
||||||
|
if (cEnd <= start) {
|
||||||
|
prevEnd = Math.max(prevEnd, cEnd)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (c.start >= oldEnd) {
|
||||||
|
nextStart = Math.min(nextStart, c.start)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// overlapping other clip within [start, oldEnd]
|
||||||
|
if (c.start < oldEnd && cEnd > start) {
|
||||||
|
start = cEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const maxStart = Math.min(minStart, nextStart - o.minDurationSeconds)
|
||||||
|
start = clamp(start, prevEnd, maxStart)
|
||||||
|
|
||||||
|
// IMPORTANT: duration must be recalculated after collision adjustments,
|
||||||
|
// otherwise right edge would drift and can overlap next clip.
|
||||||
|
const duration = Math.max(o.minDurationSeconds, oldEnd - start)
|
||||||
|
|
||||||
|
const startDiff = start - clip.start
|
||||||
|
const trimStart = (clip.trimStart ?? 0) + startDiff
|
||||||
|
const trimEnd = trimStart + duration
|
||||||
|
|
||||||
|
return { start, duration, trimStart, trimEnd }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const applyResizeEndRules = (
|
||||||
|
clips: TimelineClip[],
|
||||||
|
clipId: string,
|
||||||
|
desiredEnd: number,
|
||||||
|
playheadTime: number,
|
||||||
|
opts?: TimelineRuleOptions
|
||||||
|
) => {
|
||||||
|
const o = { ...DEFAULTS, ...(opts || {}) }
|
||||||
|
const clip = clips.find(c => c.id === clipId)
|
||||||
|
if (!clip) return { duration: o.minDurationSeconds, trimEnd: o.minDurationSeconds }
|
||||||
|
|
||||||
|
const { anchors } = buildSnapAnchors(clips, clipId, playheadTime, o)
|
||||||
|
let end = Math.max(clip.start + o.minDurationSeconds, desiredEnd)
|
||||||
|
end = snapTime(end, anchors, o)
|
||||||
|
|
||||||
|
// collision: ensure end <= nextStart
|
||||||
|
const others = clips.filter(c => c.id !== clipId).slice().sort((a, b) => a.start - b.start)
|
||||||
|
for (const c of others) {
|
||||||
|
if (c.start >= clip.start + o.minDurationSeconds) {
|
||||||
|
if (c.start >= clip.start && c.start < end) {
|
||||||
|
end = Math.min(end, c.start)
|
||||||
|
}
|
||||||
|
if (c.start >= end) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Math.max(o.minDurationSeconds, end - clip.start)
|
||||||
|
const trimStart = clip.trimStart ?? 0
|
||||||
|
const trimEnd = trimStart + duration
|
||||||
|
return { duration, trimEnd }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
69
web/src/lib/transitions.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
export type TransitionCategory =
|
||||||
|
| '基础'
|
||||||
|
| '缩放'
|
||||||
|
| '滑动'
|
||||||
|
| '旋转'
|
||||||
|
| '模糊'
|
||||||
|
| '光效'
|
||||||
|
| '颜色'
|
||||||
|
|
||||||
|
export type TransitionPreset = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
desc: string
|
||||||
|
category: TransitionCategory
|
||||||
|
tags?: string[]
|
||||||
|
// 映射到 clip.style
|
||||||
|
type: string
|
||||||
|
defaultDurationSec: number
|
||||||
|
// 导出是否支持(MVP:先只保证 fade 一致)
|
||||||
|
exportable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转场库(火山式:不改片段时长、不插入片段)
|
||||||
|
* - 预览:Remotion 末尾动效
|
||||||
|
* - 导出:逐步补齐(先保证 fade)
|
||||||
|
*/
|
||||||
|
export const transitionCatalog: TransitionPreset[] = [
|
||||||
|
// 基础
|
||||||
|
{ id: 'fade', name: '淡出', desc: '片尾淡出到黑(导出支持)', category: '基础', type: 'fade', defaultDurationSec: 0.6, exportable: true, tags: ['通用', '稳'] },
|
||||||
|
{ id: 'dipToBlack', name: '快速淡黑', desc: '更短更利落的淡出', category: '基础', type: 'fade', defaultDurationSec: 0.25, exportable: true, tags: ['节奏', '快'] },
|
||||||
|
{ id: 'fadeWhite', name: '淡到白', desc: '片尾淡出到白(导出支持)', category: '基础', type: 'fadeWhite', defaultDurationSec: 0.5, exportable: true, tags: ['明亮'] },
|
||||||
|
{ id: 'flash', name: '闪白', desc: '片尾闪一下(导出支持)', category: '光效', type: 'flash', defaultDurationSec: 0.25, exportable: true, tags: ['节奏'] },
|
||||||
|
|
||||||
|
// 缩放
|
||||||
|
{ id: 'zoomOut', name: '缩小', desc: '片尾逐渐缩小(导出支持)', category: '缩放', type: 'zoomOut', defaultDurationSec: 0.6, exportable: true, tags: ['动感'] },
|
||||||
|
{ id: 'zoomIn', name: '推进', desc: '片尾逐渐推进(导出支持)', category: '缩放', type: 'zoomIn', defaultDurationSec: 0.6, exportable: true, tags: ['动感'] },
|
||||||
|
|
||||||
|
// 滑动
|
||||||
|
{ id: 'slideLeft', name: '左滑', desc: '片尾向左滑走(导出支持)', category: '滑动', type: 'slideLeft', defaultDurationSec: 0.6, exportable: true, tags: ['通用'] },
|
||||||
|
{ id: 'slideRight', name: '右滑', desc: '片尾向右滑走(导出支持)', category: '滑动', type: 'slideRight', defaultDurationSec: 0.6, exportable: true, tags: ['通用'] },
|
||||||
|
{ id: 'slideUp', name: '上滑', desc: '片尾向上滑走(导出支持)', category: '滑动', type: 'slideUp', defaultDurationSec: 0.6, exportable: true, tags: ['通用'] },
|
||||||
|
{ id: 'slideDown', name: '下滑', desc: '片尾向下滑走(导出支持)', category: '滑动', type: 'slideDown', defaultDurationSec: 0.6, exportable: true, tags: ['通用'] },
|
||||||
|
|
||||||
|
// 旋转
|
||||||
|
{ id: 'rotateOut', name: '旋转', desc: '片尾小角度旋转(导出支持)', category: '旋转', type: 'rotateOut', defaultDurationSec: 0.6, exportable: true, tags: ['动感'] },
|
||||||
|
|
||||||
|
// 模糊
|
||||||
|
{ id: 'blurOut', name: '模糊', desc: '片尾逐渐模糊(导出支持)', category: '模糊', type: 'blurOut', defaultDurationSec: 0.6, exportable: true, tags: ['质感'] },
|
||||||
|
{ id: 'blurFade', name: '模糊淡出', desc: '片尾模糊并淡出(导出支持)', category: '模糊', type: 'blurFade', defaultDurationSec: 0.6, exportable: true, tags: ['质感', '通用'] },
|
||||||
|
|
||||||
|
// 颜色
|
||||||
|
{ id: 'desaturate', name: '去饱和', desc: '片尾逐渐变灰(导出支持)', category: '颜色', type: 'desaturate', defaultDurationSec: 0.6, exportable: true, tags: ['质感'] },
|
||||||
|
{ id: 'colorPop', name: '色彩增强', desc: '片尾增强饱和/对比(导出支持)', category: '颜色', type: 'colorPop', defaultDurationSec: 0.6, exportable: true, tags: ['动感'] },
|
||||||
|
{ id: 'hueShift', name: '色相偏移', desc: '片尾轻微色相旋转(导出支持)', category: '颜色', type: 'hueShift', defaultDurationSec: 0.6, exportable: true, tags: ['氛围'] },
|
||||||
|
{ id: 'darken', name: '变暗', desc: '片尾逐渐变暗(导出支持)', category: '颜色', type: 'darken', defaultDurationSec: 0.6, exportable: true, tags: ['氛围'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const transitionCategories: TransitionCategory[] = [
|
||||||
|
'基础',
|
||||||
|
'缩放',
|
||||||
|
'滑动',
|
||||||
|
'旋转',
|
||||||
|
'模糊',
|
||||||
|
'光效',
|
||||||
|
'颜色',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
160
web/src/lib/utils.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化时间(秒)为 MM:SS.ms 格式
|
||||||
|
*/
|
||||||
|
export function formatTime(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
const ms = Math.floor((seconds % 1) * 100)
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化时间为简短格式 M:SS
|
||||||
|
*/
|
||||||
|
export function formatTimeShort(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析用户输入时间到秒。
|
||||||
|
* 支持:
|
||||||
|
* - "M:SS" / "MM:SS"
|
||||||
|
* - "M:SS.ms" / "MM:SS.ms"
|
||||||
|
* - 纯数字秒(如 "12.5")
|
||||||
|
*/
|
||||||
|
export function parseTimeInput(input: string): number | null {
|
||||||
|
const raw = (input || '').trim()
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
// pure number seconds
|
||||||
|
if (/^\d+(\.\d+)?$/.test(raw)) {
|
||||||
|
const n = Number(raw)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// M:SS(.ms)
|
||||||
|
const m = raw.match(/^(\d+)\s*:\s*(\d{1,2})(?:\.(\d{1,3}))?$/)
|
||||||
|
if (!m) return null
|
||||||
|
const mins = Number(m[1])
|
||||||
|
const secs = Number(m[2])
|
||||||
|
const fracRaw = m[3]
|
||||||
|
if (!Number.isFinite(mins) || !Number.isFinite(secs) || secs >= 60) return null
|
||||||
|
let frac = 0
|
||||||
|
if (fracRaw) {
|
||||||
|
const fracN = Number(`0.${fracRaw.padEnd(3, '0')}`) // treat as ms-ish
|
||||||
|
frac = Number.isFinite(fracN) ? fracN : 0
|
||||||
|
}
|
||||||
|
return mins * 60 + secs + frac
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将像素转换为时间(基于缩放比例)
|
||||||
|
*/
|
||||||
|
export function pixelsToTime(pixels: number, pixelsPerSecond: number): number {
|
||||||
|
return pixels / pixelsPerSecond
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将时间转换为像素(基于缩放比例)
|
||||||
|
*/
|
||||||
|
export function timeToPixels(time: number, pixelsPerSecond: number): number {
|
||||||
|
return time * pixelsPerSecond
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 限制数值在范围内
|
||||||
|
*/
|
||||||
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(Math.max(value, min), max)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成唯一 ID
|
||||||
|
*/
|
||||||
|
export function generateId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节流函数
|
||||||
|
*/
|
||||||
|
export function throttle<T extends (...args: unknown[]) => void>(
|
||||||
|
func: T,
|
||||||
|
limit: number
|
||||||
|
): T {
|
||||||
|
let inThrottle: boolean
|
||||||
|
return function(this: unknown, ...args: Parameters<T>) {
|
||||||
|
if (!inThrottle) {
|
||||||
|
func.apply(this, args)
|
||||||
|
inThrottle = true
|
||||||
|
setTimeout(() => (inThrottle = false), limit)
|
||||||
|
}
|
||||||
|
} as T
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防抖函数
|
||||||
|
*/
|
||||||
|
export function debounce<T extends (...args: unknown[]) => void>(
|
||||||
|
func: T,
|
||||||
|
wait: number
|
||||||
|
): T {
|
||||||
|
let timeout: ReturnType<typeof setTimeout>
|
||||||
|
return function(this: unknown, ...args: Parameters<T>) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(() => func.apply(this, args), wait)
|
||||||
|
} as T
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取轨道类型的颜色
|
||||||
|
*/
|
||||||
|
export function getTrackColor(type: string): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
video: 'bg-track-video',
|
||||||
|
audio: 'bg-track-audio',
|
||||||
|
voiceover: 'bg-track-voiceover',
|
||||||
|
subtitle: 'bg-track-subtitle',
|
||||||
|
fancy_text: 'bg-track-fancy',
|
||||||
|
bgm: 'bg-track-bgm',
|
||||||
|
}
|
||||||
|
return colors[type] || 'bg-gray-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取轨道类型的边框颜色
|
||||||
|
*/
|
||||||
|
export function getTrackBorderColor(type: string): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
video: 'border-track-video',
|
||||||
|
audio: 'border-track-audio',
|
||||||
|
voiceover: 'border-track-voiceover',
|
||||||
|
subtitle: 'border-track-subtitle',
|
||||||
|
fancy_text: 'border-track-fancy',
|
||||||
|
bgm: 'border-track-bgm',
|
||||||
|
}
|
||||||
|
return colors[type] || 'border-gray-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
36
web/src/main.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60, // 1 minute
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
1525
web/src/pages/EditorPage.tsx
Normal file
172
web/src/pages/ProjectsPage.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* 项目列表页面
|
||||||
|
*/
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Plus, Film, Clock, ChevronRight } from 'lucide-react'
|
||||||
|
import { projectsApi } from '@/lib/api'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export const ProjectsPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const workflowUrl = useMemo(() => {
|
||||||
|
// 生产环境部署在同一台机器:前端 3000,控制台 8503
|
||||||
|
try {
|
||||||
|
const u = new URL(window.location.href)
|
||||||
|
return `${u.protocol}//${u.hostname}:8503`
|
||||||
|
} catch {
|
||||||
|
return 'http://localhost:8503'
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const { data: projects, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['projects'],
|
||||||
|
queryFn: projectsApi.list,
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (timestamp: number) => {
|
||||||
|
return new Date(timestamp * 1000).toLocaleString('zh-CN', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
created: 'bg-gray-500/20 text-gray-400',
|
||||||
|
script_generated: 'bg-blue-500/20 text-blue-400',
|
||||||
|
images_generated: 'bg-purple-500/20 text-purple-400',
|
||||||
|
videos_generated: 'bg-cyan-500/20 text-cyan-400',
|
||||||
|
completed: 'bg-green-500/20 text-green-400',
|
||||||
|
failed: 'bg-red-500/20 text-red-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
created: '已创建',
|
||||||
|
script_generated: '脚本完成',
|
||||||
|
images_generated: '图片完成',
|
||||||
|
videos_generated: '视频完成',
|
||||||
|
completed: '已完成',
|
||||||
|
failed: '失败',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn(
|
||||||
|
'px-2 py-0.5 rounded text-xs font-medium',
|
||||||
|
styles[status] || styles.created
|
||||||
|
)}>
|
||||||
|
{labels[status] || status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-editor-bg">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-editor-panel border-b border-editor-border">
|
||||||
|
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Film className="w-8 h-8 text-editor-accent" />
|
||||||
|
<h1 className="text-xl font-bold text-editor-text">Video Flow Editor</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={workflowUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-editor-text-muted hover:text-editor-text transition-colors"
|
||||||
|
>
|
||||||
|
打开工作流控制台 →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="max-w-6xl mx-auto px-6 py-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-editor-text">我的项目</h2>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={workflowUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-4 py-2 rounded-lg',
|
||||||
|
'bg-editor-accent text-white font-medium',
|
||||||
|
'hover:bg-editor-accent-hover transition-colors'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
新建项目
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="animate-spin w-8 h-8 border-2 border-editor-accent border-t-transparent rounded-full mx-auto" />
|
||||||
|
<p className="mt-4 text-editor-text-muted">加载中...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-red-400">加载失败,请检查后端服务是否启动</p>
|
||||||
|
<p className="text-sm text-editor-text-muted mt-2">
|
||||||
|
确保 FastAPI 服务运行在 http://localhost:8000
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : projects?.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-editor-panel rounded-xl border border-editor-border">
|
||||||
|
<Film className="w-12 h-12 text-editor-text-muted mx-auto" />
|
||||||
|
<p className="mt-4 text-editor-text-muted">还没有项目</p>
|
||||||
|
<p className="text-sm text-editor-text-muted mt-1">
|
||||||
|
前往工作流控制台创建新项目
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{projects?.map(project => (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
onClick={() => navigate(`/editor/${project.id}`)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between p-4 rounded-xl',
|
||||||
|
'bg-editor-panel border border-editor-border',
|
||||||
|
'hover:border-editor-accent/50 cursor-pointer transition-all',
|
||||||
|
'group'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-editor-surface flex items-center justify-center">
|
||||||
|
<Film className="w-6 h-6 text-editor-text-muted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-editor-text group-hover:text-editor-accent transition-colors">
|
||||||
|
{project.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
{getStatusBadge(project.status)}
|
||||||
|
<span className="flex items-center gap-1 text-xs text-editor-text-muted">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{formatDate(project.updated_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChevronRight className="w-5 h-5 text-editor-text-muted group-hover:text-editor-accent transition-colors" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectsPage
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
19
web/src/pages/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Pages 导出
|
||||||
|
*/
|
||||||
|
export { ProjectsPage } from './ProjectsPage'
|
||||||
|
export { EditorPage } from './EditorPage'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
866
web/src/remotion/VideoComposition.tsx
Normal file
@@ -0,0 +1,866 @@
|
|||||||
|
/**
|
||||||
|
* Remotion 视频合成组件
|
||||||
|
* 用于浏览器端实时预览
|
||||||
|
*/
|
||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
AbsoluteFill,
|
||||||
|
Sequence,
|
||||||
|
Video,
|
||||||
|
Audio,
|
||||||
|
Img,
|
||||||
|
useCurrentFrame,
|
||||||
|
useVideoConfig,
|
||||||
|
interpolate,
|
||||||
|
} from 'remotion'
|
||||||
|
import type { Track, TimelineClip } from '@/store/editorStore'
|
||||||
|
|
||||||
|
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
|
||||||
|
|
||||||
|
const makeFade = (localFrame: number, durationFrames: number, fps: number, fadeIn?: number, fadeOut?: number) => {
|
||||||
|
const fi = Math.max(0, Number(fadeIn || 0))
|
||||||
|
const fo = Math.max(0, Number(fadeOut || 0))
|
||||||
|
const fiF = Math.round(fi * fps)
|
||||||
|
const foF = Math.round(fo * fps)
|
||||||
|
let f = 1
|
||||||
|
if (fiF > 0) f *= clamp01(localFrame / Math.max(1, fiF))
|
||||||
|
if (foF > 0) f *= clamp01((durationFrames - localFrame) / Math.max(1, foF))
|
||||||
|
return clamp01(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvePos = (v: any, total: number, fallback: number) => {
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||||
|
// 0~1 视为百分比;否则视为像素
|
||||||
|
if (v >= 0 && v <= 1) return v * total
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const normPosPct = (v: any, total: number, fallbackPct: number) => {
|
||||||
|
const n = Number(v)
|
||||||
|
if (typeof n === 'number' && Number.isFinite(n)) {
|
||||||
|
if (n >= 0 && n <= 1) return n
|
||||||
|
if (total > 0) return n / total
|
||||||
|
}
|
||||||
|
return fallbackPct
|
||||||
|
}
|
||||||
|
|
||||||
|
type UiBridge = {
|
||||||
|
selectedClipId?: string | null
|
||||||
|
onSelectClip?: (clipId: string) => void
|
||||||
|
onUpdateClip?: (clipId: string, updates: Partial<TimelineClip>) => void
|
||||||
|
onPushHistory?: (meta?: { label?: string; icon?: string }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RenderSizeCtx = React.createContext<{ w: number; h: number }>({ w: 0, h: 0 })
|
||||||
|
|
||||||
|
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
|
||||||
|
|
||||||
|
const getBoxWPct = (style: any, width: number, fallback: number) => {
|
||||||
|
const bwRaw = style?.box_w ?? style?.boxW ?? style?.max_width ?? style?.maxWidth ?? 0
|
||||||
|
const bw = Number(bwRaw) || 0
|
||||||
|
if (bw > 0 && bw <= 1) return bw
|
||||||
|
if (bw > 1 && width > 0) return clamp(bw / width, 0.2, 1.0)
|
||||||
|
return clamp(fallback, 0.2, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCssFontFamily = (v: any) => {
|
||||||
|
if (typeof v !== 'string' || !v) return undefined
|
||||||
|
// 若是绝对路径(用于导出 renderer/ffmpeg),预览侧用系统字体名回退
|
||||||
|
if (v.includes('/')) return 'PingFang SC'
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
const tailProgress01 = (frame: number, durationFrames: number, fps: number, tailSec: number) => {
|
||||||
|
const dF = Math.max(1, Math.round(Math.max(0.0, tailSec) * fps))
|
||||||
|
const start = Math.max(0, durationFrames - dF)
|
||||||
|
if (frame < start) return 0
|
||||||
|
return clamp01((frame - start) / Math.max(1, dF))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频片段组件(含原声控制)
|
||||||
|
const VideoClip: React.FC<{
|
||||||
|
clip: TimelineClip
|
||||||
|
volume?: number
|
||||||
|
}> = ({ clip, volume }) => {
|
||||||
|
if (!clip.sourceUrl) return null
|
||||||
|
const { fps } = useVideoConfig()
|
||||||
|
const frame = useCurrentFrame()
|
||||||
|
const durationFrames = Math.max(1, Math.round((clip.duration || 0) * fps))
|
||||||
|
const s: any = clip.style || {}
|
||||||
|
const vFadeIn = Number(s.vFadeIn ?? s.v_fade_in ?? 0) || 0
|
||||||
|
const vFadeOut = Number(s.vFadeOut ?? s.v_fade_out ?? 0) || 0
|
||||||
|
const opacity = makeFade(frame, durationFrames, fps, vFadeIn, vFadeOut)
|
||||||
|
|
||||||
|
// “火山式”转场:不改片段时长,仅对片段末尾做动效(默认 out)
|
||||||
|
const tType = String(s.vTransitionType ?? s.v_transition_type ?? '')
|
||||||
|
const tDur = Number(s.vTransitionDur ?? s.v_transition_dur ?? 0) || 0
|
||||||
|
const p = tailProgress01(frame, durationFrames, fps, tDur)
|
||||||
|
const outOpacity = (() => {
|
||||||
|
if (tType === 'fade' || tType === 'fadeWhite' || tType === 'blurFade') return 1 - p
|
||||||
|
return 1
|
||||||
|
})()
|
||||||
|
const bgColor = tType === 'fadeWhite' ? '#fff' : 'transparent'
|
||||||
|
|
||||||
|
// css filters(按顺序叠加,必须与后端 FFmpeg 的滤镜意图一致)
|
||||||
|
const filterParts: string[] = []
|
||||||
|
if (tType === 'blurOut') filterParts.push(`blur(${p * 10}px)`)
|
||||||
|
if (tType === 'blurFade') filterParts.push(`blur(${p * 8}px)`)
|
||||||
|
if (tType === 'desaturate') filterParts.push(`saturate(${Math.max(0, 1 - 0.9 * p)})`)
|
||||||
|
if (tType === 'colorPop') {
|
||||||
|
filterParts.push(`saturate(${1 + 0.8 * p})`)
|
||||||
|
filterParts.push(`contrast(${1 + 0.3 * p})`)
|
||||||
|
}
|
||||||
|
if (tType === 'darken') filterParts.push(`brightness(${Math.max(0.1, 1 - 0.4 * p)})`)
|
||||||
|
if (tType === 'hueShift') filterParts.push(`hue-rotate(${60 * p}deg)`)
|
||||||
|
|
||||||
|
// flash:brightness 峰值在 p=0.5
|
||||||
|
if (tType === 'flash') {
|
||||||
|
const k = 1 - Math.abs(0.5 - p) * 2
|
||||||
|
const b = 1 + 0.7 * clamp01(k)
|
||||||
|
filterParts.push(`brightness(${b})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const outFilter = filterParts.length ? filterParts.join(' ') : 'none'
|
||||||
|
const outTransform = (() => {
|
||||||
|
const parts: string[] = []
|
||||||
|
// slide:像素位移(与导出保持同量级)
|
||||||
|
const slidePx = 80 * p
|
||||||
|
if (tType === 'slideLeft') parts.push(`translateX(${-slidePx}px)`)
|
||||||
|
if (tType === 'slideRight') parts.push(`translateX(${slidePx}px)`)
|
||||||
|
if (tType === 'slideUp') parts.push(`translateY(${-slidePx}px)`)
|
||||||
|
if (tType === 'slideDown') parts.push(`translateY(${slidePx}px)`)
|
||||||
|
|
||||||
|
if (tType === 'zoomOut') parts.push(`scale(${1 - 0.10 * p})`)
|
||||||
|
if (tType === 'zoomIn') parts.push(`scale(${1 + 0.10 * p})`)
|
||||||
|
if (tType === 'rotateOut') parts.push(`rotate(${0.12 * p}rad)`)
|
||||||
|
|
||||||
|
return parts.length ? parts.join(' ') : 'none'
|
||||||
|
})()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
opacity: opacity * outOpacity,
|
||||||
|
transform: outTransform,
|
||||||
|
filter: outFilter,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Video
|
||||||
|
src={clip.sourceUrl}
|
||||||
|
startFrom={Math.round((clip.trimStart || 0) * fps)}
|
||||||
|
volume={typeof volume === 'number' ? volume : 1}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字幕组件
|
||||||
|
const SubtitleClip: React.FC<{
|
||||||
|
clip: TimelineClip
|
||||||
|
ui?: UiBridge
|
||||||
|
editing?: { clipId: string | null; draft: string }
|
||||||
|
setEditing?: React.Dispatch<React.SetStateAction<{ clipId: string | null; draft: string }>>
|
||||||
|
}> = ({ clip, ui, editing, setEditing }) => {
|
||||||
|
if (!clip.text) return null
|
||||||
|
|
||||||
|
const frame = useCurrentFrame()
|
||||||
|
const { fps, width, height } = useVideoConfig()
|
||||||
|
|
||||||
|
// 淡入淡出效果
|
||||||
|
const opacity = interpolate(
|
||||||
|
frame,
|
||||||
|
[0, 10, clip.duration * fps - 10, clip.duration * fps],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const style: any = clip.style || {}
|
||||||
|
const isSelected = !!ui?.selectedClipId && ui.selectedClipId === clip.id
|
||||||
|
const isEditing = !!editing?.clipId && editing.clipId === clip.id
|
||||||
|
|
||||||
|
const boxW = (() => {
|
||||||
|
const bw = Number(style.box_w ?? style.boxW ?? style.max_width ?? style.maxWidth ?? 0) || 0
|
||||||
|
if (bw > 0 && bw <= 1) return Math.round(width * bw)
|
||||||
|
if (bw > 1) return Math.round(bw)
|
||||||
|
return Math.round(width * 0.8)
|
||||||
|
})()
|
||||||
|
|
||||||
|
const baseX = normPosPct(clip.position?.x, width, 0.5)
|
||||||
|
const baseY = normPosPct(clip.position?.y, height, (height - 220) / Math.max(1, height))
|
||||||
|
const renderSize = React.useContext(RenderSizeCtx)
|
||||||
|
|
||||||
|
const dragRef = React.useRef<null | { pointerId: number; sx: number; sy: number; bx: number; by: number; started: boolean }>(null)
|
||||||
|
const rafRef = React.useRef<number | null>(null)
|
||||||
|
const widthRef = React.useRef<null | { pointerId: number; sx: number; base: number; started: boolean }>(null)
|
||||||
|
|
||||||
|
const saveEdit = () => {
|
||||||
|
if (!isEditing) return
|
||||||
|
const txt = String(editing?.draft || '').replace(/\u00A0/g, ' ')
|
||||||
|
// 空文本:不保存,避免“看起来像被删掉”
|
||||||
|
if (txt.trim() === '') {
|
||||||
|
setEditing?.({ clipId: null, draft: '' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui?.onPushHistory?.({ label: '编辑文字', icon: 'text' })
|
||||||
|
ui?.onUpdateClip?.(clip.id, { text: txt })
|
||||||
|
setEditing?.({ clipId: null, draft: '' })
|
||||||
|
}
|
||||||
|
const cancelEdit = () => setEditing?.({ clipId: null, draft: '' })
|
||||||
|
|
||||||
|
const startDrag = (e: React.PointerEvent) => {
|
||||||
|
if (!ui?.onUpdateClip) return
|
||||||
|
if (isEditing) return
|
||||||
|
// 不要在 pointerdown 就 preventDefault:会破坏 click/dblclick,导致“无法双击编辑”
|
||||||
|
e.stopPropagation()
|
||||||
|
ui?.onSelectClip?.(clip.id)
|
||||||
|
dragRef.current = { pointerId: e.pointerId, sx: e.clientX, sy: e.clientY, bx: baseX, by: baseY, started: false }
|
||||||
|
const prevSel = document.body.style.userSelect
|
||||||
|
const prevCursor = document.body.style.cursor
|
||||||
|
const denomW = Math.max(1, Number(renderSize.w || 0) || 0) || width
|
||||||
|
const denomH = Math.max(1, Number(renderSize.h || 0) || 0) || height
|
||||||
|
|
||||||
|
const move = (ev: PointerEvent) => {
|
||||||
|
const d = dragRef.current
|
||||||
|
if (!d || d.pointerId !== ev.pointerId) return
|
||||||
|
const dxPx = ev.clientX - d.sx
|
||||||
|
const dyPx = ev.clientY - d.sy
|
||||||
|
if (!d.started) {
|
||||||
|
// 降低阈值:让拖拽更“跟手”(避免松开才明显位移的乏力感)
|
||||||
|
if (Math.abs(dxPx) + Math.abs(dyPx) < 1) return
|
||||||
|
d.started = true
|
||||||
|
ui?.onPushHistory?.({ label: '移动文字', icon: 'text' })
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.body.style.cursor = 'grabbing'
|
||||||
|
}
|
||||||
|
ev.preventDefault?.()
|
||||||
|
const nx = Math.max(0, Math.min(1, d.bx + dxPx / denomW))
|
||||||
|
const ny = Math.max(0, Math.min(1, d.by + dyPx / denomH))
|
||||||
|
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
||||||
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
|
ui?.onUpdateClip?.(clip.id, { position: { x: nx, y: ny } })
|
||||||
|
rafRef.current = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const up = (ev: PointerEvent) => {
|
||||||
|
const d = dragRef.current
|
||||||
|
if (!d || d.pointerId !== ev.pointerId) return
|
||||||
|
dragRef.current = null
|
||||||
|
try { window.removeEventListener('pointermove', move as any) } catch {}
|
||||||
|
try { window.removeEventListener('pointerup', up as any) } catch {}
|
||||||
|
try { window.removeEventListener('pointercancel', up as any) } catch {}
|
||||||
|
document.body.style.userSelect = prevSel
|
||||||
|
document.body.style.cursor = prevCursor
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', move as any, { passive: false } as any)
|
||||||
|
window.addEventListener('pointerup', up as any)
|
||||||
|
window.addEventListener('pointercancel', up as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startResizeW = (e: React.PointerEvent) => {
|
||||||
|
if (!ui?.onUpdateClip) return
|
||||||
|
if (isEditing) return
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
ui?.onSelectClip?.(clip.id)
|
||||||
|
const base = getBoxWPct(style, width, 0.8)
|
||||||
|
widthRef.current = { pointerId: e.pointerId, sx: e.clientX, base, started: false }
|
||||||
|
const prevSel = document.body.style.userSelect
|
||||||
|
const prevCursor = document.body.style.cursor
|
||||||
|
const denomW = Math.max(1, Number(renderSize.w || 0) || 0) || width
|
||||||
|
const move = (ev: PointerEvent) => {
|
||||||
|
const d = widthRef.current
|
||||||
|
if (!d || d.pointerId !== ev.pointerId) return
|
||||||
|
const dxPx = ev.clientX - d.sx
|
||||||
|
if (!d.started) {
|
||||||
|
if (Math.abs(dxPx) < 1) return
|
||||||
|
d.started = true
|
||||||
|
ui?.onPushHistory?.({ label: '调整文字宽度', icon: 'text' })
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.body.style.cursor = 'ew-resize'
|
||||||
|
}
|
||||||
|
ev.preventDefault?.()
|
||||||
|
const next = clamp(d.base + dxPx / denomW, 0.2, 1.0)
|
||||||
|
ui?.onUpdateClip?.(clip.id, { style: { ...(style || {}), box_w: next } })
|
||||||
|
}
|
||||||
|
const up = (ev: PointerEvent) => {
|
||||||
|
const d = widthRef.current
|
||||||
|
if (!d || d.pointerId !== ev.pointerId) return
|
||||||
|
widthRef.current = null
|
||||||
|
try { window.removeEventListener('pointermove', move as any) } catch {}
|
||||||
|
try { window.removeEventListener('pointerup', up as any) } catch {}
|
||||||
|
try { window.removeEventListener('pointercancel', up as any) } catch {}
|
||||||
|
document.body.style.userSelect = prevSel
|
||||||
|
document.body.style.cursor = prevCursor
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', move as any, { passive: false } as any)
|
||||||
|
window.addEventListener('pointerup', up as any)
|
||||||
|
window.addEventListener('pointercancel', up as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterEdit = () => {
|
||||||
|
ui?.onSelectClip?.(clip.id)
|
||||||
|
setEditing?.({ clipId: clip.id, draft: String(clip.text || '') })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: resolvePos(clip.position?.x, width, width * 0.5),
|
||||||
|
top: resolvePos(clip.position?.y, height, height - 220),
|
||||||
|
transform: 'translate(-50%, 0)',
|
||||||
|
width: boxW,
|
||||||
|
opacity,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
touchAction: 'none',
|
||||||
|
}}
|
||||||
|
onPointerDown={startDrag}
|
||||||
|
onClick={(e) => { e.stopPropagation(); ui?.onSelectClip?.(clip.id) }}
|
||||||
|
onDoubleClick={(e) => { e.preventDefault(); e.stopPropagation(); enterEdit() }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: (style.font_size as number) || (style.fontsize as number) || 60,
|
||||||
|
fontWeight: style.bold ? 800 : 600,
|
||||||
|
fontStyle: style.italic ? 'italic' : 'normal',
|
||||||
|
textDecoration: style.underline ? 'underline' : 'none',
|
||||||
|
fontFamily: toCssFontFamily(style.font_family),
|
||||||
|
color: style.font_color || '#FFFFFF',
|
||||||
|
textShadow: style._preview_heavy === false
|
||||||
|
? 'none'
|
||||||
|
: '2px 2px 4px rgba(0,0,0,0.8), -2px -2px 4px rgba(0,0,0,0.8)',
|
||||||
|
padding: '8px 16px',
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '100%',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
textAlign: 'center',
|
||||||
|
outline: isSelected ? '1px solid rgba(88, 166, 255, 0.9)' : 'none',
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: isEditing ? 'text' : 'move',
|
||||||
|
userSelect: isEditing ? 'text' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
<span
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
spellCheck={false}
|
||||||
|
onInput={(e) => {
|
||||||
|
const txt = (e.currentTarget.textContent ?? '').replace(/\u00A0/g, ' ')
|
||||||
|
setEditing?.({ clipId: clip.id, draft: txt })
|
||||||
|
}}
|
||||||
|
onBlur={saveEdit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); return }
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); saveEdit(); return }
|
||||||
|
}}
|
||||||
|
style={{ outline: 'none', display: 'inline-block', width: '100%' }}
|
||||||
|
>
|
||||||
|
{editing?.draft ?? ''}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
clip.text
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{/* 仅选中且非编辑:显示“拉宽”手柄(控制换行 box_w) */}
|
||||||
|
{isSelected && !isEditing && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: -6,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: 'rgba(88,166,255,0.95)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.65)',
|
||||||
|
cursor: 'ew-resize',
|
||||||
|
}}
|
||||||
|
onPointerDown={startResizeW}
|
||||||
|
title="拖动拉宽(控制换行)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 花字组件
|
||||||
|
const FancyTextClip: React.FC<{
|
||||||
|
clip: TimelineClip
|
||||||
|
ui?: UiBridge
|
||||||
|
editing?: { clipId: string | null; draft: string }
|
||||||
|
setEditing?: React.Dispatch<React.SetStateAction<{ clipId: string | null; draft: string }>>
|
||||||
|
}> = ({ clip, ui, editing, setEditing }) => {
|
||||||
|
if (!clip.text) return null
|
||||||
|
|
||||||
|
const frame = useCurrentFrame()
|
||||||
|
const { width, height } = useVideoConfig()
|
||||||
|
|
||||||
|
// 弹入效果
|
||||||
|
const scale = interpolate(
|
||||||
|
frame,
|
||||||
|
[0, 15],
|
||||||
|
[0.5, 1],
|
||||||
|
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const style: any = clip.style || {}
|
||||||
|
const isSelected = !!ui?.selectedClipId && ui.selectedClipId === clip.id
|
||||||
|
const isEditing = !!editing?.clipId && editing.clipId === clip.id
|
||||||
|
const position = clip.position || { x: 0.5, y: 0.2 }
|
||||||
|
const x = resolvePos((position as any).x, width, width * 0.5)
|
||||||
|
const y = resolvePos((position as any).y, height, 180)
|
||||||
|
const baseX = normPosPct((position as any).x, width, 0.5)
|
||||||
|
const baseY = normPosPct((position as any).y, height, 180 / Math.max(1, height))
|
||||||
|
const renderSize = React.useContext(RenderSizeCtx)
|
||||||
|
|
||||||
|
const dragRef = React.useRef<null | { pointerId: number; sx: number; sy: number; bx: number; by: number; started: boolean }>(null)
|
||||||
|
const rafRef = React.useRef<number | null>(null)
|
||||||
|
const widthRef = React.useRef<null | { pointerId: number; sx: number; base: number; started: boolean }>(null)
|
||||||
|
|
||||||
|
const saveEdit = () => {
|
||||||
|
if (!isEditing) return
|
||||||
|
const txt = String(editing?.draft || '').replace(/\u00A0/g, ' ')
|
||||||
|
// 空文本:不保存,避免“看起来像被删掉”
|
||||||
|
if (txt.trim() === '') {
|
||||||
|
setEditing?.({ clipId: null, draft: '' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui?.onPushHistory?.({ label: '编辑文字', icon: 'text' })
|
||||||
|
ui?.onUpdateClip?.(clip.id, { text: txt })
|
||||||
|
setEditing?.({ clipId: null, draft: '' })
|
||||||
|
}
|
||||||
|
const cancelEdit = () => setEditing?.({ clipId: null, draft: '' })
|
||||||
|
|
||||||
|
const startDrag = (e: React.PointerEvent) => {
|
||||||
|
if (!ui?.onUpdateClip) return
|
||||||
|
if (isEditing) return
|
||||||
|
// 不要在 pointerdown 就 preventDefault:会破坏 click/dblclick,导致“无法双击编辑”
|
||||||
|
e.stopPropagation()
|
||||||
|
ui?.onSelectClip?.(clip.id)
|
||||||
|
dragRef.current = { pointerId: e.pointerId, sx: e.clientX, sy: e.clientY, bx: baseX, by: baseY, started: false }
|
||||||
|
const prevSel = document.body.style.userSelect
|
||||||
|
const prevCursor = document.body.style.cursor
|
||||||
|
const denomW = Math.max(1, Number(renderSize.w || 0) || 0) || width
|
||||||
|
const denomH = Math.max(1, Number(renderSize.h || 0) || 0) || height
|
||||||
|
|
||||||
|
const move = (ev: PointerEvent) => {
|
||||||
|
const d = dragRef.current
|
||||||
|
if (!d || d.pointerId !== ev.pointerId) return
|
||||||
|
const dxPx = ev.clientX - d.sx
|
||||||
|
const dyPx = ev.clientY - d.sy
|
||||||
|
if (!d.started) {
|
||||||
|
// 降低阈值:让拖拽更“跟手”(避免松开才明显位移的乏力感)
|
||||||
|
if (Math.abs(dxPx) + Math.abs(dyPx) < 1) return
|
||||||
|
d.started = true
|
||||||
|
ui?.onPushHistory?.({ label: '移动文字', icon: 'text' })
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.body.style.cursor = 'grabbing'
|
||||||
|
}
|
||||||
|
ev.preventDefault?.()
|
||||||
|
const nx = Math.max(0, Math.min(1, d.bx + dxPx / denomW))
|
||||||
|
const ny = Math.max(0, Math.min(1, d.by + dyPx / denomH))
|
||||||
|
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
||||||
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
|
ui?.onUpdateClip?.(clip.id, { position: { x: nx, y: ny } })
|
||||||
|
rafRef.current = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const up = (ev: PointerEvent) => {
|
||||||
|
const d = dragRef.current
|
||||||
|
if (!d || d.pointerId !== ev.pointerId) return
|
||||||
|
dragRef.current = null
|
||||||
|
try { window.removeEventListener('pointermove', move as any) } catch {}
|
||||||
|
try { window.removeEventListener('pointerup', up as any) } catch {}
|
||||||
|
try { window.removeEventListener('pointercancel', up as any) } catch {}
|
||||||
|
document.body.style.userSelect = prevSel
|
||||||
|
document.body.style.cursor = prevCursor
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', move as any, { passive: false } as any)
|
||||||
|
window.addEventListener('pointerup', up as any)
|
||||||
|
window.addEventListener('pointercancel', up as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startResizeW = (e: React.PointerEvent) => {
|
||||||
|
if (!ui?.onUpdateClip) return
|
||||||
|
if (isEditing) return
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
ui?.onSelectClip?.(clip.id)
|
||||||
|
const base = getBoxWPct(style, width, 0.7)
|
||||||
|
widthRef.current = { pointerId: e.pointerId, sx: e.clientX, base, started: false }
|
||||||
|
const prevSel = document.body.style.userSelect
|
||||||
|
const prevCursor = document.body.style.cursor
|
||||||
|
const denomW = Math.max(1, Number(renderSize.w || 0) || 0) || width
|
||||||
|
const move = (ev: PointerEvent) => {
|
||||||
|
const d = widthRef.current
|
||||||
|
if (!d || d.pointerId !== ev.pointerId) return
|
||||||
|
const dxPx = ev.clientX - d.sx
|
||||||
|
if (!d.started) {
|
||||||
|
if (Math.abs(dxPx) < 1) return
|
||||||
|
d.started = true
|
||||||
|
ui?.onPushHistory?.({ label: '调整文字宽度', icon: 'text' })
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.body.style.cursor = 'ew-resize'
|
||||||
|
}
|
||||||
|
ev.preventDefault?.()
|
||||||
|
const next = clamp(d.base + dxPx / denomW, 0.2, 1.0)
|
||||||
|
ui?.onUpdateClip?.(clip.id, { style: { ...(style || {}), box_w: next } })
|
||||||
|
}
|
||||||
|
const up = (ev: PointerEvent) => {
|
||||||
|
const d = widthRef.current
|
||||||
|
if (!d || d.pointerId !== ev.pointerId) return
|
||||||
|
widthRef.current = null
|
||||||
|
try { window.removeEventListener('pointermove', move as any) } catch {}
|
||||||
|
try { window.removeEventListener('pointerup', up as any) } catch {}
|
||||||
|
try { window.removeEventListener('pointercancel', up as any) } catch {}
|
||||||
|
document.body.style.userSelect = prevSel
|
||||||
|
document.body.style.cursor = prevCursor
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', move as any, { passive: false } as any)
|
||||||
|
window.addEventListener('pointerup', up as any)
|
||||||
|
window.addEventListener('pointercancel', up as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterEdit = () => {
|
||||||
|
ui?.onSelectClip?.(clip.id)
|
||||||
|
setEditing?.({ clipId: clip.id, draft: String(clip.text || '') })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
transform: `translate(-50%, 0) scale(${scale})`,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
touchAction: 'none',
|
||||||
|
}}
|
||||||
|
onPointerDown={startDrag}
|
||||||
|
onClick={(e) => { e.stopPropagation(); ui?.onSelectClip?.(clip.id) }}
|
||||||
|
onDoubleClick={(e) => { e.preventDefault(); e.stopPropagation(); enterEdit() }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: (style.font_size as number) || 72,
|
||||||
|
fontWeight: (style as any).bold ? 800 : 700,
|
||||||
|
fontStyle: (style as any).italic ? 'italic' : 'normal',
|
||||||
|
textDecoration: (style as any).underline ? 'underline' : 'none',
|
||||||
|
fontFamily: toCssFontFamily((style as any).font_family),
|
||||||
|
color: (style.font_color as string) || '#FFFFFF',
|
||||||
|
textShadow: '3px 3px 6px rgba(0,0,0,0.9), -3px -3px 6px rgba(0,0,0,0.9)',
|
||||||
|
display: 'inline-block',
|
||||||
|
maxWidth: (() => {
|
||||||
|
const bw = Number((style as any).box_w ?? (style as any).boxW ?? (style as any).max_width ?? (style as any).maxWidth ?? 0) || 0
|
||||||
|
if (bw > 0 && bw <= 1) return Math.round(width * bw)
|
||||||
|
if (bw > 1) return Math.round(bw)
|
||||||
|
return Math.round(width * 0.7)
|
||||||
|
})(),
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
textAlign: 'center',
|
||||||
|
outline: isSelected ? '1px solid rgba(88, 166, 255, 0.9)' : 'none',
|
||||||
|
borderRadius: 10,
|
||||||
|
cursor: isEditing ? 'text' : 'move',
|
||||||
|
userSelect: isEditing ? 'text' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
<span
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
spellCheck={false}
|
||||||
|
onInput={(e) => {
|
||||||
|
const txt = (e.currentTarget.textContent ?? '').replace(/\u00A0/g, ' ')
|
||||||
|
setEditing?.({ clipId: clip.id, draft: txt })
|
||||||
|
}}
|
||||||
|
onBlur={saveEdit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); return }
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); saveEdit(); return }
|
||||||
|
}}
|
||||||
|
style={{ outline: 'none', display: 'inline-block' }}
|
||||||
|
>
|
||||||
|
{editing?.draft ?? ''}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
clip.text
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{/* 仅选中且非编辑:显示“拉宽”手柄(控制换行 box_w) */}
|
||||||
|
{isSelected && !isEditing && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: -6,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: 'rgba(88,166,255,0.95)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.65)',
|
||||||
|
cursor: 'ew-resize',
|
||||||
|
}}
|
||||||
|
onPointerDown={startResizeW}
|
||||||
|
title="拖动拉宽(控制换行)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 贴纸组件
|
||||||
|
const StickerClip: React.FC<{
|
||||||
|
clip: TimelineClip
|
||||||
|
}> = ({ clip }) => {
|
||||||
|
if (!clip.sourceUrl) return null
|
||||||
|
const { width, height } = useVideoConfig()
|
||||||
|
const style: any = clip.style || {}
|
||||||
|
const position = clip.position || { x: 0.8, y: 0.2 }
|
||||||
|
const x = resolvePos((position as any).x, width, width * 0.8)
|
||||||
|
const y = resolvePos((position as any).y, height, height * 0.2)
|
||||||
|
const scale = Math.max(0.3, Math.min(3.0, Number(style.scale ?? 1) || 1))
|
||||||
|
const rotate = Number(style.rotate ?? 0) || 0
|
||||||
|
const base = Math.round(Math.min(width, height) * 0.22)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
transform: `translate(-50%, -50%) scale(${scale}) rotate(${rotate}deg)`,
|
||||||
|
transformOrigin: 'center',
|
||||||
|
width: base,
|
||||||
|
height: base,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Img src={clip.sourceUrl} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 音频组件
|
||||||
|
const AudioClip: React.FC<{
|
||||||
|
clip: TimelineClip
|
||||||
|
volume?: (frame: number) => number
|
||||||
|
}> = ({ clip, volume }) => {
|
||||||
|
if (!clip.sourceUrl) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Audio
|
||||||
|
src={clip.sourceUrl}
|
||||||
|
volume={volume ?? (clip.volume || 1)}
|
||||||
|
playbackRate={Number((clip as any).playbackRate ?? 1) || 1}
|
||||||
|
loop={Boolean((clip as any).loop ?? (clip as any).style?.loop)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Props
|
||||||
|
export interface VideoCompositionProps {
|
||||||
|
tracks: Track[]
|
||||||
|
fps?: number
|
||||||
|
subtitleStyle?: Record<string, unknown>
|
||||||
|
preview?: {
|
||||||
|
heavyOverlays?: boolean
|
||||||
|
}
|
||||||
|
audio?: {
|
||||||
|
soloTrackIds?: string[]
|
||||||
|
}
|
||||||
|
ui?: UiBridge
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主合成组件
|
||||||
|
export const VideoComposition: React.FC<VideoCompositionProps> = ({
|
||||||
|
tracks,
|
||||||
|
subtitleStyle,
|
||||||
|
preview,
|
||||||
|
audio,
|
||||||
|
ui,
|
||||||
|
}) => {
|
||||||
|
const { fps } = useVideoConfig()
|
||||||
|
const heavy = preview?.heavyOverlays !== false
|
||||||
|
const soloSet = new Set((audio?.soloTrackIds || []).filter(Boolean))
|
||||||
|
const hasSolo = soloSet.size > 0
|
||||||
|
|
||||||
|
const [editing, setEditing] = React.useState<{ clipId: string | null; draft: string }>({ clipId: null, draft: '' })
|
||||||
|
const [renderSize, setRenderSize] = React.useState<{ w: number; h: number }>({ w: 0, h: 0 })
|
||||||
|
const sizeProbeRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = sizeProbeRef.current
|
||||||
|
if (!el) return
|
||||||
|
const update = () => {
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
const w = Math.round(r.width || 0)
|
||||||
|
const h = Math.round(r.height || 0)
|
||||||
|
if (w > 0 && h > 0) setRenderSize({ w, h })
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
const ro = new ResizeObserver(() => update())
|
||||||
|
try { ro.observe(el) } catch {}
|
||||||
|
const id = window.setInterval(update, 500) // 兜底:部分浏览器 ResizeObserver 偶发不触发
|
||||||
|
return () => {
|
||||||
|
try { ro.disconnect() } catch {}
|
||||||
|
clearInterval(id)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 分离不同类型的轨道
|
||||||
|
const videoTrack = tracks.find(t => t.type === 'video')
|
||||||
|
const subtitleTrack = tracks.find(t => t.type === 'subtitle')
|
||||||
|
const fancyTextTrack = tracks.find(t => t.type === 'fancy_text')
|
||||||
|
const stickerTrack = tracks.find(t => t.type === 'sticker')
|
||||||
|
const voiceoverTrack = tracks.find(t => t.id === 'audio-voiceover')
|
||||||
|
const bgmTrack = tracks.find(t => t.type === 'bgm')
|
||||||
|
|
||||||
|
// 预计算旁白区间(用于 BGM ducking)
|
||||||
|
const voRanges = (voiceoverTrack?.clips || []).map(c => {
|
||||||
|
const s = c.start ?? 0
|
||||||
|
const e = s + (c.duration ?? 0)
|
||||||
|
return { s, e }
|
||||||
|
})
|
||||||
|
const isVoiceAt = (t: number) => voRanges.some(r => t >= r.s && t <= r.e)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RenderSizeCtx.Provider value={renderSize}>
|
||||||
|
<AbsoluteFill style={{ backgroundColor: '#000' }}>
|
||||||
|
{/* 仅用于测量“实际渲染像素尺寸”(用于拖拽严格跟随鼠标) */}
|
||||||
|
<div ref={sizeProbeRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
|
||||||
|
{/* 视频层 */}
|
||||||
|
{videoTrack?.clips.map(clip => (
|
||||||
|
<Sequence
|
||||||
|
key={clip.id}
|
||||||
|
from={(() => {
|
||||||
|
// 用 floor/ceil 避免浮点取整导致的“1帧黑屏缝隙”
|
||||||
|
const s = Math.max(0, clip.start ?? 0)
|
||||||
|
return Math.floor(s * fps)
|
||||||
|
})()}
|
||||||
|
durationInFrames={(() => {
|
||||||
|
const s = Math.max(0, clip.start ?? 0)
|
||||||
|
const e = s + Math.max(0, clip.duration ?? 0)
|
||||||
|
const startF = Math.floor(s * fps)
|
||||||
|
const endF = Math.max(startF + 1, Math.ceil(e * fps))
|
||||||
|
return endF - startF
|
||||||
|
})()}
|
||||||
|
>
|
||||||
|
<VideoClip
|
||||||
|
clip={clip}
|
||||||
|
volume={
|
||||||
|
(videoTrack && (!hasSolo || soloSet.has(videoTrack.id)) && videoTrack.muted !== true)
|
||||||
|
? ((clip.volume ?? 0.2) as number)
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Sequence>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 贴纸层(在文字下方,避免挡字幕;可后续支持层级调整) */}
|
||||||
|
{stickerTrack?.visible !== false && stickerTrack?.clips?.map(clip => (
|
||||||
|
<Sequence
|
||||||
|
key={clip.id}
|
||||||
|
from={Math.round((clip.start ?? 0) * fps)}
|
||||||
|
durationInFrames={Math.round((clip.duration ?? 0) * fps)}
|
||||||
|
>
|
||||||
|
<StickerClip clip={clip} />
|
||||||
|
</Sequence>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 花字层 */}
|
||||||
|
{fancyTextTrack?.visible !== false && fancyTextTrack?.clips.map(clip => (
|
||||||
|
<Sequence
|
||||||
|
key={clip.id}
|
||||||
|
from={Math.round(clip.start * fps)}
|
||||||
|
durationInFrames={Math.round(clip.duration * fps)}
|
||||||
|
>
|
||||||
|
<FancyTextClip
|
||||||
|
clip={{ ...clip, style: { ...(clip.style || {}), _preview_heavy: heavy } }}
|
||||||
|
ui={ui}
|
||||||
|
editing={editing}
|
||||||
|
setEditing={setEditing}
|
||||||
|
/>
|
||||||
|
</Sequence>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 字幕层 */}
|
||||||
|
{subtitleTrack?.visible !== false && subtitleTrack?.clips.map(clip => (
|
||||||
|
<Sequence
|
||||||
|
key={clip.id}
|
||||||
|
from={Math.round(clip.start * fps)}
|
||||||
|
durationInFrames={Math.round(clip.duration * fps)}
|
||||||
|
>
|
||||||
|
<SubtitleClip
|
||||||
|
clip={{ ...clip, style: { ...(subtitleStyle || {}), ...(clip.style || {}), _preview_heavy: heavy } }}
|
||||||
|
ui={ui}
|
||||||
|
editing={editing}
|
||||||
|
setEditing={setEditing}
|
||||||
|
/>
|
||||||
|
</Sequence>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 旁白音频 */}
|
||||||
|
{(voiceoverTrack && (!hasSolo || soloSet.has(voiceoverTrack.id)) && voiceoverTrack?.muted !== true) && voiceoverTrack.clips.map(clip => (
|
||||||
|
<Sequence
|
||||||
|
key={clip.id}
|
||||||
|
from={Math.round(clip.start * fps)}
|
||||||
|
durationInFrames={Math.round(clip.duration * fps)}
|
||||||
|
>
|
||||||
|
<AudioClip
|
||||||
|
clip={clip}
|
||||||
|
volume={(frame) => {
|
||||||
|
const from = Math.round((clip.start ?? 0) * fps)
|
||||||
|
const durF = Math.max(1, Math.round((clip.duration ?? 0) * fps))
|
||||||
|
const local = frame - from
|
||||||
|
const fade = makeFade(local, durF, fps, clip.fadeIn, clip.fadeOut)
|
||||||
|
return clamp01((clip.volume ?? 1) * fade)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Sequence>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* BGM */}
|
||||||
|
{(bgmTrack && (!hasSolo || soloSet.has(bgmTrack.id)) && bgmTrack?.muted !== true) && bgmTrack.clips.map(clip => (
|
||||||
|
<Sequence
|
||||||
|
key={clip.id}
|
||||||
|
from={Math.round(clip.start * fps)}
|
||||||
|
durationInFrames={Math.round(clip.duration * fps)}
|
||||||
|
>
|
||||||
|
<AudioClip
|
||||||
|
clip={clip}
|
||||||
|
volume={(frame) => {
|
||||||
|
const from = Math.round((clip.start ?? 0) * fps)
|
||||||
|
const durF = Math.max(1, Math.round((clip.duration ?? 0) * fps))
|
||||||
|
const local = frame - from
|
||||||
|
const t = frame / fps
|
||||||
|
const fade = makeFade(local, durF, fps, clip.fadeIn ?? 0.8, clip.fadeOut ?? 0.8)
|
||||||
|
const base = (clip.volume ?? 0.15) as number
|
||||||
|
const duckEnabled = typeof clip.ducking === 'boolean' ? clip.ducking : true
|
||||||
|
const duckVol = (clip.duckVolume ?? 0.25) as number
|
||||||
|
const duck = duckEnabled && isVoiceAt(t) ? duckVol : 1
|
||||||
|
return clamp01(base * fade * duck)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Sequence>
|
||||||
|
))}
|
||||||
|
</AbsoluteFill>
|
||||||
|
</RenderSizeCtx.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VideoComposition
|
||||||
|
|
||||||
18
web/src/remotion/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Remotion 组件导出
|
||||||
|
*/
|
||||||
|
export { VideoComposition } from './VideoComposition'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
734
web/src/store/editorStore.ts
Normal file
@@ -0,0 +1,734 @@
|
|||||||
|
/**
|
||||||
|
* 编辑器状态管理 (Zustand)
|
||||||
|
* 管理时间轴、轨道、播放状态
|
||||||
|
*/
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { immer } from 'zustand/middleware/immer'
|
||||||
|
import { generateId } from '@/lib/utils'
|
||||||
|
|
||||||
|
// 类型定义
|
||||||
|
export interface TimelineClip {
|
||||||
|
id: string
|
||||||
|
type: 'video' | 'audio' | 'subtitle' | 'fancy_text' | 'bgm' | 'sticker'
|
||||||
|
start: number
|
||||||
|
duration: number
|
||||||
|
sourcePath?: string
|
||||||
|
sourceUrl?: string
|
||||||
|
trimStart?: number
|
||||||
|
trimEnd?: number
|
||||||
|
sourceDuration?: number
|
||||||
|
text?: string
|
||||||
|
style?: Record<string, unknown>
|
||||||
|
position?: { x: string | number; y: string | number }
|
||||||
|
volume?: number
|
||||||
|
fadeIn?: number
|
||||||
|
fadeOut?: number
|
||||||
|
// 仅 BGM:自动闪避(有人声时降低到 duckVolume 倍)
|
||||||
|
ducking?: boolean
|
||||||
|
duckVolume?: number
|
||||||
|
// 旁白:纯播放倍速(预览=导出)。1=正常,0.5=慢,2=快
|
||||||
|
playbackRate?: number
|
||||||
|
groupId?: string
|
||||||
|
/**
|
||||||
|
* 仅旁白:当用户修改了文本/时长后,现有配音可能不再匹配。
|
||||||
|
* 用于 UX 提示(不参与导出逻辑、也不需要持久化)。
|
||||||
|
*/
|
||||||
|
needsVoiceoverRegenerate?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Track {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
clips: TimelineClip[]
|
||||||
|
locked: boolean
|
||||||
|
visible: boolean
|
||||||
|
muted: boolean
|
||||||
|
collapsed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEntry {
|
||||||
|
tracks: Track[]
|
||||||
|
label: string
|
||||||
|
icon?: string
|
||||||
|
ts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorState {
|
||||||
|
// 项目信息
|
||||||
|
projectId: string | null
|
||||||
|
|
||||||
|
// 时间轴状态
|
||||||
|
tracks: Track[]
|
||||||
|
totalDuration: number
|
||||||
|
currentTime: number
|
||||||
|
isPlaying: boolean
|
||||||
|
|
||||||
|
// 编辑策略
|
||||||
|
rippleMode: boolean
|
||||||
|
|
||||||
|
// 音频:轨道 Solo(任意 Solo 时,仅 Solo 轨道参与预览播放)
|
||||||
|
soloTrackIds: string[]
|
||||||
|
|
||||||
|
// 字幕全局样式(MVP)
|
||||||
|
subtitleStyle: Record<string, unknown>
|
||||||
|
|
||||||
|
// 预览性能档位(不影响最终导出)
|
||||||
|
previewFps: number
|
||||||
|
previewScale: number
|
||||||
|
previewHeavyOverlays: boolean
|
||||||
|
|
||||||
|
// 缩放和滚动
|
||||||
|
zoom: number
|
||||||
|
scrollX: number
|
||||||
|
|
||||||
|
// 选中状态
|
||||||
|
selectedClipId: string | null
|
||||||
|
selectedTrackId: string | null
|
||||||
|
selectedClipIds: string[]
|
||||||
|
|
||||||
|
// 辅助线(吸附/对齐提示)
|
||||||
|
snapGuideTime: number | null
|
||||||
|
|
||||||
|
// 撤销/重做栈(最多 30 步)
|
||||||
|
history: HistoryEntry[]
|
||||||
|
historyIndex: number
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
isLoading: boolean
|
||||||
|
isSaving: boolean
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setProjectId: (id: string) => void
|
||||||
|
setTracks: (tracks: Track[]) => void
|
||||||
|
setTotalDuration: (duration: number) => void
|
||||||
|
setCurrentTime: (time: number) => void
|
||||||
|
setIsPlaying: (playing: boolean) => void
|
||||||
|
setRippleMode: (enabled: boolean) => void
|
||||||
|
toggleTrackSolo: (trackId: string) => void
|
||||||
|
setSubtitleStyle: (style: Record<string, unknown>) => void
|
||||||
|
setPreviewQuality: (q: Partial<Pick<EditorState, 'previewFps' | 'previewScale' | 'previewHeavyOverlays'>>) => void
|
||||||
|
setZoom: (zoom: number) => void
|
||||||
|
setScrollX: (x: number) => void
|
||||||
|
|
||||||
|
// 选择
|
||||||
|
selectClip: (clipId: string | null) => void
|
||||||
|
selectTrack: (trackId: string | null) => void
|
||||||
|
setSelectedClips: (clipIds: string[]) => void
|
||||||
|
toggleClipSelection: (clipId: string, mode?: 'replace' | 'toggle' | 'add') => void
|
||||||
|
clearSelection: () => void
|
||||||
|
setSnapGuideTime: (t: number | null) => void
|
||||||
|
|
||||||
|
// 分组(跨轨联动)
|
||||||
|
groupSelected: (groupId?: string) => void
|
||||||
|
ungroupSelected: () => void
|
||||||
|
shiftGroup: (groupId: string, delta: number) => void
|
||||||
|
|
||||||
|
// 片段操作
|
||||||
|
addClip: (trackId: string, clip: Omit<TimelineClip, 'id'>) => void
|
||||||
|
updateClip: (trackId: string, clipId: string, updates: Partial<TimelineClip>) => void
|
||||||
|
deleteClip: (trackId: string, clipId: string) => void
|
||||||
|
deleteSelectedClips: () => void
|
||||||
|
deleteAtPlayhead: () => void
|
||||||
|
moveClip: (fromTrackId: string, toTrackId: string, clipId: string, newStart: number) => void
|
||||||
|
splitClip: (trackId: string, clipId: string, time: number) => void
|
||||||
|
splitAtTime: (time: number) => void
|
||||||
|
rippleShiftFrom: (trackId: string, clipId: string, delta: number) => void
|
||||||
|
|
||||||
|
// 轨道操作
|
||||||
|
addTrack: (track: Omit<Track, 'id'>) => void
|
||||||
|
updateTrack: (trackId: string, updates: Partial<Track>) => void
|
||||||
|
deleteTrack: (trackId: string) => void
|
||||||
|
toggleTrackMute: (trackId: string) => void
|
||||||
|
toggleTrackLock: (trackId: string) => void
|
||||||
|
toggleTrackCollapse: (trackId: string) => void
|
||||||
|
|
||||||
|
// 历史操作
|
||||||
|
pushHistory: (meta?: { label?: string; icon?: string }) => void
|
||||||
|
undo: () => void
|
||||||
|
redo: () => void
|
||||||
|
jumpToHistory: (index: number) => void
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
projectId: null,
|
||||||
|
tracks: [],
|
||||||
|
totalDuration: 0,
|
||||||
|
currentTime: 0,
|
||||||
|
isPlaying: false,
|
||||||
|
rippleMode: true,
|
||||||
|
soloTrackIds: [],
|
||||||
|
subtitleStyle: {
|
||||||
|
fontsize: 60,
|
||||||
|
fontcolor: 'white',
|
||||||
|
borderw: 5,
|
||||||
|
bordercolor: 'black',
|
||||||
|
box: 1,
|
||||||
|
boxcolor: 'black@0.5',
|
||||||
|
boxborderw: 10,
|
||||||
|
x: '(w-text_w)/2',
|
||||||
|
y: 'h-200',
|
||||||
|
},
|
||||||
|
previewFps: 30,
|
||||||
|
previewScale: 1,
|
||||||
|
previewHeavyOverlays: true,
|
||||||
|
zoom: 1,
|
||||||
|
scrollX: 0,
|
||||||
|
selectedClipId: null,
|
||||||
|
selectedTrackId: null,
|
||||||
|
selectedClipIds: [],
|
||||||
|
snapGuideTime: null,
|
||||||
|
history: [],
|
||||||
|
historyIndex: -1,
|
||||||
|
isLoading: false,
|
||||||
|
isSaving: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEditorStore = create<EditorState>()(
|
||||||
|
immer((set) => {
|
||||||
|
const recalcTotalDuration = (tracks: Track[]) => {
|
||||||
|
let maxEnd = 0
|
||||||
|
for (const t of tracks || []) {
|
||||||
|
for (const c of t.clips || []) {
|
||||||
|
const end = (c.start || 0) + (c.duration || 0)
|
||||||
|
if (end > maxEnd) maxEnd = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampVideoDur = (c: TimelineClip, minDur: number) => {
|
||||||
|
let trimStart = c.trimStart ?? 0
|
||||||
|
let duration = Math.max(minDur, c.duration ?? minDur)
|
||||||
|
if (typeof c.sourceDuration === 'number' && c.sourceDuration > 0) {
|
||||||
|
// trimStart 不能超过 sourceDuration-minDur,否则会出现 trimEnd 超界导致预览卡住
|
||||||
|
trimStart = Math.max(0, Math.min(trimStart, Math.max(0, c.sourceDuration - minDur)))
|
||||||
|
duration = Math.min(duration, Math.max(minDur, c.sourceDuration - trimStart))
|
||||||
|
}
|
||||||
|
c.trimStart = trimStart
|
||||||
|
c.duration = duration
|
||||||
|
c.trimEnd = trimStart + duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:强制同轨道视频永不重叠(默认 Ripple:发生碰撞时推挤后续片段)
|
||||||
|
// 规则:
|
||||||
|
// - 不允许与前一个片段重叠:当前片段会被 clamp 到 prevEnd
|
||||||
|
// - 不允许与后一个片段重叠:后续片段会被整体 push 到不重叠(Ripple)
|
||||||
|
const enforceVideoNoOverlap = (track: Track) => {
|
||||||
|
if (!track || track.type !== 'video') return
|
||||||
|
const minDur = 0.5
|
||||||
|
track.clips.sort((a, b) => (a.start ?? 0) - (b.start ?? 0))
|
||||||
|
|
||||||
|
// 基础数据修正 + sourceDuration 保护
|
||||||
|
for (const c of track.clips) {
|
||||||
|
if (typeof c.start !== 'number' || !Number.isFinite(c.start)) c.start = 0
|
||||||
|
if (typeof c.duration !== 'number' || !Number.isFinite(c.duration)) c.duration = minDur
|
||||||
|
c.start = Math.max(0, c.start)
|
||||||
|
clampVideoDur(c, minDur)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ripple 推挤:确保每个 clip.start >= prevEnd
|
||||||
|
let prevEnd = 0
|
||||||
|
for (let i = 0; i < track.clips.length; i++) {
|
||||||
|
const c = track.clips[i]
|
||||||
|
const s = c.start ?? 0
|
||||||
|
if (s < prevEnd - 1e-6) {
|
||||||
|
c.start = prevEnd
|
||||||
|
}
|
||||||
|
prevEnd = (c.start ?? 0) + (c.duration ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
setProjectId: (id) => set({ projectId: id }),
|
||||||
|
|
||||||
|
setTracks: (tracks) => set({
|
||||||
|
tracks,
|
||||||
|
totalDuration: recalcTotalDuration(tracks),
|
||||||
|
}),
|
||||||
|
|
||||||
|
setTotalDuration: (duration) => set({ totalDuration: duration }),
|
||||||
|
|
||||||
|
setCurrentTime: (time) => set({ currentTime: time }),
|
||||||
|
|
||||||
|
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||||
|
|
||||||
|
// Ripple 默认强制开启(去掉开关,避免用户困惑)
|
||||||
|
setRippleMode: (_enabled) => set({ rippleMode: true }),
|
||||||
|
|
||||||
|
toggleTrackSolo: (trackId) => set((state) => {
|
||||||
|
const cur = new Set(state.soloTrackIds || [])
|
||||||
|
if (cur.has(trackId)) cur.delete(trackId)
|
||||||
|
else cur.add(trackId)
|
||||||
|
state.soloTrackIds = Array.from(cur)
|
||||||
|
}),
|
||||||
|
|
||||||
|
setSubtitleStyle: (style) => set((state) => {
|
||||||
|
state.subtitleStyle = { ...(state.subtitleStyle || {}), ...(style || {}) }
|
||||||
|
}),
|
||||||
|
|
||||||
|
setPreviewQuality: (q) => set((state) => {
|
||||||
|
if (typeof q.previewFps === 'number' && q.previewFps > 0) state.previewFps = q.previewFps
|
||||||
|
if (typeof q.previewScale === 'number' && q.previewScale > 0) state.previewScale = q.previewScale
|
||||||
|
if (typeof q.previewHeavyOverlays === 'boolean') state.previewHeavyOverlays = q.previewHeavyOverlays
|
||||||
|
}),
|
||||||
|
|
||||||
|
setZoom: (zoom) => set({ zoom: Math.max(0.1, Math.min(10, zoom)) }),
|
||||||
|
|
||||||
|
setScrollX: (x) => set({ scrollX: Math.max(0, x) }),
|
||||||
|
|
||||||
|
selectClip: (clipId) => set((state) => {
|
||||||
|
state.selectedClipId = clipId
|
||||||
|
state.selectedClipIds = clipId ? [clipId] : []
|
||||||
|
// 自动推断 trackId,方便快捷键/属性面板
|
||||||
|
if (!clipId) {
|
||||||
|
state.selectedTrackId = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const t of state.tracks) {
|
||||||
|
if (t.clips.some(c => c.id === clipId)) {
|
||||||
|
state.selectedTrackId = t.id
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
selectTrack: (trackId) => set({ selectedTrackId: trackId }),
|
||||||
|
|
||||||
|
setSelectedClips: (clipIds) => set((state) => {
|
||||||
|
const uniq = Array.from(new Set((clipIds || []).filter(Boolean)))
|
||||||
|
state.selectedClipIds = uniq
|
||||||
|
state.selectedClipId = uniq.length ? uniq[uniq.length - 1] : null
|
||||||
|
// 尝试推断 selectedTrackId
|
||||||
|
state.selectedTrackId = null
|
||||||
|
if (state.selectedClipId) {
|
||||||
|
for (const t of state.tracks) {
|
||||||
|
if (t.clips.some(c => c.id === state.selectedClipId)) {
|
||||||
|
state.selectedTrackId = t.id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
toggleClipSelection: (clipId, mode = 'replace') => set((state) => {
|
||||||
|
const cur = new Set(state.selectedClipIds || [])
|
||||||
|
if (mode === 'replace') {
|
||||||
|
state.selectedClipIds = clipId ? [clipId] : []
|
||||||
|
state.selectedClipId = clipId || null
|
||||||
|
} else if (mode === 'toggle') {
|
||||||
|
if (cur.has(clipId)) cur.delete(clipId); else cur.add(clipId)
|
||||||
|
state.selectedClipIds = Array.from(cur)
|
||||||
|
state.selectedClipId = state.selectedClipIds.length ? state.selectedClipIds[state.selectedClipIds.length - 1] : null
|
||||||
|
} else {
|
||||||
|
cur.add(clipId)
|
||||||
|
state.selectedClipIds = Array.from(cur)
|
||||||
|
state.selectedClipId = clipId
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推断 trackId
|
||||||
|
state.selectedTrackId = null
|
||||||
|
if (state.selectedClipId) {
|
||||||
|
for (const t of state.tracks) {
|
||||||
|
if (t.clips.some(c => c.id === state.selectedClipId)) {
|
||||||
|
state.selectedTrackId = t.id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
clearSelection: () => set((state) => {
|
||||||
|
state.selectedClipId = null
|
||||||
|
state.selectedTrackId = null
|
||||||
|
state.selectedClipIds = []
|
||||||
|
}),
|
||||||
|
|
||||||
|
setSnapGuideTime: (t) => set((state) => {
|
||||||
|
state.snapGuideTime = t
|
||||||
|
}),
|
||||||
|
|
||||||
|
addClip: (trackId, clip) => set((state) => {
|
||||||
|
const track = state.tracks.find(t => t.id === trackId)
|
||||||
|
if (track && !track.locked) {
|
||||||
|
const newClip = { ...clip, id: generateId() }
|
||||||
|
track.clips.push(newClip as TimelineClip)
|
||||||
|
track.clips.sort((a, b) => a.start - b.start)
|
||||||
|
if (track.type === 'video') enforceVideoNoOverlap(track)
|
||||||
|
state.totalDuration = recalcTotalDuration(state.tracks)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateClip: (trackId, clipId, updates) => set((state) => {
|
||||||
|
const track = state.tracks.find(t => t.id === trackId)
|
||||||
|
if (!track) return
|
||||||
|
|
||||||
|
// 轨道“锁定:防误触”主要防止误拖时间轴上的 start/duration。
|
||||||
|
// 但字幕/花字/贴纸的画面内位置/样式/文本属于“有意操作”,锁定也应允许,否则会出现“选中但拖不动”。
|
||||||
|
if (track.locked) {
|
||||||
|
const allowTrack = track.type === 'subtitle' || track.type === 'fancy_text' || track.type === 'sticker'
|
||||||
|
const keys = Object.keys(updates || {})
|
||||||
|
const allowKeys = keys.every(k => k === 'position' || k === 'style' || k === 'text')
|
||||||
|
if (!allowTrack || !allowKeys) return
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const clip = track.clips.find(c => c.id === clipId)
|
||||||
|
if (clip) {
|
||||||
|
const prevDur = clip.duration
|
||||||
|
const prevText = clip.text
|
||||||
|
Object.assign(clip, updates)
|
||||||
|
|
||||||
|
// UX:旁白片段被拉伸/改文案后,提醒用户“需要重新生成以适配时长/文本”
|
||||||
|
if (trackId === 'audio-voiceover') {
|
||||||
|
const textChanged = typeof updates.text === 'string' && updates.text !== prevText
|
||||||
|
|
||||||
|
// 如果用户更新了音频来源(重新生成成功),就清除提示
|
||||||
|
const hasNewAudio = typeof updates.sourceUrl === 'string' || typeof updates.sourcePath === 'string'
|
||||||
|
if (hasNewAudio) {
|
||||||
|
clip.needsVoiceoverRegenerate = false
|
||||||
|
} else if (textChanged && (clip.text || '').trim().length > 0) {
|
||||||
|
clip.needsVoiceoverRegenerate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 纯播放倍速:当用户改动旁白片段时长,自动计算倍速以“贴合当前时长”
|
||||||
|
if (typeof updates.duration === 'number' && Number.isFinite(updates.duration) && updates.duration > 0) {
|
||||||
|
const srcDur = typeof clip.sourceDuration === 'number' && clip.sourceDuration > 0
|
||||||
|
? clip.sourceDuration
|
||||||
|
: (typeof prevDur === 'number' && prevDur > 0 ? prevDur : undefined)
|
||||||
|
if (srcDur) {
|
||||||
|
const r = srcDur / Math.max(0.01, clip.duration)
|
||||||
|
clip.playbackRate = Math.max(0.5, Math.min(2.0, Number(r) || 1))
|
||||||
|
// 拉长/缩短已由倍速适配,不需要“重新生成”提示
|
||||||
|
clip.needsVoiceoverRegenerate = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.type === 'video') {
|
||||||
|
enforceVideoNoOverlap(track)
|
||||||
|
}
|
||||||
|
state.totalDuration = recalcTotalDuration(state.tracks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteClip: (trackId, clipId) => set((state) => {
|
||||||
|
const track = state.tracks.find(t => t.id === trackId)
|
||||||
|
if (track && !track.locked) {
|
||||||
|
track.clips = track.clips.filter(c => c.id !== clipId)
|
||||||
|
state.totalDuration = recalcTotalDuration(state.tracks)
|
||||||
|
}
|
||||||
|
if (state.selectedClipId === clipId) {
|
||||||
|
state.selectedClipId = null
|
||||||
|
}
|
||||||
|
if (state.selectedClipIds?.includes(clipId)) {
|
||||||
|
state.selectedClipIds = state.selectedClipIds.filter(id => id !== clipId)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteSelectedClips: () => set((state) => {
|
||||||
|
const ids = new Set((state.selectedClipIds || []).filter(Boolean))
|
||||||
|
if (!ids.size) return
|
||||||
|
for (const t of state.tracks) {
|
||||||
|
if (t.locked) continue
|
||||||
|
const before = t.clips.length
|
||||||
|
t.clips = t.clips.filter(c => !ids.has(c.id))
|
||||||
|
if (t.type === 'video' && t.clips.length !== before) {
|
||||||
|
enforceVideoNoOverlap(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.selectedClipIds = []
|
||||||
|
state.selectedClipId = null
|
||||||
|
state.selectedTrackId = null
|
||||||
|
state.totalDuration = recalcTotalDuration(state.tracks)
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteAtPlayhead: () => set((state) => {
|
||||||
|
const t = Math.max(0, state.currentTime ?? 0)
|
||||||
|
// 优先删“当前选中”
|
||||||
|
if (state.selectedClipId && state.selectedTrackId) {
|
||||||
|
const tr = state.tracks.find(x => x.id === state.selectedTrackId)
|
||||||
|
if (tr && !tr.locked) {
|
||||||
|
const exists = tr.clips.find(c => c.id === state.selectedClipId)
|
||||||
|
if (exists) {
|
||||||
|
tr.clips = tr.clips.filter(c => c.id !== state.selectedClipId)
|
||||||
|
if (tr.type === 'video') enforceVideoNoOverlap(tr)
|
||||||
|
state.selectedClipId = null
|
||||||
|
state.selectedTrackId = null
|
||||||
|
state.selectedClipIds = (state.selectedClipIds || []).filter(id => id !== exists.id)
|
||||||
|
state.totalDuration = recalcTotalDuration(state.tracks)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则删红线所在的“视频片段”
|
||||||
|
const vTrack = state.tracks.find(x => x.type === 'video')
|
||||||
|
if (!vTrack || vTrack.locked) return
|
||||||
|
const clip = (vTrack.clips || []).find(c => t >= (c.start ?? 0) && t < ((c.start ?? 0) + (c.duration ?? 0)))
|
||||||
|
if (!clip) return
|
||||||
|
vTrack.clips = vTrack.clips.filter(c => c.id !== clip.id)
|
||||||
|
enforceVideoNoOverlap(vTrack)
|
||||||
|
state.totalDuration = recalcTotalDuration(state.tracks)
|
||||||
|
if (state.selectedClipIds?.includes(clip.id)) state.selectedClipIds = state.selectedClipIds.filter(id => id !== clip.id)
|
||||||
|
if (state.selectedClipId === clip.id) state.selectedClipId = null
|
||||||
|
if (state.selectedTrackId === vTrack.id) state.selectedTrackId = null
|
||||||
|
}),
|
||||||
|
|
||||||
|
moveClip: (fromTrackId, toTrackId, clipId, newStart) => set((state) => {
|
||||||
|
const fromTrack = state.tracks.find(t => t.id === fromTrackId)
|
||||||
|
const toTrack = state.tracks.find(t => t.id === toTrackId)
|
||||||
|
|
||||||
|
if (fromTrack && toTrack && !toTrack.locked) {
|
||||||
|
const clipIndex = fromTrack.clips.findIndex(c => c.id === clipId)
|
||||||
|
if (clipIndex !== -1) {
|
||||||
|
const [clip] = fromTrack.clips.splice(clipIndex, 1)
|
||||||
|
clip.start = Math.max(0, newStart)
|
||||||
|
toTrack.clips.push(clip)
|
||||||
|
toTrack.clips.sort((a, b) => a.start - b.start)
|
||||||
|
if (fromTrack.type === 'video') enforceVideoNoOverlap(fromTrack)
|
||||||
|
if (toTrack.type === 'video') enforceVideoNoOverlap(toTrack)
|
||||||
|
state.totalDuration = recalcTotalDuration(state.tracks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
groupSelected: (groupId) => set((state) => {
|
||||||
|
const ids = state.selectedClipIds || []
|
||||||
|
if (!ids.length) return
|
||||||
|
const gid = groupId || `grp_${generateId()}`
|
||||||
|
for (const t of state.tracks) {
|
||||||
|
for (const c of t.clips) {
|
||||||
|
if (ids.includes(c.id)) c.groupId = gid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
ungroupSelected: () => set((state) => {
|
||||||
|
const ids = state.selectedClipIds || []
|
||||||
|
if (!ids.length) return
|
||||||
|
for (const t of state.tracks) {
|
||||||
|
for (const c of t.clips) {
|
||||||
|
if (ids.includes(c.id)) delete c.groupId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
shiftGroup: (groupId, delta) => set((state) => {
|
||||||
|
if (!groupId || !Number.isFinite(delta) || Math.abs(delta) < 1e-9) return
|
||||||
|
for (const t of state.tracks) {
|
||||||
|
for (const c of t.clips) {
|
||||||
|
if (c.groupId === groupId) {
|
||||||
|
c.start = Math.max(0, (c.start ?? 0) + delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.clips.sort((a, b) => a.start - b.start)
|
||||||
|
}
|
||||||
|
state.totalDuration = recalcTotalDuration(state.tracks)
|
||||||
|
}),
|
||||||
|
|
||||||
|
splitClip: (trackId, clipId, time) => set((state) => {
|
||||||
|
const track = state.tracks.find(t => t.id === trackId)
|
||||||
|
if (!track || track.locked) return
|
||||||
|
|
||||||
|
const idx = track.clips.findIndex(c => c.id === clipId)
|
||||||
|
if (idx === -1) return
|
||||||
|
const clip = track.clips[idx]
|
||||||
|
|
||||||
|
const splitOffset = time - clip.start
|
||||||
|
const minDur = 0.5
|
||||||
|
if (splitOffset <= minDur || splitOffset >= clip.duration - minDur) return
|
||||||
|
|
||||||
|
const aId = generateId()
|
||||||
|
const bId = generateId()
|
||||||
|
const trimStart = clip.trimStart ?? 0
|
||||||
|
|
||||||
|
const clipA: TimelineClip = {
|
||||||
|
...clip,
|
||||||
|
id: aId,
|
||||||
|
duration: splitOffset,
|
||||||
|
trimStart,
|
||||||
|
trimEnd: trimStart + splitOffset,
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipBTrimStart = trimStart + splitOffset
|
||||||
|
const clipB: TimelineClip = {
|
||||||
|
...clip,
|
||||||
|
id: bId,
|
||||||
|
start: clip.start + splitOffset,
|
||||||
|
duration: clip.duration - splitOffset,
|
||||||
|
trimStart: clipBTrimStart,
|
||||||
|
trimEnd: clipBTrimStart + (clip.duration - splitOffset),
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace in place
|
||||||
|
track.clips.splice(idx, 1, clipA, clipB)
|
||||||
|
track.clips.sort((x, y) => x.start - y.start)
|
||||||
|
if (track.type === 'video') enforceVideoNoOverlap(track)
|
||||||
|
state.selectedClipId = clipB.id
|
||||||
|
state.totalDuration = recalcTotalDuration(state.tracks)
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 以红色播放头为准切分:不要求选中片段;会对所有未锁定轨道中“覆盖该时间点”的片段生效
|
||||||
|
splitAtTime: (time) => set((state) => {
|
||||||
|
const t = Number(time)
|
||||||
|
if (!Number.isFinite(t) || t <= 0) return
|
||||||
|
const minDur = 0.5
|
||||||
|
|
||||||
|
for (const track of state.tracks) {
|
||||||
|
if (!track || track.locked) continue
|
||||||
|
const idx = track.clips.findIndex(c => {
|
||||||
|
const s = c.start ?? 0
|
||||||
|
const e = s + (c.duration ?? 0)
|
||||||
|
return s + minDur < t && t < e - minDur
|
||||||
|
})
|
||||||
|
if (idx === -1) continue
|
||||||
|
|
||||||
|
const clip = track.clips[idx]
|
||||||
|
const splitOffset = t - (clip.start ?? 0)
|
||||||
|
if (splitOffset <= minDur || splitOffset >= (clip.duration ?? 0) - minDur) continue
|
||||||
|
|
||||||
|
const aId = generateId()
|
||||||
|
const bId = generateId()
|
||||||
|
const trimStart0 = clip.trimStart ?? 0
|
||||||
|
|
||||||
|
const clipA: TimelineClip = {
|
||||||
|
...clip,
|
||||||
|
id: aId,
|
||||||
|
duration: splitOffset,
|
||||||
|
trimStart: track.type === 'video' ? trimStart0 : clip.trimStart,
|
||||||
|
trimEnd: track.type === 'video' ? (trimStart0 + splitOffset) : clip.trimEnd,
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipBTrimStart = trimStart0 + splitOffset
|
||||||
|
const clipB: TimelineClip = {
|
||||||
|
...clip,
|
||||||
|
id: bId,
|
||||||
|
start: (clip.start ?? 0) + splitOffset,
|
||||||
|
duration: (clip.duration ?? 0) - splitOffset,
|
||||||
|
trimStart: track.type === 'video' ? clipBTrimStart : clip.trimStart,
|
||||||
|
trimEnd: track.type === 'video' ? (clipBTrimStart + ((clip.duration ?? 0) - splitOffset)) : clip.trimEnd,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.type === 'video') {
|
||||||
|
clampVideoDur(clipA, minDur)
|
||||||
|
clampVideoDur(clipB, minDur)
|
||||||
|
}
|
||||||
|
|
||||||
|
track.clips.splice(idx, 1, clipA, clipB)
|
||||||
|
track.clips.sort((x, y) => (x.start ?? 0) - (y.start ?? 0))
|
||||||
|
if (track.type === 'video') enforceVideoNoOverlap(track)
|
||||||
|
state.selectedClipId = clipB.id
|
||||||
|
state.selectedTrackId = track.id
|
||||||
|
}
|
||||||
|
|
||||||
|
state.totalDuration = recalcTotalDuration(state.tracks)
|
||||||
|
}),
|
||||||
|
|
||||||
|
rippleShiftFrom: (trackId, clipId, delta) => set((state) => {
|
||||||
|
const track = state.tracks.find(t => t.id === trackId)
|
||||||
|
if (!track || track.locked) return
|
||||||
|
const ordered = track.clips.slice().sort((a, b) => a.start - b.start)
|
||||||
|
const idx = ordered.findIndex(c => c.id === clipId)
|
||||||
|
if (idx === -1) return
|
||||||
|
const ids = new Set(ordered.slice(idx).map(c => c.id))
|
||||||
|
for (const c of track.clips) {
|
||||||
|
if (ids.has(c.id)) {
|
||||||
|
c.start = Math.max(0, (c.start ?? 0) + delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
track.clips.sort((a, b) => a.start - b.start)
|
||||||
|
if (track.type === 'video') enforceVideoNoOverlap(track)
|
||||||
|
state.totalDuration = recalcTotalDuration(state.tracks)
|
||||||
|
}),
|
||||||
|
|
||||||
|
addTrack: (track) => set((state) => {
|
||||||
|
state.tracks.push({
|
||||||
|
...track,
|
||||||
|
id: generateId(),
|
||||||
|
} as Track)
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateTrack: (trackId, updates) => set((state) => {
|
||||||
|
const track = state.tracks.find(t => t.id === trackId)
|
||||||
|
if (track) {
|
||||||
|
Object.assign(track, updates)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteTrack: (trackId) => set((state) => {
|
||||||
|
state.tracks = state.tracks.filter(t => t.id !== trackId)
|
||||||
|
state.totalDuration = recalcTotalDuration(state.tracks)
|
||||||
|
if (state.selectedTrackId === trackId) {
|
||||||
|
state.selectedTrackId = null
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
toggleTrackMute: (trackId) => set((state) => {
|
||||||
|
const track = state.tracks.find(t => t.id === trackId)
|
||||||
|
if (track) {
|
||||||
|
track.muted = !track.muted
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
toggleTrackLock: (trackId) => set((state) => {
|
||||||
|
const track = state.tracks.find(t => t.id === trackId)
|
||||||
|
if (track) {
|
||||||
|
track.locked = !track.locked
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
toggleTrackCollapse: (trackId) => set((state) => {
|
||||||
|
const track = state.tracks.find(t => t.id === trackId)
|
||||||
|
if (track) {
|
||||||
|
track.collapsed = !track.collapsed
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
pushHistory: (meta) => set((state) => {
|
||||||
|
const newHistory = state.history.slice(0, state.historyIndex + 1)
|
||||||
|
newHistory.push({
|
||||||
|
tracks: JSON.parse(JSON.stringify(state.tracks)),
|
||||||
|
label: meta?.label || '编辑',
|
||||||
|
icon: meta?.icon,
|
||||||
|
ts: Date.now(),
|
||||||
|
})
|
||||||
|
state.history = newHistory.slice(-30) // 最多保留30步
|
||||||
|
state.historyIndex = state.history.length - 1
|
||||||
|
}),
|
||||||
|
|
||||||
|
undo: () => set((state) => {
|
||||||
|
if (state.historyIndex > 0) {
|
||||||
|
state.historyIndex--
|
||||||
|
state.tracks = JSON.parse(JSON.stringify(state.history[state.historyIndex].tracks))
|
||||||
|
state.totalDuration = recalcTotalDuration(state.tracks)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
redo: () => set((state) => {
|
||||||
|
if (state.historyIndex < state.history.length - 1) {
|
||||||
|
state.historyIndex++
|
||||||
|
state.tracks = JSON.parse(JSON.stringify(state.history[state.historyIndex].tracks))
|
||||||
|
state.totalDuration = recalcTotalDuration(state.tracks)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
jumpToHistory: (index) => set((state) => {
|
||||||
|
const i = Math.max(0, Math.min(index, state.history.length - 1))
|
||||||
|
if (i < 0 || i >= state.history.length) return
|
||||||
|
state.historyIndex = i
|
||||||
|
state.tracks = JSON.parse(JSON.stringify(state.history[i].tracks))
|
||||||
|
state.totalDuration = recalcTotalDuration(state.tracks)
|
||||||
|
}),
|
||||||
|
|
||||||
|
reset: () => set(initialState),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
5
web/src/templates.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Back-compat re-export
|
||||||
|
export * from './lib/templates'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
55
web/tailwind.config.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// 深色主题 - 专业视频编辑器风格
|
||||||
|
editor: {
|
||||||
|
bg: '#1a1a1a',
|
||||||
|
panel: '#242424',
|
||||||
|
surface: '#2d2d2d',
|
||||||
|
border: '#3d3d3d',
|
||||||
|
hover: '#404040',
|
||||||
|
accent: '#3b82f6',
|
||||||
|
'accent-hover': '#2563eb',
|
||||||
|
text: '#e5e5e5',
|
||||||
|
'text-muted': '#a3a3a3',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
danger: '#ef4444',
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
video: '#3b82f6',
|
||||||
|
audio: '#22c55e',
|
||||||
|
voiceover: '#8b5cf6',
|
||||||
|
subtitle: '#f59e0b',
|
||||||
|
fancy: '#ec4899',
|
||||||
|
bgm: '#06b6d4',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'Menlo', 'Monaco', 'monospace'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
39
web/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
24
web/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
39
web/vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/static': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||