chore: sync code and project files

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

View File

@@ -7,6 +7,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libsm6 \ libsm6 \
libxext6 \ libxext6 \
libgl1 \ libgl1 \
librsvg2-bin \
fonts-noto-cjk \ fonts-noto-cjk \
fonts-wqy-zenhei \ fonts-wqy-zenhei \
curl \ curl \

0
api/__init__.py Normal file
View File

85
api/celery_app.py Normal file
View 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()

View File

@@ -68,6 +68,7 @@ app.add_middleware(
app.mount("/static/output", StaticFiles(directory=str(config.OUTPUT_DIR)), name="output") app.mount("/static/output", StaticFiles(directory=str(config.OUTPUT_DIR)), name="output")
app.mount("/static/temp", StaticFiles(directory=str(config.TEMP_DIR)), name="temp") app.mount("/static/temp", StaticFiles(directory=str(config.TEMP_DIR)), name="temp")
app.mount("/static/assets", StaticFiles(directory=str(config.ASSETS_DIR)), name="assets") app.mount("/static/assets", StaticFiles(directory=str(config.ASSETS_DIR)), name="assets")
app.mount("/static/fonts", StaticFiles(directory=str(config.FONTS_DIR)), name="fonts")
# Legacy mounts8502 runtime 产物,宿主机目录通过 docker-compose 挂载到容器内) # Legacy mounts8502 runtime 产物,宿主机目录通过 docker-compose 挂载到容器内)
try: try:

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,15 @@
# Celery Tasks Package

212
api/tasks/audio_tasks.py Normal file
View 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
View 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

652
app.py
View File

@@ -26,6 +26,7 @@ from modules import path_utils
from modules import limits from modules import limits
from modules.legacy_path_mapper import map_legacy_local_path from modules.legacy_path_mapper import map_legacy_local_path
from modules.legacy_normalizer import normalize_legacy_project from modules.legacy_normalizer import normalize_legacy_project
import extra_streamlit_components as stx
# Page Config # Page Config
st.set_page_config( st.set_page_config(
@@ -35,6 +36,84 @@ st.set_page_config(
initial_sidebar_state="expanded" initial_sidebar_state="expanded"
) )
# ============================================================
# Auth (login + remember cookie)
# ============================================================
COOKIE_NAME = "vf_session"
cookie_manager = stx.CookieManager(key="vf_cookie_mgr")
def _current_user() -> dict:
u = st.session_state.get("current_user")
return u if isinstance(u, dict) else None
def _set_current_user(u: dict):
st.session_state.current_user = u
def _try_restore_login():
if _current_user():
return
try:
token = cookie_manager.get(COOKIE_NAME)
except Exception:
token = None
if token:
u = db.validate_session(token)
if u:
_set_current_user(u)
def _logout():
try:
token = cookie_manager.get(COOKIE_NAME)
except Exception:
token = None
if token:
db.revoke_session(token)
try:
cookie_manager.delete(COOKIE_NAME)
except Exception:
pass
st.session_state.pop("current_user", None)
st.rerun()
def _login_gate():
_try_restore_login()
u = _current_user()
if u:
return
st.title("登录")
# Login page runs before the global CSS block below; inject minimal CSS here.
st.markdown(
"""
<style>
button[aria-label="Show password text"],
button[aria-label="Hide password text"] {
display: none !important;
}
</style>
""",
unsafe_allow_html=True,
)
c1, c2 = st.columns([1, 2])
with c1:
username = st.text_input("用户名", value="", key="login_user")
password = st.text_input("密码", value="", type="password", key="login_pass")
if st.button("登录", type="primary"):
au = db.authenticate_user(username.strip(), password)
if not au:
st.error("用户名或密码错误,或账号已禁用")
st.stop()
token = db.create_session(au["id"])
try:
cookie_manager.set(COOKIE_NAME, token, max_age=7 * 24 * 3600)
except Exception:
# fallback: session-only
pass
_set_current_user(au)
st.rerun()
st.stop()
_login_gate()
# ============================================================ # ============================================================
# BGM 智能匹配函数 # BGM 智能匹配函数
# ============================================================ # ============================================================
@@ -109,6 +188,12 @@ st.markdown("""
.stTextInput input, .stTextArea textarea { .stTextInput input, .stTextArea textarea {
border-radius: 4px; border-radius: 4px;
} }
/* Hide Streamlit password reveal (eye) buttons to avoid accidental exposure */
button[aria-label="Show password text"],
button[aria-label="Hide password text"] {
display: none !important;
}
</style> </style>
""", unsafe_allow_html=True) """, unsafe_allow_html=True)
@@ -150,9 +235,9 @@ def _ui_key(suffix: str) -> str:
def load_project(project_id): def load_project(project_id):
"""Load project state from DB""" """Load project state from DB"""
data = db.get_project(project_id) data = db.get_project_for_user(project_id, _current_user())
if not data: if not data:
st.error("Project not found") st.error("Project not found / no permission")
return return
st.session_state.project_id = project_id st.session_state.project_id = project_id
@@ -203,6 +288,7 @@ def load_project(project_id):
st.session_state.uploaded_images = [] st.session_state.uploaded_images = []
# Restore assets # Restore assets
# RBAC: assets are project-scoped, so permission already checked above.
assets = db.get_assets(project_id) assets = db.get_assets(project_id)
images = {} images = {}
videos = {} videos = {}
@@ -238,11 +324,107 @@ def load_project(project_id):
else: else:
st.session_state.current_step = 0 st.session_state.current_step = 0
# ============================================================
# Helper Functions (must be defined before Sidebar uses them)
# ============================================================
def _record_metrics(project_id: str, patch: dict):
"""Persist lightweight timing/diagnostic metrics into project.product_info['_metrics']."""
if not project_id or not isinstance(patch, dict) or not patch:
return
try:
proj = db.get_project(project_id) or {}
product_info = proj.get("product_info") or {}
metrics = product_info.get("_metrics") if isinstance(product_info.get("_metrics"), dict) else {}
metrics.update(patch)
metrics["updated_at"] = time.time()
product_info["_metrics"] = metrics
db.update_project_product_info(project_id, product_info)
except Exception:
# metrics must never break UX
pass
def _get_metrics(project_id: str) -> dict:
try:
proj = db.get_project(project_id) or {}
product_info = proj.get("product_info") or {}
m = product_info.get("_metrics")
return m if isinstance(m, dict) else {}
except Exception:
return {}
def _ensure_local_videos(project_id: str, scenes: list):
"""
Step 5 合成前的保障逻辑:
检查分镜视频是否已下载到服务器本地。如果只有 URL 没有本地文件,则后台静默下载。
"""
if not project_id or not scenes:
return
vid_gen = VideoGenerator()
downloaded = 0
missing_assets = []
for scene in scenes:
scene_id = scene["id"]
local_path = st.session_state.scene_videos.get(scene_id)
# 如果本地文件不存在,尝试补全
if not local_path or not os.path.exists(local_path):
asset = db.get_asset(project_id, scene_id, "video")
if not asset:
missing_assets.append(f"Scene {scene_id}")
continue
meta = asset.get("metadata") or {}
video_url = meta.get("video_url")
task_id = asset.get("task_id")
# 如果 DB 里没有 URL但有 task_id尝试现场查询一次
if not video_url and task_id:
logger.info(f"Checking task {task_id} status for Scene {scene_id} during composition...")
status, url = vid_gen.check_task_status(task_id)
if status == "succeeded" and url:
video_url = url
db.update_asset_metadata(project_id, scene_id, "video", {"video_url": url, "volc_status": status})
if video_url:
out_name = path_utils.unique_filename(
prefix="scene_video",
ext="mp4",
project_id=project_id,
scene_id=scene_id,
extra=f"auto_{int(time.time())}"
)
logger.info(f"Auto-downloading missing video for Scene {scene_id} from {video_url}")
target_dir = path_utils.project_videos_dir(project_id)
target_path = str(target_dir / out_name)
if vid_gen._download_video_to(video_url, target_path):
st.session_state.scene_videos[scene_id] = target_path
db.save_asset(project_id, scene_id, "video", "completed", local_path=target_path)
downloaded += 1
else:
missing_assets.append(f"Scene {scene_id} (未生成/无URL)")
if downloaded > 0:
logger.info(f"Successfully auto-downloaded {downloaded} missing videos for project {project_id}")
if missing_assets:
msg = f"无法合成:以下分镜缺少视频素材,请先在第 4 步生成:{', '.join(missing_assets)}"
logger.warning(msg)
raise ValueError(msg)
# ============================================================ # ============================================================
# Sidebar # Sidebar
# ============================================================ # ============================================================
with st.sidebar: with st.sidebar:
st.title("📽️ Video Flow") st.title("📽️ Video Flow")
cu = _current_user()
if cu:
st.caption(f"登录用户: {cu.get('username')} ({cu.get('role')})")
if st.button("退出登录", type="secondary"):
_logout()
# Mode Selection - 正确计算 index # Mode Selection - 正确计算 index
mode_options = ["🛠️ 工作台", "📜 历史任务", "⚙️ 设置"] mode_options = ["🛠️ 工作台", "📜 历史任务", "⚙️ 设置"]
@@ -262,7 +444,7 @@ with st.sidebar:
if st.session_state.view_mode == "workspace": if st.session_state.view_mode == "workspace":
# Project Selection # Project Selection
st.subheader("Current Project") st.subheader("Current Project")
projects = db.list_projects() projects = db.list_projects_for_user(_current_user())
proj_options = {p['id']: f"{p.get('name', 'Untitled')} ({p['id']})" for p in projects} proj_options = {p['id']: f"{p.get('name', 'Untitled')} ({p['id']})" for p in projects}
selected_proj_id = st.selectbox( selected_proj_id = st.selectbox(
@@ -307,13 +489,6 @@ with st.sidebar:
for k in keys: for k in keys:
if k in m: if k in m:
st.caption(f"{k}: {m.get(k)}") st.caption(f"{k}: {m.get(k)}")
# 在线剪辑入口React Editor
web_base_url = os.getenv("WEB_BASE_URL", "http://localhost:3000").rstrip("/")
st.markdown(
f"[打开在线剪辑器]({web_base_url}/editor/{st.session_state.project_id})",
unsafe_allow_html=False,
)
st.markdown("---") st.markdown("---")
# Navigation / Progress # Navigation / Progress
@@ -335,35 +510,6 @@ with st.sidebar:
if key != "view_mode": del st.session_state[key] if key != "view_mode": del st.session_state[key]
st.rerun() st.rerun()
# ============================================================
# Helper Functions
# ============================================================
def _record_metrics(project_id: str, patch: dict):
"""Persist lightweight timing/diagnostic metrics into project.product_info['_metrics']."""
if not project_id or not isinstance(patch, dict) or not patch:
return
try:
proj = db.get_project(project_id) or {}
product_info = proj.get("product_info") or {}
metrics = product_info.get("_metrics") if isinstance(product_info.get("_metrics"), dict) else {}
metrics.update(patch)
metrics["updated_at"] = time.time()
product_info["_metrics"] = metrics
db.update_project_product_info(project_id, product_info)
except Exception:
# metrics must never break UX
pass
def _get_metrics(project_id: str) -> dict:
try:
proj = db.get_project(project_id) or {}
product_info = proj.get("product_info") or {}
m = product_info.get("_metrics")
return m if isinstance(m, dict) else {}
except Exception:
return {}
def save_uploaded_file(project_id: str, uploaded_file): def save_uploaded_file(project_id: str, uploaded_file):
"""Save uploaded file to per-project upload dir (avoid overwrites across projects).""" """Save uploaded file to per-project upload dir (avoid overwrites across projects)."""
if uploaded_file is None: if uploaded_file is None:
@@ -467,13 +613,24 @@ if st.session_state.view_mode == "workspace":
# DB: Create Project # DB: Create Project
# 将 uploaded_images 保存到 product_info 以便持久化 # 将 uploaded_images 保存到 product_info 以便持久化
product_info = {"category": category, "price": price, "tags": tags, "params": params, "style_hint": style_hint, "uploaded_images": image_paths} product_info = {"category": category, "price": price, "tags": tags, "params": params, "style_hint": style_hint, "uploaded_images": image_paths}
db.create_project(st.session_state.project_id, product_name, product_info) db.create_project(
st.session_state.project_id,
product_name,
product_info,
owner_user_id=(_current_user() or {}).get("id"),
)
# Call Script Generator # Call Script Generator
with st.spinner(f"正在分析商品信息并生成脚本 ({selected_model_label})..."): with st.spinner(f"正在分析商品信息并生成脚本 ({selected_model_label})..."):
gen = ScriptGenerator() gen = ScriptGenerator()
t0 = perf_counter() t0 = perf_counter()
script = gen.generate_script(product_name, product_info, image_paths, model_provider=model_provider) script = gen.generate_script(
product_name,
product_info,
image_paths,
model_provider=model_provider,
user_id=(_current_user() or {}).get("id"),
)
_record_metrics(st.session_state.project_id, { _record_metrics(st.session_state.project_id, {
"script_gen_s": round(perf_counter() - t0, 3), "script_gen_s": round(perf_counter() - t0, 3),
"script_model": model_provider, "script_model": model_provider,
@@ -689,9 +846,9 @@ if st.session_state.view_mode == "workspace":
if not ok: if not ok:
st.warning("系统正在生成其他任务(生图并发已达上限),请稍后再试。") st.warning("系统正在生成其他任务(生图并发已达上限),请稍后再试。")
st.stop() st.stop()
img_gen = ImageGenerator() img_gen = ImageGenerator()
# Pass ALL uploaded images as reference # Pass ALL uploaded images as reference
base_imgs = st.session_state.uploaded_images if st.session_state.uploaded_images else [] base_imgs = st.session_state.uploaded_images if st.session_state.uploaded_images else []
if not base_imgs: if not base_imgs:
st.error("No base image found (未找到参考底图). Please upload in Step 1.") st.error("No base image found (未找到参考底图). Please upload in Step 1.")
@@ -742,7 +899,7 @@ if st.session_state.view_mode == "workspace":
total_scenes = len(scenes) total_scenes = len(scenes)
progress_bar = st.progress(0) progress_bar = st.progress(0)
status_text = st.empty() status_text = st.empty()
try: try:
t0 = perf_counter() t0 = perf_counter()
# Parallel workers within a single run; global semaphore already acquired above. # Parallel workers within a single run; global semaphore already acquired above.
@@ -755,7 +912,7 @@ if st.session_state.view_mode == "workspace":
img_gen.generate_single_scene_image, img_gen.generate_single_scene_image,
scene=scene, scene=scene,
original_image_path=list(base_imgs), # ONLY merchant images original_image_path=list(base_imgs), # ONLY merchant images
previous_image_path=None, previous_image_path=None,
model_provider=img_provider, model_provider=img_provider,
visual_anchor=visual_anchor, visual_anchor=visual_anchor,
project_id=st.session_state.project_id, project_id=st.session_state.project_id,
@@ -768,17 +925,15 @@ if st.session_state.view_mode == "workspace":
status_text.text(f"已完成 {done}/{total_scenes}Scene {scene_id}") status_text.text(f"已完成 {done}/{total_scenes}Scene {scene_id}")
try: try:
img_path = fut.result() img_path = fut.result()
if img_path:
st.session_state.scene_images[scene_id] = img_path
db.save_asset(st.session_state.project_id, scene_id, "image", "completed", local_path=img_path)
# Invalidate stale video for this scene (image changed => old video is wrong)
db.clear_asset(st.session_state.project_id, scene_id, "video", status="pending")
st.session_state.scene_videos.pop(scene_id, None)
except Exception as e: except Exception as e:
img_path = None
st.warning(f"Scene {scene_id} 生成失败:{e}") st.warning(f"Scene {scene_id} 生成失败:{e}")
if img_path:
st.session_state.scene_images[scene_id] = img_path
db.save_asset(st.session_state.project_id, scene_id, "image", "completed", local_path=img_path)
# Invalidate stale video for this scene (image changed => old video is wrong)
db.clear_asset(st.session_state.project_id, scene_id, "video", status="pending")
st.session_state.scene_videos.pop(scene_id, None)
progress_bar.progress(done / total_scenes) progress_bar.progress(done / total_scenes)
status_text.text("生图完成!") status_text.text("生图完成!")
@@ -863,112 +1018,152 @@ if st.session_state.view_mode == "workspace":
scenes = st.session_state.script_data.get("scenes", []) scenes = st.session_state.script_data.get("scenes", [])
vid_gen = VideoGenerator() vid_gen = VideoGenerator()
# Submit-only (non-blocking) to avoid freezing Streamlit under concurrency st.caption("简化策略:第 4 步完成“生成→轮询→下载到服务器本地”。当至少有一个分镜视频落盘后,才允许进入第 5 步合成。")
if st.button("🎬 提交图生视频任务(非阻塞)", type="primary"):
if st.button("🎬 生成分镜视频并下载到服务器(阻塞)", type="primary"):
with limits.acquire_video(blocking=False) as ok: with limits.acquire_video(blocking=False) as ok:
if not ok: if not ok:
st.warning("系统正在处理其他视频任务(并发已达上限),请稍后再试。") st.warning("系统正在处理其他视频任务(并发已达上限),请稍后再试。")
st.stop() st.stop()
t0 = perf_counter()
submitted = 0 if not st.session_state.project_id:
st.error("缺少 project_id无法生成视频。")
st.stop()
if not scenes:
st.error("脚本中没有分镜,无法生成视频。")
st.stop()
# 先把 DB 中已完成且存在的本地视频恢复到 session
for scene in scenes: for scene in scenes:
scene_id = scene["id"] scene_id = scene["id"]
existing = st.session_state.scene_videos.get(scene_id)
if existing and os.path.exists(existing):
continue
asset = db.get_asset(st.session_state.project_id, scene_id, "video")
local_path = (asset or {}).get("local_path")
if local_path and os.path.exists(local_path):
st.session_state.scene_videos[scene_id] = local_path
t0 = perf_counter()
total = len(scenes)
done = sum(1 for _sid, p in (st.session_state.scene_videos or {}).items() if p and os.path.exists(p))
progress = st.progress(0.0)
status_text = st.empty()
# 收集/提交任务
tasks = {} # scene_id -> task_id
for scene in scenes:
scene_id = scene["id"]
existing = st.session_state.scene_videos.get(scene_id)
if existing and os.path.exists(existing):
continue
asset = db.get_asset(st.session_state.project_id, scene_id, "video")
task_id = (asset or {}).get("task_id")
if task_id:
tasks[scene_id] = task_id
continue
image_path = st.session_state.scene_images.get(scene_id) image_path = st.session_state.scene_images.get(scene_id)
prompt = scene.get("video_prompt", "High quality video") prompt = scene.get("video_prompt", "High quality video")
task_id = vid_gen.submit_scene_video_task( new_task_id = vid_gen.submit_scene_video_task(
st.session_state.project_id, scene_id, image_path, prompt st.session_state.project_id, scene_id, image_path, prompt
) )
if task_id: if new_task_id:
submitted += 1 tasks[scene_id] = new_task_id
_record_metrics(st.session_state.project_id, {
"video_submit_s": round(perf_counter() - t0, 3), if tasks:
"video_submitted": submitted, db.update_project_status(st.session_state.project_id, "videos_processing")
})
if submitted: out_dir = path_utils.project_videos_dir(st.session_state.project_id)
db.update_project_status(st.session_state.project_id, "videos_processing") pending = set(tasks.keys())
st.success(f"已提交 {submitted} 个分镜视频任务。可点击下方“刷新恢复”下载结果。") deadline = time.time() + 15 * 60 # 15 min
time.sleep(0.5)
st.rerun() while pending and time.time() < deadline:
else: status_text.text(f"视频生成/下载中:已完成 {done}/{total},队列中 {len(pending)} ...")
st.warning("未提交任何任务(可能缺少图片或接口失败)。") to_remove = []
for scene_id in list(pending):
task_id = tasks.get(scene_id)
if not task_id:
to_remove.append(scene_id)
continue
if st.button("🔄 刷新状态并恢复已完成任务", type="secondary"):
with limits.acquire_video(blocking=False) as ok:
if not ok:
st.warning("系统正在处理其他视频任务(并发已达上限),请稍后再试。")
st.stop()
t0 = perf_counter()
updated = 0
for scene in scenes:
scene_id = scene["id"]
asset = db.get_asset(st.session_state.project_id, scene_id, "video")
if not asset or not asset.get("task_id"):
continue
# if already have local video, skip
existing = st.session_state.scene_videos.get(scene_id)
if existing and os.path.exists(existing):
continue
task_id = asset.get("task_id")
# Query volc status; store URL for direct preview (no server download)
status = None
url = None
# short retries for "succeeded but url missing"
for attempt in range(3):
status, url = vid_gen.check_task_status(task_id) status, url = vid_gen.check_task_status(task_id)
if status == "succeeded" and url: if status == "succeeded" and url:
break out_name = path_utils.unique_filename(
time.sleep(0.5 * (2 ** attempt)) prefix="scene_video",
ext="mp4",
project_id=st.session_state.project_id,
scene_id=scene_id,
extra=(task_id[-8:] if isinstance(task_id, str) else None),
)
target_path = str(out_dir / out_name)
ok_dl = vid_gen._download_video_to(url, target_path)
if ok_dl and os.path.exists(target_path):
st.session_state.scene_videos[scene_id] = target_path
db.save_asset(
st.session_state.project_id,
scene_id,
"video",
"completed",
local_path=target_path,
task_id=task_id,
metadata={"downloaded_at": time.time()},
)
done += 1
else:
db.save_asset(
st.session_state.project_id,
scene_id,
"video",
"failed",
task_id=task_id,
metadata={"download_error": True, "checked_at": time.time()},
)
to_remove.append(scene_id)
elif status in ["failed", "cancelled"]:
db.save_asset(
st.session_state.project_id,
scene_id,
"video",
"failed",
task_id=task_id,
metadata={"volc_status": status, "checked_at": time.time()},
)
to_remove.append(scene_id)
else:
db.update_asset_metadata(
st.session_state.project_id,
scene_id,
"video",
{"volc_status": status, "checked_at": time.time()},
)
meta_patch = {"checked_at": time.time(), "volc_status": status} for sid in to_remove:
if url: pending.discard(sid)
meta_patch["video_url"] = url
db.update_asset_metadata(st.session_state.project_id, scene_id, "video", meta_patch) progress.progress(min(1.0, done / max(total, 1)))
updated += 1 if pending:
time.sleep(5)
if pending:
st.warning(f"仍有 {len(pending)} 个分镜未在本轮完成:{sorted(list(pending))}。可再次点击按钮继续轮询与下载。")
_record_metrics(st.session_state.project_id, { _record_metrics(st.session_state.project_id, {
"video_recover_s": round(perf_counter() - t0, 3), "video_blocking_total_s": round(perf_counter() - t0, 3),
"video_recovered": updated, "video_done": done,
"video_total": total,
}) })
if updated:
st.success(f"已刷新 {updated} 个分镜状态(成功的将以 URL 直连预览)。")
else:
st.info("暂无可恢复的视频(可能仍在排队/生成中)。")
time.sleep(0.5)
st.rerun()
if st.button("📥 准备合成素材(下载成功的视频到服务器)", type="secondary"): if any(p and os.path.exists(p) for p in (st.session_state.scene_videos or {}).values()):
with limits.acquire_video(blocking=False) as ok: db.update_project_status(st.session_state.project_id, "videos_generated")
if not ok: st.success("已生成并下载到服务器本地。进入第 5 步合成。")
st.warning("系统正在处理其他视频任务(并发已达上限),请稍后再试。") st.session_state.current_step = 4
st.stop() st.rerun()
downloaded = 0
for scene in scenes:
scene_id = scene["id"]
existing = st.session_state.scene_videos.get(scene_id)
if existing and os.path.exists(existing):
continue
asset = db.get_asset(st.session_state.project_id, scene_id, "video")
meta = (asset or {}).get("metadata") or {}
video_url = meta.get("video_url")
if not video_url:
continue
out_name = path_utils.unique_filename(
prefix="scene_video",
ext="mp4",
project_id=st.session_state.project_id,
scene_id=scene_id,
)
target_path = str(path_utils.project_videos_dir(st.session_state.project_id) / out_name)
if vid_gen._download_video_to(video_url, target_path):
st.session_state.scene_videos[scene_id] = target_path
db.save_asset(st.session_state.project_id, scene_id, "video", "completed", local_path=target_path, task_id=(asset or {}).get("task_id"), metadata=meta)
downloaded += 1
if downloaded:
st.success(f"已下载 {downloaded} 段视频,可进入合成。")
else: else:
st.info("暂无可下载的视频(请先刷新状态获取 video_url") st.error("未生成任何可用视频,请检查生视频接口或稍后重试")
time.sleep(0.5)
st.rerun()
# Display Videos (even when partially available) # Display Videos (even when partially available)
if st.session_state.scene_videos or scenes: if st.session_state.scene_videos or scenes:
@@ -987,43 +1182,14 @@ if st.session_state.view_mode == "workspace":
if vid_path and os.path.exists(vid_path): if vid_path and os.path.exists(vid_path):
st.video(vid_path) st.video(vid_path)
else: else:
# Try URL preview from DB metadata
asset = db.get_asset(st.session_state.project_id, scene_id, "video") asset = db.get_asset(st.session_state.project_id, scene_id, "video")
meta = (asset or {}).get("metadata") or {} status = (asset or {}).get("status") or "pending"
video_url = meta.get("video_url") task_id = (asset or {}).get("task_id")
if video_url: if task_id:
# Detect stale mapping: if source image signature differs, warn and avoid misleading preview st.caption(f"状态: {status} | Task: {str(task_id)[-6:]}")
stale = False
try:
cur_img = st.session_state.scene_images.get(scene_id)
if cur_img and os.path.exists(cur_img):
st_img = stat(cur_img)
cur_size = int(getattr(st_img, "st_size", 0) or 0)
cur_mtime = float(getattr(st_img, "st_mtime", 0.0) or 0.0)
src_size = meta.get("source_image_size")
src_mtime = meta.get("source_image_mtime")
if (src_size and cur_size and int(src_size) != cur_size) or (src_mtime and cur_mtime and abs(float(src_mtime) - cur_mtime) > 1e-3):
stale = True
except Exception:
stale = False
if stale:
st.warning("检测到该视频可能基于旧图片生成(图片已更新)。请点击“提交图生视频任务”重新生成,以避免主体不一致。")
st.caption("URL 直连预览(不经服务器落盘)")
st.video(video_url)
else: else:
st.warning("Video missing") st.caption(f"状态: {status}")
# --- Recovery Logic --- st.warning("暂无本地视频(请点击上方“生成分镜视频并下载到服务器”)。")
if asset and asset.get("task_id"):
task_id = asset.get("task_id")
if st.button(f"🔍 刷新URL (Task {task_id[-6:]})", key=f"recov_{scene_id}"):
with st.spinner("查询任务状态中..."):
status, url = vid_gen.check_task_status(task_id)
patch = {"checked_at": time.time(), "volc_status": status}
if url:
patch["video_url"] = url
db.update_asset_metadata(st.session_state.project_id, scene_id, "video", patch)
st.success("已刷新任务状态。")
st.rerun()
# Per-scene regenerate button # Per-scene regenerate button
if st.button(f"🔄 重生 S{scene_id}", key=f"regen_vid_{scene_id}"): if st.button(f"🔄 重生 S{scene_id}", key=f"regen_vid_{scene_id}"):
@@ -1046,14 +1212,11 @@ if st.session_state.view_mode == "workspace":
scene_id=scene_id, scene_id=scene_id,
extra=(t_id[-8:] if isinstance(t_id, str) else None), extra=(t_id[-8:] if isinstance(t_id, str) else None),
) )
new_path = vid_gen._download_video( target_dir = path_utils.project_videos_dir(st.session_state.project_id)
url, target_path = str(target_dir / out_name)
out_name, if vid_gen._download_video_to(url, target_path) and os.path.exists(target_path):
output_dir=path_utils.project_videos_dir(st.session_state.project_id), st.session_state.scene_videos[scene_id] = target_path
) db.save_asset(st.session_state.project_id, scene_id, "video", "completed", local_path=target_path, task_id=t_id)
if new_path:
st.session_state.scene_videos[scene_id] = new_path
db.save_asset(st.session_state.project_id, scene_id, "video", "completed", local_path=new_path, task_id=t_id)
st.rerun() st.rerun()
break break
elif status in ["failed", "cancelled"]: elif status in ["failed", "cancelled"]:
@@ -1067,18 +1230,23 @@ if st.session_state.view_mode == "workspace":
c_act1, c_act2 = st.columns([1, 4]) c_act1, c_act2 = st.columns([1, 4])
with c_act1: with c_act1:
if st.button("🔄 重新生成所有视频", type="secondary"): if st.button("🧹 清空视频并重新生成", type="secondary"):
# Clear videos and rerun # Clear videos and rerun
st.session_state.scene_videos = {} st.session_state.scene_videos = {}
# Also clear DB video assets to avoid stale URL preview # Also clear DB video assets
if st.session_state.project_id: if st.session_state.project_id:
db.clear_assets(st.session_state.project_id, "video", status="pending") db.clear_assets(st.session_state.project_id, "video", status="pending")
st.rerun() st.rerun()
with c_act2: with c_act2:
if st.button("下一步:合成最终成片", type="primary"): can_compose = any(
p and os.path.exists(p) for p in (st.session_state.scene_videos or {}).values()
)
if st.button("进入第 5 步:合成最终成片", type="primary", disabled=not can_compose):
st.session_state.current_step = 4 st.session_state.current_step = 4
st.rerun() st.rerun()
if not can_compose:
st.caption("⚠️ 需要至少一个分镜视频已下载到服务器本地后,才能进入合成。")
# --- Step 5: Final Composition & Tuning --- # --- Step 5: Final Composition & Tuning ---
if st.session_state.current_step >= 4: if st.session_state.current_step >= 4:
@@ -1174,6 +1342,9 @@ if st.session_state.view_mode == "workspace":
if st.button("🔄 重新合成 (Re-Compose)", type="primary"): if st.button("🔄 重新合成 (Re-Compose)", type="primary"):
with st.spinner("正在应用修改并重新合成..."): with st.spinner("正在应用修改并重新合成..."):
# 自动补齐视频下载逻辑 (关键优化)
_ensure_local_videos(st.session_state.project_id, st.session_state.script_data.get("scenes", []))
composer = VideoComposer(voice_type=selected_voice) composer = VideoComposer(voice_type=selected_voice)
bgm_path = None bgm_path = None
@@ -1255,6 +1426,9 @@ if st.session_state.view_mode == "workspace":
st.info("暂无合成视频,请先点击开始合成。") st.info("暂无合成视频,请先点击开始合成。")
if st.button("✨ 开始首次合成", type="primary"): if st.button("✨ 开始首次合成", type="primary"):
with st.spinner("正在进行多轨合成..."): with st.spinner("正在进行多轨合成..."):
# 自动补齐视频下载逻辑 (关键优化)
_ensure_local_videos(st.session_state.project_id, st.session_state.script_data.get("scenes", []))
# Default compose logic with smart BGM matching # Default compose logic with smart BGM matching
composer = VideoComposer(voice_type=config.VOLC_TTS_DEFAULT_VOICE) composer = VideoComposer(voice_type=config.VOLC_TTS_DEFAULT_VOICE)
@@ -1290,6 +1464,9 @@ if st.session_state.view_mode == "workspace":
else: else:
st.success(f"共找到 {len(found_files)} 个历史版本") st.success(f"共找到 {len(found_files)} 个历史版本")
# 提示用户:如果重新合成,系统会自动补全未下载的素材
st.caption("💡 重新合成将应用当前的微调设置。如果分镜未下载,系统将尝试自动补全。")
# 遍历显示所有历史版本 # 遍历显示所有历史版本
for idx, vid_path in enumerate(found_files): for idx, vid_path in enumerate(found_files):
mtime = os.path.getmtime(vid_path) mtime = os.path.getmtime(vid_path)
@@ -1324,7 +1501,7 @@ if st.session_state.view_mode == "workspace":
elif st.session_state.view_mode == "history": elif st.session_state.view_mode == "history":
st.header("📜 历史任务") st.header("📜 历史任务")
projects = db.list_projects() projects = db.list_projects_for_user(_current_user())
for proj in projects: for proj in projects:
with st.expander(f"{proj['name']} ({proj['updated_at']})"): with st.expander(f"{proj['name']} ({proj['updated_at']})"):
@@ -1394,11 +1571,19 @@ elif st.session_state.view_mode == "settings":
st.subheader("Prompt 配置") st.subheader("Prompt 配置")
# Script Generation Prompt # Script Generation Prompt
current_prompt = db.get_config("prompt_script_gen") cu = _current_user() or {}
user_prompt = None
try:
user_prompt = db.get_user_prompt(cu.get("id"), "prompt_script_gen") if cu.get("id") else None
except Exception:
user_prompt = None
current_prompt = user_prompt or db.get_config("prompt_script_gen")
# 显示当前状态 # 显示当前状态
if current_prompt: if user_prompt:
st.info("✅ 已加载自定义 Prompt来自数据库") st.info("✅ 已加载自定义 Prompt当前用户")
elif current_prompt:
st.info("✅ 已加载自定义 Prompt全局默认")
else: else:
st.warning("⚠️ 使用默认 Prompt数据库中无自定义配置") st.warning("⚠️ 使用默认 Prompt数据库中无自定义配置")
# Load default from instance if not in DB # Load default from instance if not in DB
@@ -1410,18 +1595,89 @@ elif st.session_state.view_mode == "settings":
col_save, col_reset = st.columns([1, 3]) col_save, col_reset = st.columns([1, 3])
with col_save: with col_save:
if st.button("💾 保存配置", type="primary"): if st.button("💾 保存配置", type="primary"):
db.set_config("prompt_script_gen", new_prompt, "System prompt for script generation step") # Save per-user prompt
# 验证保存 db.set_user_prompt(cu.get("id"), "prompt_script_gen", new_prompt)
saved = db.get_config("prompt_script_gen") saved = db.get_user_prompt(cu.get("id"), "prompt_script_gen")
if saved == new_prompt: if saved == new_prompt:
st.success("配置已保存并验证成功!下次生成脚本时将使用新 Prompt") st.success("✅ 已保存为“当前用户 Prompt”。下次生成脚本仅影响你自己的账号")
else: else:
st.error("❌ 保存可能失败,请检查日志") st.error("❌ 保存可能失败,请检查日志")
with col_reset: with col_reset:
if st.button("🔄 恢复默认"): if st.button("🔄 恢复默认"):
temp_gen = ScriptGenerator() temp_gen = ScriptGenerator()
db.set_config("prompt_script_gen", temp_gen.default_system_prompt, "System prompt for script generation step (DEFAULT)") # Clear per-user override: set empty to remove effect
st.success("已恢复默认 Prompt请刷新页面查看") db.set_user_prompt(cu.get("id"), "prompt_script_gen", "")
st.success("已清除当前用户 Prompt将回退到全局/默认 Prompt。")
st.rerun()
st.markdown("---")
st.subheader("账号与权限")
# Password change for current user
with st.expander("修改我的密码", expanded=False):
p1 = st.text_input("新密码", type="password", key=_ui_key("pwd_new"))
p2 = st.text_input("确认新密码", type="password", key=_ui_key("pwd_new2"))
if st.button("保存新密码", type="primary", key=_ui_key("pwd_save")):
if not p1 or len(p1) < 6:
st.error("密码至少 6 位")
elif p1 != p2:
st.error("两次输入不一致")
else:
db.reset_user_password(cu.get("id"), p1)
st.success("密码已更新,请重新登录。")
_logout()
# Admin console
if (cu.get("role") == "admin"):
st.markdown("### Admin账号管理")
users = db.list_users()
if users:
st.dataframe(
[{k: u.get(k) for k in ["username", "role", "is_active", "last_login_at", "created_at", "id"]} for u in users],
use_container_width=True,
)
with st.expander("创建/更新用户", expanded=False):
u_name = st.text_input("用户名", key=_ui_key("adm_u_name"))
u_role = st.selectbox("角色", ["user", "admin"], index=0, key=_ui_key("adm_u_role"))
u_active = st.selectbox("是否启用", ["启用", "禁用"], index=0, key=_ui_key("adm_u_active"))
u_pwd = st.text_input("初始/重置密码", type="password", key=_ui_key("adm_u_pwd"))
if st.button("保存用户", type="primary", key=_ui_key("adm_u_save")):
if not u_name:
st.error("用户名不能为空")
elif not u_pwd or len(u_pwd) < 6:
st.error("密码至少 6 位")
else:
uid = db.upsert_user(
username=u_name.strip(),
password=u_pwd,
role=u_role,
is_active=(1 if u_active == "启用" else 0),
)
st.success(f"已保存用户:{u_name} (id={uid})")
st.rerun()
with st.expander("重置/禁用用户(按用户名)", expanded=False):
uname = st.selectbox(
"选择用户",
options=[u.get("username") for u in users if u.get("username")],
key=_ui_key("adm_sel_user"),
) if users else None
new_pass = st.text_input("新密码(可选)", type="password", key=_ui_key("adm_reset_pwd"))
new_active = st.selectbox("状态", ["不修改", "启用", "禁用"], key=_ui_key("adm_reset_active"))
if st.button("应用修改", type="secondary", key=_ui_key("adm_apply")):
target = next((u for u in users if u.get("username") == uname), None)
if not target:
st.error("未找到用户")
else:
if new_pass:
if len(new_pass) < 6:
st.error("密码至少 6 位")
st.stop()
db.reset_user_password(target.get("id"), new_pass)
if new_active != "不修改":
db.set_user_active(target.get("id"), 1 if new_active == "启用" else 0)
st.success("已更新")
st.rerun() st.rerun()

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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": ["气泡", "弹幕", "链接"] }
]
}
]
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -38,6 +38,10 @@ DOUBAO_IMG_MODEL = "ep-20251203231641-wg9nb"
SHUBIAOBIAO_KEY = os.getenv("SHUBIAOBIAO_KEY", "sk-aL167A8sQEyvs40yBfC140Fc0fDa4c198f029aAcF0429108") SHUBIAOBIAO_KEY = os.getenv("SHUBIAOBIAO_KEY", "sk-aL167A8sQEyvs40yBfC140Fc0fDa4c198f029aAcF0429108")
SHUBIAOBIAO_BASE_URL = os.getenv("SHUBIAOBIAO_BASE_URL", "https://api.shubiaobiao.cn/v1") SHUBIAOBIAO_BASE_URL = os.getenv("SHUBIAOBIAO_BASE_URL", "https://api.shubiaobiao.cn/v1")
SHUBIAOBIAO_MODEL_TEXT = "gemini-3-pro-preview" SHUBIAOBIAO_MODEL_TEXT = "gemini-3-pro-preview"
# ShuBiaoBiao (OpenAI-compatible) chat timeout/retries
# Note: OpenAI Python SDK default timeout is 10 minutes; we cap it to avoid UI "hang" feeling.
SHUBIAOBIAO_CHAT_TIMEOUT_S = float(os.getenv("SHUBIAOBIAO_CHAT_TIMEOUT_S", "180"))
SHUBIAOBIAO_CHAT_MAX_RETRIES = int(os.getenv("SHUBIAOBIAO_CHAT_MAX_RETRIES", "2"))
# Image Generation API (Updated) # Image Generation API (Updated)
# Host: https://api.wuyinkeji.com/ # Host: https://api.wuyinkeji.com/

View 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

View File

@@ -0,0 +1,51 @@
## 目标
为编辑器内置一个**适合抖音场景**的贴纸库PNG/SVG并保证
- **可商用/可分发**(许可清晰)
- **可本地托管**(不依赖外部 CDN
- **所见即所得**:预览与导出一致(贴纸叠加到成片)
## 推荐贴纸库(抖音场景友好)
### 方案 AMicrosoft Fluent UI Emoji更“抖音感”
- **风格**:高饱和、现代、偏 3D/大图标,适合“强调/氛围/卖点”
- **形态**PNG/SVG仓库提供多种风格/尺寸)
- **适用**:火/赞/心/星星/箭头/提示/表情等常用贴纸
- **风险**:请在引入前再次核对仓库 LICENSE不同仓库/分支可能不同)
### 方案 BTwemoji稳定、覆盖全、但更像“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
View 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
View 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()

View File

@@ -502,11 +502,20 @@ class VideoComposer:
current_video = fancy_path current_video = fancy_path
# 7. 添加 BGM # 7. 添加 BGM
# 说明add_bgm 的 ducking=True 路径使用 sidechaincompress但该滤镜本身不做“混音”
# 在某些 ffmpeg 版本/参数组合下会导致 BGM 听起来像“没加上”。
# 我们在 compose() 里已禁用 ducking这里保持一致使用 amix 叠加并提高默认音量。
if bgm_path: if bgm_path:
bgm_output = str(Path(temp_root) / f"{output_name}_bgm.mp4") bgm_output = str(Path(temp_root) / f"{output_name}_bgm.mp4")
ffmpeg_utils.add_bgm( ffmpeg_utils.add_bgm(
current_video, bgm_path, bgm_output, current_video,
bgm_volume=0.15 bgm_path,
bgm_output,
bgm_volume=0.20,
ducking=False,
duck_gain_db=-6.0,
fade_in=1.0,
fade_out=1.0,
) )
self._add_temp(bgm_output) self._add_temp(bgm_output)
current_video = bgm_output current_video = bgm_output

View File

@@ -6,14 +6,43 @@
import json import json
import logging import logging
import time import time
from typing import Dict, List, Any, Optional import secrets
from typing import Dict, List, Any, Optional, Tuple
from sqlalchemy import create_engine, Column, String, Integer, Text, Float, UniqueConstraint, func from sqlalchemy import create_engine, Column, String, Integer, Text, Float, UniqueConstraint, func, text, inspect
from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
import config import config
# NOTE: Some deployments do not ship `modules/auth.py`.
# Keep a minimal, local auth helper here to avoid hard dependency.
import hashlib
import hmac
def _hash_password(password: str, salt_hex: str = None) -> tuple[str, str]:
salt = bytes.fromhex(salt_hex) if salt_hex else secrets.token_bytes(16)
dk = hashlib.pbkdf2_hmac("sha256", (password or "").encode("utf-8"), salt, 120_000)
return dk.hex(), salt.hex()
def _verify_password(password: str, pwd_hash_hex: str, salt_hex: str) -> bool:
try:
cand, _ = _hash_password(password or "", salt_hex=salt_hex)
return hmac.compare_digest(cand, pwd_hash_hex or "")
except Exception:
return False
def _new_session_token() -> str:
return secrets.token_urlsafe(32)
def _hash_token(token: str) -> str:
# store only hash for sessions
return hashlib.sha256((token or "").encode("utf-8")).hexdigest()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
Base = declarative_base() Base = declarative_base()
@@ -26,6 +55,7 @@ class Project(Base):
status = Column(String) # created, script_generated, images_generated, videos_generated, completed status = Column(String) # created, script_generated, images_generated, videos_generated, completed
product_info = Column(Text) # JSON string (SQLite) or JSONB (PG - using Text for compat) product_info = Column(Text) # JSON string (SQLite) or JSONB (PG - using Text for compat)
script_data = Column(Text) # JSON string script_data = Column(Text) # JSON string
owner_user_id = Column(String, index=True, nullable=True)
created_at = Column(Float, default=time.time) created_at = Column(Float, default=time.time)
updated_at = Column(Float, default=time.time, onupdate=time.time) updated_at = Column(Float, default=time.time, onupdate=time.time)
@@ -54,6 +84,62 @@ class AppConfig(Base):
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
updated_at = Column(Float, default=time.time, onupdate=time.time) updated_at = Column(Float, default=time.time, onupdate=time.time)
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True) # uuid hex
username = Column(String, unique=True, index=True)
password_hash = Column(String)
password_salt = Column(String)
role = Column(String, default="user") # admin/user
is_active = Column(Integer, default=1) # 1/0 for portability
created_at = Column(Float, default=time.time)
updated_at = Column(Float, default=time.time, onupdate=time.time)
last_login_at = Column(Float, nullable=True)
class UserSession(Base):
__tablename__ = "user_sessions"
id = Column(String, primary_key=True) # uuid hex
user_id = Column(String, index=True)
token_hash = Column(String, index=True)
expires_at = Column(Float)
created_at = Column(Float, default=time.time)
last_seen_at = Column(Float, default=time.time)
ip = Column(String, nullable=True)
user_agent = Column(String, nullable=True)
class UserPrompt(Base):
__tablename__ = "user_prompts"
id = Column(String, primary_key=True) # uuid hex
user_id = Column(String, index=True)
key = Column(String, index=True)
value = Column(Text)
updated_at = Column(Float, default=time.time, onupdate=time.time)
__table_args__ = (UniqueConstraint("user_id", "key", name="uix_user_prompt"),)
class RenderJob(Base):
"""
Render job status table (async compose pipeline).
This is intentionally minimal and portable across SQLite/Postgres.
"""
__tablename__ = "render_jobs"
id = Column(String, primary_key=True) # task_id
project_id = Column(String, index=True)
status = Column(String, default="queued") # queued/running/success/failed/cancelled
progress = Column(Float, default=0.0)
message = Column(Text, default="")
output_path = Column(Text, nullable=True)
output_url = Column(Text, nullable=True)
error = Column(Text, nullable=True)
request_json = Column(Text, nullable=True) # JSON string of ComposeRequest
parent_id = Column(String, nullable=True, index=True)
created_at = Column(Float, default=time.time)
updated_at = Column(Float, default=time.time, onupdate=time.time)
class DBManager: class DBManager:
def __init__(self, connection_string: str = None): def __init__(self, connection_string: str = None):
if not connection_string: if not connection_string:
@@ -62,17 +148,59 @@ class DBManager:
self.engine = create_engine(connection_string, pool_recycle=3600) self.engine = create_engine(connection_string, pool_recycle=3600)
self.Session = scoped_session(sessionmaker(bind=self.engine)) self.Session = scoped_session(sessionmaker(bind=self.engine))
self._init_db() self._init_db()
# bootstrap admin (safe to call repeatedly)
try:
self._bootstrap_admin_id = self.ensure_admin_user("admin", "admin1234")
except Exception:
self._bootstrap_admin_id = None
def _init_db(self): def _init_db(self):
"""初始化表结构""" """初始化表结构 + 轻量自迁移(不依赖 Alembic"""
Base.metadata.create_all(self.engine) Base.metadata.create_all(self.engine)
self._ensure_schema()
def _ensure_schema(self) -> None:
"""
Ensure newer columns/tables exist in both Postgres and SQLite.
create_all will not add columns to existing tables, so we do a minimal ALTER here.
"""
try:
insp = inspect(self.engine)
# projects.owner_user_id
try:
cols = [c["name"] for c in insp.get_columns("projects")]
except Exception:
cols = []
if "owner_user_id" not in cols:
logger.info("Migrating: add projects.owner_user_id")
with self.engine.begin() as conn:
conn.execute(text("ALTER TABLE projects ADD COLUMN owner_user_id VARCHAR"))
try:
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_projects_owner_user_id ON projects (owner_user_id)"))
except Exception:
# some engines (older sqlite) may not support IF NOT EXISTS
try:
conn.execute(text("CREATE INDEX ix_projects_owner_user_id ON projects (owner_user_id)"))
except Exception:
pass
except Exception as e:
logger.warning(f"Schema ensure skipped/failed (non-fatal): {e}")
# Ensure render_jobs table exists (create_all should handle this; keep for safety)
try:
insp = inspect(self.engine)
if "render_jobs" not in insp.get_table_names():
logger.info("Migrating: create render_jobs table")
Base.metadata.create_all(self.engine)
except Exception as e:
logger.warning(f"render_jobs ensure skipped/failed (non-fatal): {e}")
def _get_session(self): def _get_session(self):
return self.Session() return self.Session()
# --- Project Operations --- # --- Project Operations ---
def create_project(self, project_id: str, name: str, product_info: Dict[str, Any]): def create_project(self, project_id: str, name: str, product_info: Dict[str, Any], owner_user_id: Optional[str] = None):
session = self._get_session() session = self._get_session()
try: try:
# Check if exists # Check if exists
@@ -86,6 +214,7 @@ class DBManager:
name=name, name=name,
status="created", status="created",
product_info=json.dumps(product_info, ensure_ascii=False), product_info=json.dumps(product_info, ensure_ascii=False),
owner_user_id=owner_user_id,
created_at=time.time(), created_at=time.time(),
updated_at=time.time() updated_at=time.time()
) )
@@ -157,6 +286,7 @@ class DBManager:
"status": project.status, "status": project.status,
"product_info": json.loads(project.product_info) if project.product_info else {}, "product_info": json.loads(project.product_info) if project.product_info else {},
"script_data": json.loads(project.script_data) if project.script_data else None, "script_data": json.loads(project.script_data) if project.script_data else None,
"owner_user_id": getattr(project, "owner_user_id", None),
"created_at": project.created_at, "created_at": project.created_at,
"updated_at": project.updated_at "updated_at": project.updated_at
} }
@@ -175,12 +305,288 @@ class DBManager:
"id": p.id, "id": p.id,
"name": p.name, "name": p.name,
"status": p.status, "status": p.status,
"owner_user_id": getattr(p, "owner_user_id", None),
"updated_at": p.updated_at "updated_at": p.updated_at
}) })
return results return results
finally: finally:
session.close() session.close()
# --- User/Auth Operations ---
def ensure_admin_user(self, username: str = "admin", password: str = "admin1234") -> str:
"""Create bootstrap admin user if missing. Returns admin user_id."""
session = self._get_session()
try:
u = session.query(User).filter_by(username=username).first()
if u:
return u.id
pwd_hash, salt_hex = _hash_password(password)
uid = secrets.token_hex(16)
u = User(
id=uid,
username=username,
password_hash=pwd_hash,
password_salt=salt_hex,
role="admin",
is_active=1,
created_at=time.time(),
updated_at=time.time(),
)
session.add(u)
session.commit()
logger.warning("Bootstrap admin created (please change password asap).")
return uid
except Exception as e:
session.rollback()
logger.error(f"ensure_admin_user failed: {e}")
raise
finally:
session.close()
def authenticate_user(self, username: str, password: str) -> Optional[Dict[str, Any]]:
session = self._get_session()
try:
u = session.query(User).filter_by(username=username).first()
if not u or int(getattr(u, "is_active", 1) or 0) != 1:
return None
if not _verify_password(password or "", getattr(u, "password_hash", ""), getattr(u, "password_salt", "")):
return None
u.last_login_at = time.time()
session.commit()
return {"id": u.id, "username": u.username, "role": u.role, "is_active": int(u.is_active or 0)}
except Exception as e:
session.rollback()
logger.error(f"authenticate_user error: {e}")
return None
finally:
session.close()
def create_session(self, user_id: str, *, ttl_seconds: int = 7 * 24 * 3600, ip: str = None, user_agent: str = None) -> str:
"""Returns raw session token (store hash in DB)."""
token = _new_session_token()
token_hash = _hash_token(token)
sid = secrets.token_hex(16)
session = self._get_session()
try:
now = time.time()
s = UserSession(
id=sid,
user_id=user_id,
token_hash=token_hash,
expires_at=now + int(ttl_seconds),
created_at=now,
last_seen_at=now,
ip=ip,
user_agent=user_agent,
)
session.add(s)
session.commit()
return token
except Exception as e:
session.rollback()
logger.error(f"create_session error: {e}")
raise
finally:
session.close()
def validate_session(self, token: str) -> Optional[Dict[str, Any]]:
if not token:
return None
token_hash = _hash_token(token)
session = self._get_session()
try:
now = time.time()
s = session.query(UserSession).filter_by(token_hash=token_hash).first()
if not s or (s.expires_at and float(s.expires_at) < now):
return None
u = session.query(User).filter_by(id=s.user_id).first()
if not u or int(getattr(u, "is_active", 1) or 0) != 1:
return None
s.last_seen_at = now
session.commit()
return {"id": u.id, "username": u.username, "role": u.role, "is_active": int(u.is_active or 0)}
except Exception as e:
session.rollback()
logger.error(f"validate_session error: {e}")
return None
finally:
session.close()
def revoke_session(self, token: str) -> None:
if not token:
return
token_hash = _hash_token(token)
session = self._get_session()
try:
s = session.query(UserSession).filter_by(token_hash=token_hash).first()
if s:
session.delete(s)
session.commit()
except Exception as e:
session.rollback()
logger.error(f"revoke_session error: {e}")
finally:
session.close()
def list_users(self) -> List[Dict[str, Any]]:
session = self._get_session()
try:
users = session.query(User).order_by(User.created_at.asc()).all()
return [
{
"id": u.id,
"username": u.username,
"role": u.role,
"is_active": int(u.is_active or 0),
"created_at": u.created_at,
"last_login_at": u.last_login_at,
}
for u in users
]
finally:
session.close()
def upsert_user(self, username: str, password: str, *, role: str = "user", is_active: int = 1) -> str:
session = self._get_session()
try:
u = session.query(User).filter_by(username=username).first()
pwd_hash, salt_hex = _hash_password(password)
now = time.time()
if u:
u.password_hash = pwd_hash
u.password_salt = salt_hex
u.role = role
u.is_active = int(is_active)
u.updated_at = now
session.commit()
return u.id
uid = secrets.token_hex(16)
u = User(
id=uid,
username=username,
password_hash=pwd_hash,
password_salt=salt_hex,
role=role,
is_active=int(is_active),
created_at=now,
updated_at=now,
)
session.add(u)
session.commit()
return uid
except Exception as e:
session.rollback()
logger.error(f"upsert_user error: {e}")
raise
finally:
session.close()
def set_user_active(self, user_id: str, is_active: int) -> None:
session = self._get_session()
try:
u = session.query(User).filter_by(id=user_id).first()
if not u:
return
u.is_active = int(is_active)
u.updated_at = time.time()
session.commit()
except Exception as e:
session.rollback()
logger.error(f"set_user_active error: {e}")
finally:
session.close()
def reset_user_password(self, user_id: str, new_password: str) -> None:
session = self._get_session()
try:
u = session.query(User).filter_by(id=user_id).first()
if not u:
return
pwd_hash, salt_hex = _hash_password(new_password)
u.password_hash = pwd_hash
u.password_salt = salt_hex
u.updated_at = time.time()
session.commit()
except Exception as e:
session.rollback()
logger.error(f"reset_user_password error: {e}")
finally:
session.close()
def get_user_prompt(self, user_id: str, key: str) -> Optional[str]:
if not user_id or not key:
return None
session = self._get_session()
try:
p = session.query(UserPrompt).filter_by(user_id=user_id, key=key).first()
return p.value if p else None
finally:
session.close()
def set_user_prompt(self, user_id: str, key: str, value: Any) -> None:
if not user_id or not key:
return
session = self._get_session()
try:
now = time.time()
v = json.dumps(value, ensure_ascii=False) if not isinstance(value, str) else value
p = session.query(UserPrompt).filter_by(user_id=user_id, key=key).first()
if p:
p.value = v
p.updated_at = now
else:
p = UserPrompt(id=secrets.token_hex(16), user_id=user_id, key=key, value=v, updated_at=now)
session.add(p)
session.commit()
except Exception as e:
session.rollback()
logger.error(f"set_user_prompt error: {e}")
finally:
session.close()
# --- RBAC helpers ---
def list_projects_for_user(self, user: Dict[str, Any]) -> List[Dict[str, Any]]:
if not user:
return []
if user.get("role") == "admin":
return self.list_projects()
uid = user.get("id")
session = self._get_session()
try:
projects = session.query(Project).filter_by(owner_user_id=uid).order_by(Project.updated_at.desc()).all()
return [{"id": p.id, "name": p.name, "status": p.status, "owner_user_id": p.owner_user_id, "updated_at": p.updated_at} for p in projects]
finally:
session.close()
def get_project_for_user(self, project_id: str, user: Dict[str, Any]) -> Optional[Dict[str, Any]]:
data = self.get_project(project_id)
if not data or not user:
return None
if user.get("role") == "admin":
return data
if data.get("owner_user_id") != user.get("id"):
return None
return data
def migrate_projects_owner_to(self, owner_user_id: str) -> int:
"""Assign owner_user_id for legacy projects where it is NULL/empty."""
session = self._get_session()
try:
q = session.query(Project).filter((Project.owner_user_id == None) | (Project.owner_user_id == "")) # noqa: E711
rows = q.all()
for p in rows:
p.owner_user_id = owner_user_id
p.updated_at = time.time()
session.commit()
return len(rows)
except Exception as e:
session.rollback()
logger.error(f"migrate_projects_owner_to error: {e}")
return 0
finally:
session.close()
# --- Asset/Task Operations --- # --- Asset/Task Operations ---
def save_asset(self, project_id: str, scene_id: int, asset_type: str, def save_asset(self, project_id: str, scene_id: int, asset_type: str,
@@ -253,6 +659,92 @@ class DBManager:
finally: finally:
session.close() session.close()
# --- Render Job Operations ---
def create_render_job(
self,
job_id: str,
project_id: str,
*,
status: str = "queued",
progress: float = 0.0,
message: str = "",
request: Optional[Dict[str, Any]] = None,
parent_id: Optional[str] = None,
) -> None:
session = self._get_session()
try:
existing = session.query(RenderJob).filter_by(id=job_id).first()
if existing:
return
now = time.time()
rj = RenderJob(
id=job_id,
project_id=project_id,
status=status,
progress=float(progress or 0.0),
message=message or "",
request_json=json.dumps(request, ensure_ascii=False) if request is not None else None,
parent_id=parent_id,
created_at=now,
updated_at=now,
)
session.add(rj)
session.commit()
except Exception as e:
session.rollback()
logger.error(f"create_render_job error: {e}")
raise
finally:
session.close()
def update_render_job(self, job_id: str, patch: Dict[str, Any]) -> None:
if not job_id or not patch:
return
session = self._get_session()
try:
rj = session.query(RenderJob).filter_by(id=job_id).first()
if not rj:
return
for k, v in patch.items():
if not hasattr(rj, k):
continue
setattr(rj, k, v)
rj.updated_at = time.time()
session.commit()
except Exception as e:
session.rollback()
logger.error(f"update_render_job error: {e}")
finally:
session.close()
def get_render_job(self, job_id: str) -> Optional[Dict[str, Any]]:
session = self._get_session()
try:
rj = session.query(RenderJob).filter_by(id=job_id).first()
if not rj:
return None
try:
req = json.loads(rj.request_json) if rj.request_json else None
except Exception:
req = None
return {
"id": rj.id,
"project_id": rj.project_id,
"status": rj.status,
"progress": float(rj.progress or 0.0),
"message": rj.message or "",
"output_path": rj.output_path,
"output_url": rj.output_url,
"error": rj.error,
"request": req,
"parent_id": rj.parent_id,
"created_at": rj.created_at,
"updated_at": rj.updated_at,
}
finally:
session.close()
def get_asset(self, project_id: str, scene_id: int, asset_type: str) -> Optional[Dict[str, Any]]: def get_asset(self, project_id: str, scene_id: int, asset_type: str) -> Optional[Dict[str, Any]]:
session = self._get_session() session = self._get_session()
try: try:
@@ -279,6 +771,30 @@ class DBManager:
finally: finally:
session.close() session.close()
def get_asset_by_id(self, asset_id: int) -> Optional[Dict[str, Any]]:
"""通过自增 id 获取素材记录(用于 file proxy"""
session = self._get_session()
try:
a = session.query(SceneAsset).filter_by(id=int(asset_id)).first()
if not a:
return None
return {
"id": a.id,
"project_id": a.project_id,
"scene_id": a.scene_id,
"asset_type": a.asset_type,
"status": a.status,
"local_path": a.local_path,
"remote_url": a.remote_url,
"task_id": a.task_id,
"metadata": json.loads(a.metadata_json) if a.metadata_json else {},
"updated_at": a.updated_at,
}
except Exception:
return None
finally:
session.close()
def update_asset_metadata(self, project_id: str, scene_id: int, asset_type: str, patch: Dict[str, Any]) -> None: def update_asset_metadata(self, project_id: str, scene_id: int, asset_type: str, patch: Dict[str, Any]) -> None:
"""Merge-patch asset.metadata JSON without overwriting other fields.""" """Merge-patch asset.metadata JSON without overwriting other fields."""
if not patch: if not patch:

View File

@@ -172,7 +172,8 @@ def get_video_info(video_path: str) -> Dict[str, Any]:
def concat_videos( def concat_videos(
video_paths: List[str], video_paths: List[str],
output_path: str, output_path: str,
target_size: Tuple[int, int] = (1080, 1920) target_size: Tuple[int, int] = (1080, 1920),
fades: Optional[List[Dict[str, float]]] = None
) -> str: ) -> str:
""" """
使用 FFmpeg concat demuxer 拼接多段视频 使用 FFmpeg concat demuxer 拼接多段视频
@@ -197,10 +198,72 @@ def concat_videos(
filter_parts = [] filter_parts = []
for i in range(len(video_paths)): for i in range(len(video_paths)):
# scale 保持宽高比pad 填充黑边居中 # scale 保持宽高比pad 填充黑边居中
filter_parts.append( chain = (
f"[{i}:v]scale={width}:{height}:force_original_aspect_ratio=decrease," f"[{i}:v]scale={width}:{height}:force_original_aspect_ratio=decrease,"
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[v{i}]" f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1"
) )
# 可选:片段末尾“火山式转场”(不改时长、不重叠)
if fades and i < len(fades):
fx = fades[i] or {}
fi = float(fx.get("in", 0) or 0.0)
fo = float(fx.get("out", 0) or 0.0)
t_type = str(fx.get("type") or "")
t_dur = float(fx.get("dur") or 0.0)
try:
dur = float(get_video_info(video_paths[i]).get("duration") or 0.0)
except Exception:
dur = 0.0
# 基础淡入/淡出
if fi > 0:
chain += f",fade=t=in:st=0:d={fi}"
if fo > 0 and dur > 0:
st = max(dur - fo, 0.0)
chain += f",fade=t=out:st={st}:d={fo}"
# 末尾动效WYSIWYG前端预览必须与此一致
if t_type and t_dur > 0 and dur > 0:
st = max(dur - t_dur, 0.0)
td = max(t_dur, 0.001)
p = f"if(between(t\\,{st}\\,{dur})\\,(t-{st})/{td}\\,0)"
if t_type == "fade":
chain += f",fade=t=out:st={st}:d={t_dur}"
elif t_type == "fadeWhite":
chain += f",fade=t=out:st={st}:d={t_dur}:color=white"
elif t_type == "blurOut":
chain += f",gblur=sigma='10*{p}':steps=1"
elif t_type == "blurFade":
chain += f",gblur=sigma='8*{p}':steps=1,fade=t=out:st={st}:d={t_dur}"
elif t_type == "flash":
chain += f",eq=brightness='0.7*(1-abs(0.5-{p})*2)'"
elif t_type == "desaturate":
chain += f",hue=s='1-0.9*{p}'"
elif t_type == "colorPop":
chain += f",hue=s='1+0.8*{p}',eq=contrast='1+0.3*{p}'"
elif t_type == "hueShift":
chain += f",hue=h='60*{p}'"
elif t_type == "darken":
chain += f",eq=brightness='-0.4*{p}'"
elif t_type in ("slideLeft", "slideRight", "slideUp", "slideDown"):
off = 80
if t_type == "slideLeft":
chain += f",pad={width+off}:{height}:{off/2}-{off}*{p}:0:black,crop={width}:{height}:{off/2}:0"
if t_type == "slideRight":
chain += f",pad={width+off}:{height}:{off/2}+{off}*{p}:0:black,crop={width}:{height}:{off/2}:0"
if t_type == "slideUp":
chain += f",pad={width}:{height+off}:0:{off/2}-{off}*{p}:black,crop={width}:{height}:0:{off/2}"
if t_type == "slideDown":
chain += f",pad={width}:{height+off}:0:{off/2}+{off}*{p}:black,crop={width}:{height}:0:{off/2}"
elif t_type in ("zoomOut", "zoomIn"):
if t_type == "zoomOut":
chain += f",scale=w='{width}*(1-0.10*{p})':h='{height}*(1-0.10*{p})':eval=frame,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black"
else:
chain += f",scale=w='{width}*(1+0.10*{p})':h='{height}*(1+0.10*{p})':eval=frame,crop={width}:{height}"
elif t_type == "rotateOut":
chain += f",rotate=a='0.12*{p}':c=black@1:ow={width}:oh={height}"
chain += f"[v{i}]"
filter_parts.append(chain)
# 拼接所有视频流 # 拼接所有视频流
concat_inputs = "".join([f"[v{i}]" for i in range(len(video_paths))]) concat_inputs = "".join([f"[v{i}]" for i in range(len(video_paths))])
@@ -232,7 +295,8 @@ def concat_videos(
def concat_videos_with_audio( def concat_videos_with_audio(
video_paths: List[str], video_paths: List[str],
output_path: str, output_path: str,
target_size: Tuple[int, int] = (1080, 1920) target_size: Tuple[int, int] = (1080, 1920),
fades: Optional[List[Dict[str, float]]] = None
) -> str: ) -> str:
""" """
拼接视频并保留音频轨道 拼接视频并保留音频轨道
@@ -250,10 +314,70 @@ def concat_videos_with_audio(
# 视频处理 # 视频处理
for i in range(n): for i in range(n):
filter_parts.append( chain = (
f"[{i}:v]scale={width}:{height}:force_original_aspect_ratio=decrease," f"[{i}:v]scale={width}:{height}:force_original_aspect_ratio=decrease,"
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[v{i}]" f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1"
) )
# 可选:片段末尾“火山式转场”(不改时长、不重叠)
if fades and i < len(fades):
fx = fades[i] or {}
fi = float(fx.get("in", 0) or 0.0)
fo = float(fx.get("out", 0) or 0.0)
t_type = str(fx.get("type") or "")
t_dur = float(fx.get("dur") or 0.0)
try:
dur = float(get_video_info(video_paths[i]).get("duration") or 0.0)
except Exception:
dur = 0.0
if fi > 0:
chain += f",fade=t=in:st=0:d={fi}"
if fo > 0 and dur > 0:
st = max(dur - fo, 0.0)
chain += f",fade=t=out:st={st}:d={fo}"
if t_type and t_dur > 0 and dur > 0:
st = max(dur - t_dur, 0.0)
td = max(t_dur, 0.001)
p = f"if(between(t\\,{st}\\,{dur})\\,(t-{st})/{td}\\,0)"
if t_type == "fade":
chain += f",fade=t=out:st={st}:d={t_dur}"
elif t_type == "fadeWhite":
chain += f",fade=t=out:st={st}:d={t_dur}:color=white"
elif t_type == "blurOut":
chain += f",gblur=sigma='10*{p}':steps=1"
elif t_type == "blurFade":
chain += f",gblur=sigma='8*{p}':steps=1,fade=t=out:st={st}:d={t_dur}"
elif t_type == "flash":
chain += f",eq=brightness='0.7*(1-abs(0.5-{p})*2)'"
elif t_type == "desaturate":
chain += f",hue=s='1-0.9*{p}'"
elif t_type == "colorPop":
chain += f",hue=s='1+0.8*{p}',eq=contrast='1+0.3*{p}'"
elif t_type == "hueShift":
chain += f",hue=h='60*{p}'"
elif t_type == "darken":
chain += f",eq=brightness='-0.4*{p}'"
elif t_type in ("slideLeft", "slideRight", "slideUp", "slideDown"):
off = 80
if t_type == "slideLeft":
chain += f",pad={width+off}:{height}:{off/2}-{off}*{p}:0:black,crop={width}:{height}:{off/2}:0"
if t_type == "slideRight":
chain += f",pad={width+off}:{height}:{off/2}+{off}*{p}:0:black,crop={width}:{height}:{off/2}:0"
if t_type == "slideUp":
chain += f",pad={width}:{height+off}:0:{off/2}-{off}*{p}:black,crop={width}:{height}:0:{off/2}"
if t_type == "slideDown":
chain += f",pad={width}:{height+off}:0:{off/2}+{off}*{p}:black,crop={width}:{height}:0:{off/2}"
elif t_type in ("zoomOut", "zoomIn"):
if t_type == "zoomOut":
chain += f",scale=w='{width}*(1-0.10*{p})':h='{height}*(1-0.10*{p})':eval=frame,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black"
else:
chain += f",scale=w='{width}*(1+0.10*{p})':h='{height}*(1+0.10*{p})':eval=frame,crop={width}:{height}"
elif t_type == "rotateOut":
chain += f",rotate=a='0.12*{p}':c=black@1:ow={width}:oh={height}"
chain += f"[v{i}]"
filter_parts.append(chain)
# 音频处理(静音填充如果没有音频) # 音频处理(静音填充如果没有音频)
for i in range(n): for i in range(n):
@@ -469,6 +593,127 @@ def adjust_audio_duration(
return output_path return output_path
def _atempo_chain(speed: float) -> str:
"""
构造 atempo 链,支持 <0.5 或 >2.0 的倍速(通过链式 atempo
"""
try:
s = float(speed)
except Exception:
s = 1.0
if s <= 0:
s = 1.0
parts = []
# atempo 支持 0.5~2.0
while s > 2.0:
parts.append("atempo=2.0")
s /= 2.0
while s < 0.5:
parts.append("atempo=0.5")
s /= 0.5
parts.append(f"atempo={s}")
return ",".join(parts)
def change_audio_speed(input_path: str, speed: float, output_path: str) -> str:
"""改变音频播放倍速(纯播放倍速)。"""
if not os.path.exists(input_path):
return None
af = _atempo_chain(speed)
cmd = [FFMPEG_PATH, "-y", "-i", input_path, "-filter:a", af, output_path]
_run_ffmpeg(cmd)
return output_path
def fit_audio_to_duration_by_speed(input_path: str, target_duration: float, output_path: str) -> str:
"""
通过“改变播放倍速”来贴合目标时长(可快可慢),并裁剪/补齐到严格时长。
适用于旁白:用户拉伸片段期望语速变化,而不是静音补齐。
"""
if not os.path.exists(input_path):
return None
try:
td = float(target_duration or 0)
except Exception:
td = 0.0
if td <= 0:
import shutil
shutil.copy(input_path, output_path)
return output_path
cur = float(get_audio_info(input_path).get("duration") or 0.0)
if cur <= 0:
import shutil
shutil.copy(input_path, output_path)
return output_path
speed = cur / td
af_speed = _atempo_chain(speed)
# 贴合后仍做一次 atrim+apad 保证严格时长(避免累计误差)
af = f"{af_speed},atrim=0:{td},apad=pad_dur=0,atrim=0:{td}"
cmd = [FFMPEG_PATH, "-y", "-i", input_path, "-filter:a", af, output_path]
_run_ffmpeg(cmd)
return output_path
def force_audio_duration(input_path: str, target_duration: float, output_path: str) -> str:
"""不改变倍速,仅裁剪/补齐到严格时长(用于倍速已在上游完成的场景)。"""
if not os.path.exists(input_path):
return None
try:
td = float(target_duration or 0)
except Exception:
td = 0.0
if td <= 0:
import shutil
shutil.copy(input_path, output_path)
return output_path
af = f"atrim=0:{td},apad=pad_dur=0,atrim=0:{td}"
cmd = [FFMPEG_PATH, "-y", "-i", input_path, "-filter:a", af, output_path]
_run_ffmpeg(cmd)
return output_path
def _which(cmd: str) -> Optional[str]:
try:
import shutil
return shutil.which(cmd)
except Exception:
return None
def normalize_sticker_to_png(input_path: str, output_path: str) -> str:
"""
将贴纸规范化为 PNG用于 ffmpeg overlay
- PNG/WEBP直接返回原图或拷贝
- SVG优先用 rsvg-convert 转 PNG否则尝试 ffmpeg 直接解码
"""
if not input_path or not os.path.exists(input_path):
return None
ext = Path(input_path).suffix.lower()
if ext in [".png"]:
return input_path
if ext in [".webp"]:
# 转 PNG避免某些 ffmpeg build 对 webp 支持不一致
cmd = [FFMPEG_PATH, "-y", "-i", input_path, output_path]
_run_ffmpeg(cmd)
return output_path
if ext == ".svg":
rsvg = _which("rsvg-convert")
if rsvg:
import subprocess
subprocess.check_call([rsvg, "-o", output_path, input_path])
return output_path
# fallback: ffmpeg decode svg依赖 build
cmd = [FFMPEG_PATH, "-y", "-i", input_path, output_path]
_run_ffmpeg(cmd)
return output_path
# 其他格式:尽量用 ffmpeg 转
cmd = [FFMPEG_PATH, "-y", "-i", input_path, output_path]
_run_ffmpeg(cmd)
return output_path
def get_audio_info(file_path: str) -> Dict[str, Any]: def get_audio_info(file_path: str) -> Dict[str, Any]:
"""获取音频信息""" """获取音频信息"""
return get_video_info(file_path) return get_video_info(file_path)
@@ -830,6 +1075,12 @@ def add_bgm(
loop: bool = True, loop: bool = True,
ducking: bool = True, ducking: bool = True,
duck_gain_db: float = -6.0, duck_gain_db: float = -6.0,
# 新增:按时间段闪避(更可控,和旁白时间轴严格对齐)
duck_volume: float = 0.25,
duck_ranges: Optional[List[Tuple[float, float]]] = None,
# 新增BGM 片段可有起点/时长(不强制从 0 覆盖整段视频)
start_time: float = 0.0,
clip_duration: Optional[float] = None,
fade_in: float = 1.0, fade_in: float = 1.0,
fade_out: float = 1.0 fade_out: float = 1.0
) -> str: ) -> str:
@@ -856,30 +1107,46 @@ def add_bgm(
info = get_video_info(video_path) info = get_video_info(video_path)
video_duration = info["duration"] video_duration = info["duration"]
if loop: # 片段时长:默认覆盖整段视频
bgm_chain = ( dur = float(clip_duration) if (clip_duration is not None and float(clip_duration) > 0) else float(video_duration)
f"[1:a]aloop=-1:size=2e+09,asetpts=N/SR/TB," st = max(0.0, float(start_time or 0.0))
f"atrim=0:{video_duration}," end_for_fade = max(dur - float(fade_out or 0.0), 0.0)
f"afade=t=in:st=0:d={fade_in},"
f"afade=t=out:st={max(video_duration - fade_out, 0)}:d={fade_out},"
f"volume={bgm_volume}[bgm]"
)
else:
bgm_chain = (
f"[1:a]"
f"afade=t=in:st=0:d={fade_in},"
f"afade=t=out:st={max(video_duration - fade_out, 0)}:d={fade_out},"
f"volume={bgm_volume}[bgm]"
)
if ducking: # 基础链loop/trim -> fades -> base volume
# 使用安全参数的 sidechaincompress避免 unsupported 参数 if loop:
bgm_chain = f"[1:a]aloop=-1:size=2e+09,asetpts=N/SR/TB,atrim=0:{dur}"
else:
bgm_chain = f"[1:a]atrim=0:{dur}"
bgm_chain += f",afade=t=in:st=0:d={float(fade_in or 0.0)},afade=t=out:st={end_for_fade}:d={float(fade_out or 0.0)},volume={bgm_volume}"
# 延迟到 start_time
if st > 1e-6:
ms = int(st * 1000)
bgm_chain += f",adelay={ms}|{ms}"
# 闪避(按时间段)
# 注意:使用 enable 让 filter 只在区间内生效(外部直接 passthrough
if ducking and duck_ranges:
dv = max(0.05, min(1.0, float(duck_volume or 0.25)))
for (rs, re) in duck_ranges:
rsf = max(0.0, float(rs))
ref = max(rsf, float(re))
bgm_chain += f",volume={dv}:enable='between(t,{rsf},{ref})'"
bgm_chain += "[bgm]"
# 如果提供了 duck_ranges就用确定性的 amixducking 已在 bgm_chain 内完成)
if ducking and duck_ranges:
filter_complex = f"{bgm_chain};[0:a][bgm]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[outa]"
elif ducking:
# 否则退回 sidechaincompress对原视频音频进行侧链压缩
filter_complex = ( filter_complex = (
f"{bgm_chain};" f"{bgm_chain};"
f"[0:a][bgm]sidechaincompress=threshold=0.1:ratio=4:attack=5:release=250:makeup=1:mix=1:level_in=1:level_sc=1[outa]" f"[0:a][bgm]sidechaincompress=threshold=0.1:ratio=4:attack=5:release=250:makeup=1:mix=1:level_in=1:level_sc=1[outa]"
) )
else: else:
filter_complex = f"{bgm_chain};[0:a][bgm]amix=inputs=2:duration=first[outa]" filter_complex = f"{bgm_chain};[0:a][bgm]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[outa]"
cmd = [ cmd = [
FFMPEG_PATH, "-y", FFMPEG_PATH, "-y",

View File

@@ -246,3 +246,9 @@ def normalize_legacy_project(doc: Dict[str, Any]) -> Dict[str, Any]:

View File

@@ -19,6 +19,7 @@ from typing import Optional, Tuple
LEGACY_HOST_TEMP_PREFIX = "/root/video-flow/temp/" LEGACY_HOST_TEMP_PREFIX = "/root/video-flow/temp/"
LEGACY_HOST_OUTPUT_PREFIX = "/root/video-flow/output/" LEGACY_HOST_OUTPUT_PREFIX = "/root/video-flow/output/"
LEGACY_HOST_PREFIX = "/root/video-flow/"
# Container mount points (see docker-compose.yml) # Container mount points (see docker-compose.yml)
LEGACY_CONTAINER_TEMP_DIR = "/legacy/temp" LEGACY_CONTAINER_TEMP_DIR = "/legacy/temp"
@@ -42,18 +43,27 @@ def map_legacy_local_path(local_path: Optional[str]) -> Tuple[Optional[str], Opt
if os.path.exists(local_path): if os.path.exists(local_path):
return local_path, None return local_path, None
# Legacy host -> container mapping by basename # Legacy host path -> current container workspace path (same repo but different prefix)
# Example:
# /root/video-flow/temp/projects/... -> /app/temp/projects/...
# This covers cases where we don't mount /legacy/* but the files were copied into current stack.
if local_path.startswith(LEGACY_HOST_PREFIX):
rest = local_path[len(LEGACY_HOST_PREFIX):].lstrip("/")
candidate = str(Path("/app") / rest)
if os.path.exists(candidate):
return candidate, None
# Legacy host -> container mapping (preserve relative path)
if local_path.startswith(LEGACY_HOST_TEMP_PREFIX): if local_path.startswith(LEGACY_HOST_TEMP_PREFIX):
name = Path(local_path).name rel = local_path[len(LEGACY_HOST_TEMP_PREFIX):].lstrip("/")
container_path = str(Path(LEGACY_CONTAINER_TEMP_DIR) / name) container_path = str(Path(LEGACY_CONTAINER_TEMP_DIR) / rel)
url = f"{LEGACY_STATIC_TEMP_PREFIX}{name}" # 静态路由通常只覆盖目录根(不包含子目录);这里交给 /api/assets/file 做 FileResponse 更稳
return container_path, url return container_path, None
if local_path.startswith(LEGACY_HOST_OUTPUT_PREFIX): if local_path.startswith(LEGACY_HOST_OUTPUT_PREFIX):
name = Path(local_path).name rel = local_path[len(LEGACY_HOST_OUTPUT_PREFIX):].lstrip("/")
container_path = str(Path(LEGACY_CONTAINER_OUTPUT_DIR) / name) container_path = str(Path(LEGACY_CONTAINER_OUTPUT_DIR) / rel)
url = f"{LEGACY_STATIC_OUTPUT_PREFIX}{name}" return container_path, None
return container_path, url
# Unknown path: keep as-is # Unknown path: keep as-is
return local_path, None return local_path, None
@@ -64,3 +74,9 @@ def map_legacy_local_path(local_path: Optional[str]) -> Tuple[Optional[str], Opt

102
modules/preview_proxy.py Normal file
View 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

View File

@@ -6,6 +6,7 @@ import base64
import json import json
import logging import logging
import os import os
import time
import requests import requests
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from pathlib import Path from pathlib import Path
@@ -27,7 +28,10 @@ class ScriptGenerator:
# OpenAI-compatible client for ShuBiaoBiao (supports multiple models incl. GPT) # OpenAI-compatible client for ShuBiaoBiao (supports multiple models incl. GPT)
self.shubiaobiao_client = OpenAI( self.shubiaobiao_client = OpenAI(
api_key=config.SHUBIAOBIAO_KEY, api_key=config.SHUBIAOBIAO_KEY,
base_url=config.SHUBIAOBIAO_BASE_URL base_url=config.SHUBIAOBIAO_BASE_URL,
# IMPORTANT: OpenAI SDK default timeout is 10 minutes; cap it to keep UX responsive.
timeout=config.SHUBIAOBIAO_CHAT_TIMEOUT_S,
max_retries=config.SHUBIAOBIAO_CHAT_MAX_RETRIES,
) )
# Default System Prompt # Default System Prompt
@@ -139,15 +143,23 @@ class ScriptGenerator:
product_name: str, product_name: str,
product_info: Dict[str, Any], product_info: Dict[str, Any],
image_paths: List[str] = None, image_paths: List[str] = None,
model_provider: str = "shubiaobiao" # "shubiaobiao" or "doubao" model_provider: str = "shubiaobiao", # "shubiaobiao" or "doubao"
user_id: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
生成分镜脚本 生成分镜脚本
""" """
logger.info(f"Generating script for: {product_name} (Provider: {model_provider})") logger.info(f"Generating script for: {product_name} (Provider: {model_provider})")
# 1. 构造 Prompt (优先从数据库读取配置) # 1. 构造 Prompt (优先按 user_id 读取;否则回退到全局配置,再回退默认)
system_prompt = db.get_config("prompt_script_gen", self.default_system_prompt) system_prompt = None
if user_id:
try:
system_prompt = db.get_user_prompt(user_id, "prompt_script_gen")
except Exception:
system_prompt = None
if not system_prompt:
system_prompt = db.get_config("prompt_script_gen", self.default_system_prompt)
user_prompt = self._build_user_prompt(product_name, product_info) user_prompt = self._build_user_prompt(product_name, product_info)
# Branch for Doubao (Volcengine) # Branch for Doubao (Volcengine)
@@ -293,21 +305,40 @@ class ScriptGenerator:
ShuBiaoBiao OpenAI-compatible multimodal chat. ShuBiaoBiao OpenAI-compatible multimodal chat.
IMPORTANT: For ShuBiaoBiao models, we pass image URLs (R2 public URLs), not base64. IMPORTANT: For ShuBiaoBiao models, we pass image URLs (R2 public URLs), not base64.
""" """
t0 = time.time()
# Use WARNING level so it shows up even if Streamlit/root logger is not configured to INFO.
logger.warning(
f"[script_gen] start shubiaobiao chat model={model_name} images={len(image_paths or [])} "
f"timeout_s={getattr(config, 'SHUBIAOBIAO_CHAT_TIMEOUT_S', 'n/a')} "
f"max_retries={getattr(config, 'SHUBIAOBIAO_CHAT_MAX_RETRIES', 'n/a')}"
)
messages = [{"role": "system", "content": system_prompt}] messages = [{"role": "system", "content": system_prompt}]
user_content: List[Dict[str, Any]] = [] user_content: List[Dict[str, Any]] = []
# Images first (URL), then text # Images first (URL), then text
t_upload0 = time.time()
urls = self._upload_images_to_r2(image_paths or [], limit=10) urls = self._upload_images_to_r2(image_paths or [], limit=10)
logger.warning(
f"[script_gen] r2_upload done urls={len(urls)} elapsed_s={time.time() - t_upload0:.2f}"
)
for url in urls: for url in urls:
user_content.append({"type": "image_url", "image_url": {"url": url}}) user_content.append({"type": "image_url", "image_url": {"url": url}})
user_content.append({"type": "text", "text": user_prompt}) user_content.append({"type": "text", "text": user_prompt})
messages.append({"role": "user", "content": user_content}) messages.append({"role": "user", "content": user_content})
try: try:
resp = self.shubiaobiao_client.chat.completions.create( client = self.shubiaobiao_client.with_options(
timeout=config.SHUBIAOBIAO_CHAT_TIMEOUT_S,
max_retries=config.SHUBIAOBIAO_CHAT_MAX_RETRIES,
)
t_call0 = time.time()
resp = client.chat.completions.create(
model=model_name, model=model_name,
messages=messages, messages=messages,
temperature=0.7, temperature=0.7,
) )
logger.warning(
f"[script_gen] shubiaobiao chat done elapsed_s={time.time() - t_call0:.2f} total_s={time.time() - t0:.2f}"
)
content_text = (resp.choices[0].message.content or "").strip() content_text = (resp.choices[0].message.content or "").strip()
script_json = self._extract_json_from_response(content_text) script_json = self._extract_json_from_response(content_text)
if script_json is None: if script_json is None:
@@ -323,7 +354,9 @@ class ScriptGenerator:
} }
return final_script return final_script
except Exception as e: except Exception as e:
logger.error(f"shubiaobiao script generation failed ({model_name}): {e}") logger.error(
f"shubiaobiao script generation failed ({model_name}) after {time.time() - t0:.2f}s: {e}"
)
return None return None
def _postprocess_selling_points(self, product_info: Dict[str, Any], selling_points: Any) -> List[str]: def _postprocess_selling_points(self, product_info: Dict[str, Any], selling_points: Any) -> List[str]:
@@ -582,7 +615,33 @@ class ScriptGenerator:
def _validate_and_fix_script(self, script: Dict[str, Any]) -> Dict[str, Any]: def _validate_and_fix_script(self, script: Dict[str, Any]) -> Dict[str, Any]:
"""校验并修复脚本结构""" """校验并修复脚本结构"""
# 简单校验,确保必要字段存在 if not isinstance(script, dict):
if "scenes" not in script: return {"scenes": []}
# Ensure fields exist
if "scenes" not in script or not isinstance(script.get("scenes"), list):
script["scenes"] = [] script["scenes"] = []
# Normalize: keep visual_anchor at top-level, but avoid repeating the full anchor in every scene.visual_prompt.
# Reason: repeating a long anchor 4-5 times explodes tokens and makes UI look like "only three sections",
# while image generation already supports passing visual_anchor separately and prepending it at runtime.
visual_anchor = script.get("visual_anchor") or ""
if isinstance(visual_anchor, str) and visual_anchor.strip() and script["scenes"]:
anchor = visual_anchor.strip()
prefix = f"[{anchor}]"
for scene in script["scenes"]:
if not isinstance(scene, dict):
continue
vp = scene.get("visual_prompt")
if not isinstance(vp, str) or not vp.strip():
continue
s = vp.strip()
# Strip exact "[anchor]" prefix if present
if s.startswith(prefix):
s = s[len(prefix):].lstrip()
# If the model output copied the raw anchor without brackets, strip it too
elif s.startswith(anchor):
s = s[len(anchor):].lstrip()
scene["visual_prompt"] = s
return script return script

View File

@@ -90,6 +90,79 @@ class TextRenderer:
return color return color
return (0, 0, 0, 255) return (0, 0, 0, 255)
def _wrap_text_to_width(self, text: str, font: ImageFont.FreeTypeFont, max_width: int) -> str:
"""
将文本按最大宽度自动换行(支持中英文混排)。
- 保留原始换行符为段落边界
- 英文优先按空格断词;中文按字符贪心换行
"""
try:
mw = int(max_width or 0)
except Exception:
mw = 0
if mw <= 0:
return text
# 兼容:去掉末尾多余空行
raw_paras = (text or "").split("\n")
out_lines: List[str] = []
# 1x1 dummy draw 用于测量
dummy_draw = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
def text_w(s: str) -> float:
try:
return float(dummy_draw.textlength(s, font=font))
except Exception:
bbox = dummy_draw.textbbox((0, 0), s, font=font)
return float((bbox[2] - bbox[0]) if bbox else 0)
for para in raw_paras:
p = (para or "").rstrip()
if not p:
out_lines.append("")
continue
# 英文/混排:尝试按空格分词,否则按字符
use_words = (" " in p)
tokens = p.split(" ") if use_words else list(p)
cur = ""
for tok in tokens:
cand = (cur + (" " if (use_words and cur) else "") + tok) if cur else tok
if text_w(cand) <= mw:
cur = cand
continue
# 当前行放不下:先落一行
if cur:
out_lines.append(cur)
cur = tok
else:
# 单 token 超宽:强制按字符拆
if use_words:
chars = list(tok)
else:
chars = [tok]
buf = ""
for ch in chars:
cand2 = buf + ch
if text_w(cand2) <= mw or not buf:
buf = cand2
else:
out_lines.append(buf)
buf = ch
cur = buf
if cur:
out_lines.append(cur)
# 去掉尾部空行(保持中间空行)
while out_lines and out_lines[-1] == "":
out_lines.pop()
return "\n".join(out_lines)
def render(self, text: str, style: Union[Dict[str, Any], str], cache: bool = True) -> str: def render(self, text: str, style: Union[Dict[str, Any], str], cache: bool = True) -> str:
""" """
渲染文本并返回图片路径 渲染文本并返回图片路径
@@ -122,14 +195,29 @@ class TextRenderer:
font_size = style.get("font_size", 60) font_size = style.get("font_size", 60)
font = self._get_font(font_path, font_size) font = self._get_font(font_path, font_size)
font_color = self._parse_color(style.get("font_color", "#FFFFFF")) font_color = self._parse_color(style.get("font_color", "#FFFFFF"))
bold = bool(style.get("bold", False))
italic = bool(style.get("italic", False))
underline = bool(style.get("underline", False))
# 3. 测量文本尺寸 # 3. 自动换行(可选)
max_width = style.get("max_width") or style.get("maxWidth") or style.get("text_box_width")
try:
max_width = int(max_width) if max_width is not None else 0
except Exception:
max_width = 0
if max_width > 0:
text = self._wrap_text_to_width(text, font, max_width)
# 4. 测量文本尺寸(支持多行)
dummy_draw = ImageDraw.Draw(Image.new("RGBA", (1, 1))) dummy_draw = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
bbox = dummy_draw.textbbox((0, 0), text, font=font) try:
text_w = bbox[2] - bbox[0] bbox = dummy_draw.multiline_textbbox((0, 0), text, font=font, spacing=int(font_size * 0.25), align="center")
text_h = bbox[3] - bbox[1] except Exception:
bbox = dummy_draw.textbbox((0, 0), text, font=font)
text_w = (bbox[2] - bbox[0]) if bbox else 0
text_h = (bbox[3] - bbox[1]) if bbox else 0
# 4. 计算总尺寸 (包含 padding, stroke, shadow) # 5. 计算总尺寸 (包含 padding, stroke, shadow)
strokes = style.get("stroke", []) strokes = style.get("stroke", [])
if isinstance(strokes, dict): strokes = [strokes] # 兼容旧格式 if isinstance(strokes, dict): strokes = [strokes] # 兼容旧格式
@@ -156,7 +244,7 @@ class TextRenderer:
canvas_w = content_w + extra_margin * 2 canvas_w = content_w + extra_margin * 2
canvas_h = content_h + extra_margin * 2 canvas_h = content_h + extra_margin * 2
# 5. 创建画布 # 6. 创建画布
img = Image.new("RGBA", (int(canvas_w), int(canvas_h)), (0, 0, 0, 0)) img = Image.new("RGBA", (int(canvas_w), int(canvas_h)), (0, 0, 0, 0))
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
@@ -164,7 +252,7 @@ class TextRenderer:
center_x = canvas_w // 2 center_x = canvas_w // 2
center_y = canvas_h // 2 center_y = canvas_h // 2
# 6. 绘制顺序: 阴影 -> 背景 -> 描边 -> 文本 # 7. 绘制顺序: 阴影 -> 背景 -> 描边 -> 文本
# --- 绘制阴影 (针对整个块) --- # --- 绘制阴影 (针对整个块) ---
if shadow: if shadow:
@@ -183,7 +271,11 @@ class TextRenderer:
# 文字阴影 # 文字阴影
txt_x = center_x - text_w / 2 txt_x = center_x - text_w / 2
txt_y = center_y - text_h / 2 txt_y = center_y - text_h / 2
shadow_draw.text((txt_x, txt_y), text, font=font, fill=shadow_color) # 多行阴影
try:
shadow_draw.multiline_text((txt_x, txt_y), text, font=font, fill=shadow_color, spacing=int(font_size * 0.25), align="center")
except Exception:
shadow_draw.text((txt_x, txt_y), text, font=font, fill=shadow_color)
# 描边阴影 # 描边阴影
for s in strokes: for s in strokes:
width = s.get("width", 0) width = s.get("width", 0)
@@ -217,10 +309,55 @@ class TextRenderer:
width = s.get("width", 0) width = s.get("width", 0)
if width > 0: if width > 0:
# 通过偏移模拟描边 (Pillow stroke_width 效果一般,但这里先用原生参数) # 通过偏移模拟描边 (Pillow stroke_width 效果一般,但这里先用原生参数)
draw.text((txt_x, txt_y), text, font=font, fill=color, stroke_width=width, stroke_fill=color) try:
draw.multiline_text((txt_x, txt_y), text, font=font, fill=color, spacing=int(font_size * 0.25), align="center", stroke_width=width, stroke_fill=color)
except Exception:
draw.text((txt_x, txt_y), text, font=font, fill=color, stroke_width=width, stroke_fill=color)
# --- 绘制文字 --- # --- 绘制文字 ---
draw.text((txt_x, txt_y), text, font=font, fill=font_color) # italic通过仿射变换做简单斜体先绘制到单独图层再 shear
# bold通过多次微小偏移叠加模拟加粗比改 stroke 更接近“字重”)
if italic:
text_layer = Image.new("RGBA", img.size, (0, 0, 0, 0))
text_draw = ImageDraw.Draw(text_layer)
if bold:
for dx in (0, 1):
try:
text_draw.multiline_text((txt_x + dx, txt_y), text, font=font, fill=font_color, spacing=int(font_size * 0.25), align="center")
except Exception:
text_draw.text((txt_x + dx, txt_y), text, font=font, fill=font_color)
else:
try:
text_draw.multiline_text((txt_x, txt_y), text, font=font, fill=font_color, spacing=int(font_size * 0.25), align="center")
except Exception:
text_draw.text((txt_x, txt_y), text, font=font, fill=font_color)
shear = 0.22 # 经验值:适中倾斜
text_layer = text_layer.transform(
text_layer.size,
Image.AFFINE,
(1, shear, 0, 0, 1, 0),
resample=Image.BICUBIC
)
img = Image.alpha_composite(img, text_layer)
draw = ImageDraw.Draw(img)
else:
if bold:
for dx in (0, 1):
try:
draw.multiline_text((txt_x + dx, txt_y), text, font=font, fill=font_color, spacing=int(font_size * 0.25), align="center")
except Exception:
draw.text((txt_x + dx, txt_y), text, font=font, fill=font_color)
else:
try:
draw.multiline_text((txt_x, txt_y), text, font=font, fill=font_color, spacing=int(font_size * 0.25), align="center")
except Exception:
draw.text((txt_x, txt_y), text, font=font, fill=font_color)
# underline在文本底部画线与字号相关
if underline:
line_y = txt_y + text_h + max(2, int(font_size * 0.08))
line_th = max(2, int(font_size * 0.06))
draw.rectangle([txt_x, line_y, txt_x + text_w, line_y + line_th], fill=font_color)
# 7. 裁剪多余透明区域 # 7. 裁剪多余透明区域
bbox = img.getbbox() bbox = img.getbbox()

View File

@@ -23,6 +23,7 @@ numpy>=1.24.0
# Web UI (Streamlit - 保留原有调试界面) # Web UI (Streamlit - 保留原有调试界面)
streamlit>=1.29.0 streamlit>=1.29.0
extra-streamlit-components>=0.1.71
# FastAPI Backend (新增前后端分离) # FastAPI Backend (新增前后端分离)
fastapi>=0.109.0 fastapi>=0.109.0

View 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
View 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)

View 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()

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,20 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

31
web/src/App.tsx Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
// Back-compat re-export
export { TrackRow } from './components/Timeline/TrackRow'
export { default } from './components/Timeline/TrackRow'

View File

@@ -0,0 +1,6 @@
// Back-compat re-export
export { VideoComposition } from './remotion/VideoComposition'
export { default } from './remotion/VideoComposition'

View 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

View 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

View 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

View 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

View 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">X0~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">Y0~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_familyrenderer 可解析,预览也能 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

View 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

View 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
View File

@@ -0,0 +1,5 @@
// Back-compat re-export
export * from './store/editorStore'

118
web/src/index.css Normal file
View 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
View 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/trackcamelCase转换为后端接口期望的 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
View 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
}

View 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 }
}

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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
View File

@@ -0,0 +1,19 @@
/**
* Pages 导出
*/
export { ProjectsPage } from './ProjectsPage'
export { EditorPage } from './EditorPage'

View 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)`)
// flashbrightness 峰值在 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
View File

@@ -0,0 +1,18 @@
/**
* Remotion 组件导出
*/
export { VideoComposition } from './VideoComposition'

View 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
View File

@@ -0,0 +1,5 @@
// Back-compat re-export
export * from './lib/templates'

55
web/tailwind.config.js Normal file
View 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
View 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
View 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
View 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,
},
},
},
})