chore: sync code and project files
@@ -7,6 +7,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libsm6 \
|
||||
libxext6 \
|
||||
libgl1 \
|
||||
librsvg2-bin \
|
||||
fonts-noto-cjk \
|
||||
fonts-wqy-zenhei \
|
||||
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/temp", StaticFiles(directory=str(config.TEMP_DIR)), name="temp")
|
||||
app.mount("/static/assets", StaticFiles(directory=str(config.ASSETS_DIR)), name="assets")
|
||||
app.mount("/static/fonts", StaticFiles(directory=str(config.FONTS_DIR)), name="fonts")
|
||||
|
||||
# Legacy mounts(8502 runtime 产物,宿主机目录通过 docker-compose 挂载到容器内)
|
||||
try:
|
||||
|
||||
15
api/routes/__init__.py
Normal file
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
624
app.py
@@ -26,6 +26,7 @@ from modules import path_utils
|
||||
from modules import limits
|
||||
from modules.legacy_path_mapper import map_legacy_local_path
|
||||
from modules.legacy_normalizer import normalize_legacy_project
|
||||
import extra_streamlit_components as stx
|
||||
|
||||
# Page Config
|
||||
st.set_page_config(
|
||||
@@ -35,6 +36,84 @@ st.set_page_config(
|
||||
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 智能匹配函数
|
||||
# ============================================================
|
||||
@@ -109,6 +188,12 @@ st.markdown("""
|
||||
.stTextInput input, .stTextArea textarea {
|
||||
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>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
@@ -150,9 +235,9 @@ def _ui_key(suffix: str) -> str:
|
||||
|
||||
def load_project(project_id):
|
||||
"""Load project state from DB"""
|
||||
data = db.get_project(project_id)
|
||||
data = db.get_project_for_user(project_id, _current_user())
|
||||
if not data:
|
||||
st.error("Project not found")
|
||||
st.error("Project not found / no permission")
|
||||
return
|
||||
|
||||
st.session_state.project_id = project_id
|
||||
@@ -203,6 +288,7 @@ def load_project(project_id):
|
||||
st.session_state.uploaded_images = []
|
||||
|
||||
# Restore assets
|
||||
# RBAC: assets are project-scoped, so permission already checked above.
|
||||
assets = db.get_assets(project_id)
|
||||
images = {}
|
||||
videos = {}
|
||||
@@ -238,11 +324,107 @@ def load_project(project_id):
|
||||
else:
|
||||
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
|
||||
# ============================================================
|
||||
with st.sidebar:
|
||||
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_options = ["🛠️ 工作台", "📜 历史任务", "⚙️ 设置"]
|
||||
@@ -262,7 +444,7 @@ with st.sidebar:
|
||||
if st.session_state.view_mode == "workspace":
|
||||
# Project Selection
|
||||
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}
|
||||
|
||||
selected_proj_id = st.selectbox(
|
||||
@@ -307,13 +489,6 @@ with st.sidebar:
|
||||
for k in keys:
|
||||
if k in m:
|
||||
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("---")
|
||||
|
||||
# Navigation / Progress
|
||||
@@ -335,35 +510,6 @@ with st.sidebar:
|
||||
if key != "view_mode": del st.session_state[key]
|
||||
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):
|
||||
"""Save uploaded file to per-project upload dir (avoid overwrites across projects)."""
|
||||
if uploaded_file is None:
|
||||
@@ -467,13 +613,24 @@ if st.session_state.view_mode == "workspace":
|
||||
# DB: Create Project
|
||||
# 将 uploaded_images 保存到 product_info 以便持久化
|
||||
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
|
||||
with st.spinner(f"正在分析商品信息并生成脚本 ({selected_model_label})..."):
|
||||
gen = ScriptGenerator()
|
||||
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, {
|
||||
"script_gen_s": round(perf_counter() - t0, 3),
|
||||
"script_model": model_provider,
|
||||
@@ -768,16 +925,14 @@ if st.session_state.view_mode == "workspace":
|
||||
status_text.text(f"已完成 {done}/{total_scenes}(Scene {scene_id})")
|
||||
try:
|
||||
img_path = fut.result()
|
||||
except Exception as e:
|
||||
img_path = None
|
||||
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)
|
||||
except Exception as e:
|
||||
st.warning(f"Scene {scene_id} 生成失败:{e}")
|
||||
|
||||
progress_bar.progress(done / total_scenes)
|
||||
|
||||
@@ -863,112 +1018,152 @@ if st.session_state.view_mode == "workspace":
|
||||
scenes = st.session_state.script_data.get("scenes", [])
|
||||
vid_gen = VideoGenerator()
|
||||
|
||||
# Submit-only (non-blocking) to avoid freezing Streamlit under concurrency
|
||||
if st.button("🎬 提交图生视频任务(非阻塞)", type="primary"):
|
||||
st.caption("简化策略:第 4 步完成“生成→轮询→下载到服务器本地”。当至少有一个分镜视频落盘后,才允许进入第 5 步合成。")
|
||||
|
||||
if st.button("🎬 生成分镜视频并下载到服务器(阻塞)", type="primary"):
|
||||
with limits.acquire_video(blocking=False) as ok:
|
||||
if not ok:
|
||||
st.warning("系统正在处理其他视频任务(并发已达上限),请稍后再试。")
|
||||
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:
|
||||
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)
|
||||
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
|
||||
)
|
||||
if task_id:
|
||||
submitted += 1
|
||||
_record_metrics(st.session_state.project_id, {
|
||||
"video_submit_s": round(perf_counter() - t0, 3),
|
||||
"video_submitted": submitted,
|
||||
})
|
||||
if submitted:
|
||||
db.update_project_status(st.session_state.project_id, "videos_processing")
|
||||
st.success(f"已提交 {submitted} 个分镜视频任务。可点击下方“刷新恢复”下载结果。")
|
||||
time.sleep(0.5)
|
||||
st.rerun()
|
||||
else:
|
||||
st.warning("未提交任何任务(可能缺少图片或接口失败)。")
|
||||
if new_task_id:
|
||||
tasks[scene_id] = new_task_id
|
||||
|
||||
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"):
|
||||
if tasks:
|
||||
db.update_project_status(st.session_state.project_id, "videos_processing")
|
||||
|
||||
out_dir = path_utils.project_videos_dir(st.session_state.project_id)
|
||||
pending = set(tasks.keys())
|
||||
deadline = time.time() + 15 * 60 # 15 min
|
||||
|
||||
while pending and time.time() < deadline:
|
||||
status_text.text(f"视频生成/下载中:已完成 {done}/{total},队列中 {len(pending)} ...")
|
||||
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 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)
|
||||
if status == "succeeded" and url:
|
||||
break
|
||||
time.sleep(0.5 * (2 ** attempt))
|
||||
|
||||
meta_patch = {"checked_at": time.time(), "volc_status": status}
|
||||
if url:
|
||||
meta_patch["video_url"] = url
|
||||
db.update_asset_metadata(st.session_state.project_id, scene_id, "video", meta_patch)
|
||||
updated += 1
|
||||
|
||||
_record_metrics(st.session_state.project_id, {
|
||||
"video_recover_s": round(perf_counter() - t0, 3),
|
||||
"video_recovered": updated,
|
||||
})
|
||||
if updated:
|
||||
st.success(f"已刷新 {updated} 个分镜状态(成功的将以 URL 直连预览)。")
|
||||
else:
|
||||
st.info("暂无可恢复的视频(可能仍在排队/生成中)。")
|
||||
time.sleep(0.5)
|
||||
st.rerun()
|
||||
|
||||
if st.button("📥 准备合成素材(下载成功的视频到服务器)", type="secondary"):
|
||||
with limits.acquire_video(blocking=False) as ok:
|
||||
if not ok:
|
||||
st.warning("系统正在处理其他视频任务(并发已达上限),请稍后再试。")
|
||||
st.stop()
|
||||
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,
|
||||
extra=(task_id[-8:] if isinstance(task_id, str) else None),
|
||||
)
|
||||
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):
|
||||
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=(asset or {}).get("task_id"), metadata=meta)
|
||||
downloaded += 1
|
||||
if downloaded:
|
||||
st.success(f"已下载 {downloaded} 段视频,可进入合成。")
|
||||
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:
|
||||
st.info("暂无可下载的视频(请先刷新状态获取 video_url)。")
|
||||
time.sleep(0.5)
|
||||
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()},
|
||||
)
|
||||
|
||||
for sid in to_remove:
|
||||
pending.discard(sid)
|
||||
|
||||
progress.progress(min(1.0, done / max(total, 1)))
|
||||
if pending:
|
||||
time.sleep(5)
|
||||
|
||||
if pending:
|
||||
st.warning(f"仍有 {len(pending)} 个分镜未在本轮完成:{sorted(list(pending))}。可再次点击按钮继续轮询与下载。")
|
||||
|
||||
_record_metrics(st.session_state.project_id, {
|
||||
"video_blocking_total_s": round(perf_counter() - t0, 3),
|
||||
"video_done": done,
|
||||
"video_total": total,
|
||||
})
|
||||
|
||||
if any(p and os.path.exists(p) for p in (st.session_state.scene_videos or {}).values()):
|
||||
db.update_project_status(st.session_state.project_id, "videos_generated")
|
||||
st.success("已生成并下载到服务器本地。进入第 5 步合成。")
|
||||
st.session_state.current_step = 4
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("未生成任何可用视频,请检查生视频接口或稍后重试。")
|
||||
|
||||
# Display Videos (even when partially available)
|
||||
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):
|
||||
st.video(vid_path)
|
||||
else:
|
||||
# Try URL preview from DB metadata
|
||||
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 video_url:
|
||||
# Detect stale mapping: if source image signature differs, warn and avoid misleading preview
|
||||
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)
|
||||
status = (asset or {}).get("status") or "pending"
|
||||
task_id = (asset or {}).get("task_id")
|
||||
if task_id:
|
||||
st.caption(f"状态: {status} | Task: {str(task_id)[-6:]}")
|
||||
else:
|
||||
st.warning("Video missing")
|
||||
# --- Recovery Logic ---
|
||||
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()
|
||||
st.caption(f"状态: {status}")
|
||||
st.warning("暂无本地视频(请点击上方“生成分镜视频并下载到服务器”)。")
|
||||
|
||||
# Per-scene regenerate button
|
||||
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,
|
||||
extra=(t_id[-8:] if isinstance(t_id, str) else None),
|
||||
)
|
||||
new_path = vid_gen._download_video(
|
||||
url,
|
||||
out_name,
|
||||
output_dir=path_utils.project_videos_dir(st.session_state.project_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)
|
||||
target_dir = path_utils.project_videos_dir(st.session_state.project_id)
|
||||
target_path = str(target_dir / out_name)
|
||||
if vid_gen._download_video_to(url, target_path) 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=t_id)
|
||||
st.rerun()
|
||||
break
|
||||
elif status in ["failed", "cancelled"]:
|
||||
@@ -1067,18 +1230,23 @@ if st.session_state.view_mode == "workspace":
|
||||
|
||||
c_act1, c_act2 = st.columns([1, 4])
|
||||
with c_act1:
|
||||
if st.button("🔄 重新生成所有视频", type="secondary"):
|
||||
if st.button("🧹 清空视频并重新生成", type="secondary"):
|
||||
# Clear videos and rerun
|
||||
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:
|
||||
db.clear_assets(st.session_state.project_id, "video", status="pending")
|
||||
st.rerun()
|
||||
|
||||
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.rerun()
|
||||
if not can_compose:
|
||||
st.caption("⚠️ 需要至少一个分镜视频已下载到服务器本地后,才能进入合成。")
|
||||
|
||||
# --- Step 5: Final Composition & Tuning ---
|
||||
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"):
|
||||
with st.spinner("正在应用修改并重新合成..."):
|
||||
# 自动补齐视频下载逻辑 (关键优化)
|
||||
_ensure_local_videos(st.session_state.project_id, st.session_state.script_data.get("scenes", []))
|
||||
|
||||
composer = VideoComposer(voice_type=selected_voice)
|
||||
|
||||
bgm_path = None
|
||||
@@ -1255,6 +1426,9 @@ if st.session_state.view_mode == "workspace":
|
||||
st.info("暂无合成视频,请先点击开始合成。")
|
||||
if st.button("✨ 开始首次合成", type="primary"):
|
||||
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
|
||||
composer = VideoComposer(voice_type=config.VOLC_TTS_DEFAULT_VOICE)
|
||||
|
||||
@@ -1290,6 +1464,9 @@ if st.session_state.view_mode == "workspace":
|
||||
else:
|
||||
st.success(f"共找到 {len(found_files)} 个历史版本")
|
||||
|
||||
# 提示用户:如果重新合成,系统会自动补全未下载的素材
|
||||
st.caption("💡 重新合成将应用当前的微调设置。如果分镜未下载,系统将尝试自动补全。")
|
||||
|
||||
# 遍历显示所有历史版本
|
||||
for idx, vid_path in enumerate(found_files):
|
||||
mtime = os.path.getmtime(vid_path)
|
||||
@@ -1324,7 +1501,7 @@ if st.session_state.view_mode == "workspace":
|
||||
elif st.session_state.view_mode == "history":
|
||||
st.header("📜 历史任务")
|
||||
|
||||
projects = db.list_projects()
|
||||
projects = db.list_projects_for_user(_current_user())
|
||||
|
||||
for proj in projects:
|
||||
with st.expander(f"{proj['name']} ({proj['updated_at']})"):
|
||||
@@ -1394,11 +1571,19 @@ elif st.session_state.view_mode == "settings":
|
||||
st.subheader("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:
|
||||
st.info("✅ 已加载自定义 Prompt(来自数据库)")
|
||||
if user_prompt:
|
||||
st.info("✅ 已加载自定义 Prompt(当前用户)")
|
||||
elif current_prompt:
|
||||
st.info("✅ 已加载自定义 Prompt(全局默认)")
|
||||
else:
|
||||
st.warning("⚠️ 使用默认 Prompt(数据库中无自定义配置)")
|
||||
# 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])
|
||||
with col_save:
|
||||
if st.button("💾 保存配置", type="primary"):
|
||||
db.set_config("prompt_script_gen", new_prompt, "System prompt for script generation step")
|
||||
# 验证保存
|
||||
saved = db.get_config("prompt_script_gen")
|
||||
# Save per-user prompt
|
||||
db.set_user_prompt(cu.get("id"), "prompt_script_gen", new_prompt)
|
||||
saved = db.get_user_prompt(cu.get("id"), "prompt_script_gen")
|
||||
if saved == new_prompt:
|
||||
st.success("✅ 配置已保存并验证成功!下次生成脚本时将使用新 Prompt。")
|
||||
st.success("✅ 已保存为“当前用户 Prompt”。下次生成脚本仅影响你自己的账号。")
|
||||
else:
|
||||
st.error("❌ 保存可能失败,请检查日志")
|
||||
|
||||
with col_reset:
|
||||
if st.button("🔄 恢复默认"):
|
||||
temp_gen = ScriptGenerator()
|
||||
db.set_config("prompt_script_gen", temp_gen.default_system_prompt, "System prompt for script generation step (DEFAULT)")
|
||||
st.success("已恢复默认 Prompt,请刷新页面查看")
|
||||
# Clear per-user override: set empty to remove effect
|
||||
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()
|
||||
|
||||
|
||||
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_BASE_URL = os.getenv("SHUBIAOBIAO_BASE_URL", "https://api.shubiaobiao.cn/v1")
|
||||
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)
|
||||
# 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
|
||||
|
||||
# 7. 添加 BGM
|
||||
# 说明:add_bgm 的 ducking=True 路径使用 sidechaincompress,但该滤镜本身不做“混音”,
|
||||
# 在某些 ffmpeg 版本/参数组合下会导致 BGM 听起来像“没加上”。
|
||||
# 我们在 compose() 里已禁用 ducking,这里保持一致,使用 amix 叠加并提高默认音量。
|
||||
if bgm_path:
|
||||
bgm_output = str(Path(temp_root) / f"{output_name}_bgm.mp4")
|
||||
ffmpeg_utils.add_bgm(
|
||||
current_video, bgm_path, bgm_output,
|
||||
bgm_volume=0.15
|
||||
current_video,
|
||||
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)
|
||||
current_video = bgm_output
|
||||
|
||||
@@ -6,14 +6,43 @@
|
||||
import json
|
||||
import logging
|
||||
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.dialects.postgresql import JSONB
|
||||
|
||||
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__)
|
||||
|
||||
Base = declarative_base()
|
||||
@@ -26,6 +55,7 @@ class Project(Base):
|
||||
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)
|
||||
script_data = Column(Text) # JSON string
|
||||
owner_user_id = Column(String, index=True, nullable=True)
|
||||
created_at = Column(Float, default=time.time)
|
||||
updated_at = Column(Float, default=time.time, onupdate=time.time)
|
||||
|
||||
@@ -54,6 +84,62 @@ class AppConfig(Base):
|
||||
description = Column(Text, nullable=True)
|
||||
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:
|
||||
def __init__(self, connection_string: str = None):
|
||||
if not connection_string:
|
||||
@@ -62,17 +148,59 @@ class DBManager:
|
||||
self.engine = create_engine(connection_string, pool_recycle=3600)
|
||||
self.Session = scoped_session(sessionmaker(bind=self.engine))
|
||||
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):
|
||||
"""初始化表结构"""
|
||||
"""初始化表结构 + 轻量自迁移(不依赖 Alembic)"""
|
||||
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):
|
||||
return self.Session()
|
||||
|
||||
# --- 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()
|
||||
try:
|
||||
# Check if exists
|
||||
@@ -86,6 +214,7 @@ class DBManager:
|
||||
name=name,
|
||||
status="created",
|
||||
product_info=json.dumps(product_info, ensure_ascii=False),
|
||||
owner_user_id=owner_user_id,
|
||||
created_at=time.time(),
|
||||
updated_at=time.time()
|
||||
)
|
||||
@@ -157,6 +286,7 @@ class DBManager:
|
||||
"status": project.status,
|
||||
"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,
|
||||
"owner_user_id": getattr(project, "owner_user_id", None),
|
||||
"created_at": project.created_at,
|
||||
"updated_at": project.updated_at
|
||||
}
|
||||
@@ -175,12 +305,288 @@ class DBManager:
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"status": p.status,
|
||||
"owner_user_id": getattr(p, "owner_user_id", None),
|
||||
"updated_at": p.updated_at
|
||||
})
|
||||
return results
|
||||
finally:
|
||||
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 ---
|
||||
|
||||
def save_asset(self, project_id: str, scene_id: int, asset_type: str,
|
||||
@@ -253,6 +659,92 @@ class DBManager:
|
||||
finally:
|
||||
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]]:
|
||||
session = self._get_session()
|
||||
try:
|
||||
@@ -279,6 +771,30 @@ class DBManager:
|
||||
finally:
|
||||
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:
|
||||
"""Merge-patch asset.metadata JSON without overwriting other fields."""
|
||||
if not patch:
|
||||
|
||||
@@ -172,7 +172,8 @@ def get_video_info(video_path: str) -> Dict[str, Any]:
|
||||
def concat_videos(
|
||||
video_paths: List[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:
|
||||
"""
|
||||
使用 FFmpeg concat demuxer 拼接多段视频
|
||||
@@ -197,10 +198,72 @@ def concat_videos(
|
||||
filter_parts = []
|
||||
for i in range(len(video_paths)):
|
||||
# scale 保持宽高比,pad 填充黑边居中
|
||||
filter_parts.append(
|
||||
chain = (
|
||||
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))])
|
||||
@@ -232,7 +295,8 @@ def concat_videos(
|
||||
def concat_videos_with_audio(
|
||||
video_paths: List[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:
|
||||
"""
|
||||
拼接视频并保留音频轨道
|
||||
@@ -250,10 +314,70 @@ def concat_videos_with_audio(
|
||||
|
||||
# 视频处理
|
||||
for i in range(n):
|
||||
filter_parts.append(
|
||||
chain = (
|
||||
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):
|
||||
@@ -469,6 +593,127 @@ def adjust_audio_duration(
|
||||
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]:
|
||||
"""获取音频信息"""
|
||||
return get_video_info(file_path)
|
||||
@@ -830,6 +1075,12 @@ def add_bgm(
|
||||
loop: bool = True,
|
||||
ducking: bool = True,
|
||||
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_out: float = 1.0
|
||||
) -> str:
|
||||
@@ -856,30 +1107,46 @@ def add_bgm(
|
||||
info = get_video_info(video_path)
|
||||
video_duration = info["duration"]
|
||||
|
||||
if loop:
|
||||
bgm_chain = (
|
||||
f"[1:a]aloop=-1:size=2e+09,asetpts=N/SR/TB,"
|
||||
f"atrim=0:{video_duration},"
|
||||
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]"
|
||||
)
|
||||
# 片段时长:默认覆盖整段视频
|
||||
dur = float(clip_duration) if (clip_duration is not None and float(clip_duration) > 0) else float(video_duration)
|
||||
st = max(0.0, float(start_time or 0.0))
|
||||
end_for_fade = max(dur - float(fade_out or 0.0), 0.0)
|
||||
|
||||
if ducking:
|
||||
# 使用安全参数的 sidechaincompress,避免 unsupported 参数
|
||||
# 基础链:loop/trim -> fades -> base volume
|
||||
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 = (
|
||||
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]"
|
||||
)
|
||||
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 = [
|
||||
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_OUTPUT_PREFIX = "/root/video-flow/output/"
|
||||
LEGACY_HOST_PREFIX = "/root/video-flow/"
|
||||
|
||||
# Container mount points (see docker-compose.yml)
|
||||
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):
|
||||
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):
|
||||
name = Path(local_path).name
|
||||
container_path = str(Path(LEGACY_CONTAINER_TEMP_DIR) / name)
|
||||
url = f"{LEGACY_STATIC_TEMP_PREFIX}{name}"
|
||||
return container_path, url
|
||||
rel = local_path[len(LEGACY_HOST_TEMP_PREFIX):].lstrip("/")
|
||||
container_path = str(Path(LEGACY_CONTAINER_TEMP_DIR) / rel)
|
||||
# 静态路由通常只覆盖目录根(不包含子目录);这里交给 /api/assets/file 做 FileResponse 更稳
|
||||
return container_path, None
|
||||
|
||||
if local_path.startswith(LEGACY_HOST_OUTPUT_PREFIX):
|
||||
name = Path(local_path).name
|
||||
container_path = str(Path(LEGACY_CONTAINER_OUTPUT_DIR) / name)
|
||||
url = f"{LEGACY_STATIC_OUTPUT_PREFIX}{name}"
|
||||
return container_path, url
|
||||
rel = local_path[len(LEGACY_HOST_OUTPUT_PREFIX):].lstrip("/")
|
||||
container_path = str(Path(LEGACY_CONTAINER_OUTPUT_DIR) / rel)
|
||||
return container_path, None
|
||||
|
||||
# Unknown path: keep as-is
|
||||
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 logging
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pathlib import Path
|
||||
@@ -27,7 +28,10 @@ class ScriptGenerator:
|
||||
# OpenAI-compatible client for ShuBiaoBiao (supports multiple models incl. GPT)
|
||||
self.shubiaobiao_client = OpenAI(
|
||||
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
|
||||
@@ -139,14 +143,22 @@ class ScriptGenerator:
|
||||
product_name: str,
|
||||
product_info: Dict[str, Any],
|
||||
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]:
|
||||
"""
|
||||
生成分镜脚本
|
||||
"""
|
||||
logger.info(f"Generating script for: {product_name} (Provider: {model_provider})")
|
||||
|
||||
# 1. 构造 Prompt (优先从数据库读取配置)
|
||||
# 1. 构造 Prompt (优先按 user_id 读取;否则回退到全局配置,再回退默认)
|
||||
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)
|
||||
|
||||
@@ -293,21 +305,40 @@ class ScriptGenerator:
|
||||
ShuBiaoBiao OpenAI-compatible multimodal chat.
|
||||
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}]
|
||||
user_content: List[Dict[str, Any]] = []
|
||||
# Images first (URL), then text
|
||||
t_upload0 = time.time()
|
||||
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:
|
||||
user_content.append({"type": "image_url", "image_url": {"url": url}})
|
||||
user_content.append({"type": "text", "text": user_prompt})
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
|
||||
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,
|
||||
messages=messages,
|
||||
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()
|
||||
script_json = self._extract_json_from_response(content_text)
|
||||
if script_json is None:
|
||||
@@ -323,7 +354,9 @@ class ScriptGenerator:
|
||||
}
|
||||
return final_script
|
||||
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
|
||||
|
||||
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]:
|
||||
"""校验并修复脚本结构"""
|
||||
# 简单校验,确保必要字段存在
|
||||
if "scenes" not in script:
|
||||
if not isinstance(script, dict):
|
||||
return {"scenes": []}
|
||||
|
||||
# Ensure fields exist
|
||||
if "scenes" not in script or not isinstance(script.get("scenes"), list):
|
||||
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
|
||||
|
||||
@@ -90,6 +90,79 @@ class TextRenderer:
|
||||
return color
|
||||
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:
|
||||
"""
|
||||
渲染文本并返回图片路径
|
||||
@@ -122,14 +195,29 @@ class TextRenderer:
|
||||
font_size = style.get("font_size", 60)
|
||||
font = self._get_font(font_path, font_size)
|
||||
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)))
|
||||
try:
|
||||
bbox = dummy_draw.multiline_textbbox((0, 0), text, font=font, spacing=int(font_size * 0.25), align="center")
|
||||
except Exception:
|
||||
bbox = dummy_draw.textbbox((0, 0), text, font=font)
|
||||
text_w = bbox[2] - bbox[0]
|
||||
text_h = bbox[3] - bbox[1]
|
||||
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", [])
|
||||
if isinstance(strokes, dict): strokes = [strokes] # 兼容旧格式
|
||||
|
||||
@@ -156,7 +244,7 @@ class TextRenderer:
|
||||
canvas_w = content_w + 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))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
@@ -164,7 +252,7 @@ class TextRenderer:
|
||||
center_x = canvas_w // 2
|
||||
center_y = canvas_h // 2
|
||||
|
||||
# 6. 绘制顺序: 阴影 -> 背景 -> 描边 -> 文本
|
||||
# 7. 绘制顺序: 阴影 -> 背景 -> 描边 -> 文本
|
||||
|
||||
# --- 绘制阴影 (针对整个块) ---
|
||||
if shadow:
|
||||
@@ -183,6 +271,10 @@ class TextRenderer:
|
||||
# 文字阴影
|
||||
txt_x = center_x - text_w / 2
|
||||
txt_y = center_y - text_h / 2
|
||||
# 多行阴影
|
||||
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:
|
||||
@@ -217,11 +309,56 @@ class TextRenderer:
|
||||
width = s.get("width", 0)
|
||||
if width > 0:
|
||||
# 通过偏移模拟描边 (Pillow stroke_width 效果一般,但这里先用原生参数)
|
||||
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)
|
||||
|
||||
# --- 绘制文字 ---
|
||||
# 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. 裁剪多余透明区域
|
||||
bbox = img.getbbox()
|
||||
if bbox:
|
||||
|
||||
@@ -23,6 +23,7 @@ numpy>=1.24.0
|
||||
|
||||
# Web UI (Streamlit - 保留原有调试界面)
|
||||
streamlit>=1.29.0
|
||||
extra-streamlit-components>=0.1.71
|
||||
|
||||
# FastAPI Backend (新增前后端分离)
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||