diff --git a/Dockerfile b/Dockerfile index f3288e3..925f4e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libsm6 \ libxext6 \ libgl1 \ + librsvg2-bin \ fonts-noto-cjk \ fonts-wqy-zenhei \ curl \ diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/celery_app.py b/api/celery_app.py new file mode 100644 index 0000000..dfe5239 --- /dev/null +++ b/api/celery_app.py @@ -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() + + + + + + + + + + + + + + diff --git a/api/main.py b/api/main.py index 3b0beef..393a61c 100644 --- a/api/main.py +++ b/api/main.py @@ -68,6 +68,7 @@ app.add_middleware( app.mount("/static/output", StaticFiles(directory=str(config.OUTPUT_DIR)), name="output") app.mount("/static/temp", StaticFiles(directory=str(config.TEMP_DIR)), name="temp") app.mount("/static/assets", StaticFiles(directory=str(config.ASSETS_DIR)), name="assets") +app.mount("/static/fonts", StaticFiles(directory=str(config.FONTS_DIR)), name="fonts") # Legacy mounts(8502 runtime 产物,宿主机目录通过 docker-compose 挂载到容器内) try: diff --git a/api/routes/__init__.py b/api/routes/__init__.py new file mode 100644 index 0000000..3465a58 --- /dev/null +++ b/api/routes/__init__.py @@ -0,0 +1,15 @@ +# API Routes Package + + + + + + + + + + + + + + diff --git a/api/routes/assets.py b/api/routes/assets.py new file mode 100644 index 0000000..8915ca3 --- /dev/null +++ b/api/routes/assets.py @@ -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)) + + + + + + + + + + + + + + diff --git a/api/routes/compose.py b/api/routes/compose.py new file mode 100644 index 0000000..210a3ec --- /dev/null +++ b/api/routes/compose.py @@ -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 + diff --git a/api/routes/editor.py b/api/routes/editor.py new file mode 100644 index 0000000..525990d --- /dev/null +++ b/api/routes/editor.py @@ -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} + + + diff --git a/api/routes/projects.py b/api/routes/projects.py new file mode 100644 index 0000000..820243c --- /dev/null +++ b/api/routes/projects.py @@ -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="视频生成失败") + + + diff --git a/api/tasks/__init__.py b/api/tasks/__init__.py new file mode 100644 index 0000000..3e907f4 --- /dev/null +++ b/api/tasks/__init__.py @@ -0,0 +1,15 @@ +# Celery Tasks Package + + + + + + + + + + + + + + diff --git a/api/tasks/audio_tasks.py b/api/tasks/audio_tasks.py new file mode 100644 index 0000000..c5d84af --- /dev/null +++ b/api/tasks/audio_tasks.py @@ -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 + } + + + + + + + + + + + + + + diff --git a/api/tasks/video_tasks.py b/api/tasks/video_tasks.py new file mode 100644 index 0000000..06bac77 --- /dev/null +++ b/api/tasks/video_tasks.py @@ -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 + + + + + + + + + + + + + + diff --git a/app.py b/app.py index cdfe1f0..d6f1640 100644 --- a/app.py +++ b/app.py @@ -26,6 +26,7 @@ from modules import path_utils from modules import limits from modules.legacy_path_mapper import map_legacy_local_path from modules.legacy_normalizer import normalize_legacy_project +import extra_streamlit_components as stx # Page Config st.set_page_config( @@ -35,6 +36,84 @@ st.set_page_config( initial_sidebar_state="expanded" ) +# ============================================================ +# Auth (login + remember cookie) +# ============================================================ +COOKIE_NAME = "vf_session" +cookie_manager = stx.CookieManager(key="vf_cookie_mgr") + +def _current_user() -> dict: + u = st.session_state.get("current_user") + return u if isinstance(u, dict) else None + +def _set_current_user(u: dict): + st.session_state.current_user = u + +def _try_restore_login(): + if _current_user(): + return + try: + token = cookie_manager.get(COOKIE_NAME) + except Exception: + token = None + if token: + u = db.validate_session(token) + if u: + _set_current_user(u) + +def _logout(): + try: + token = cookie_manager.get(COOKIE_NAME) + except Exception: + token = None + if token: + db.revoke_session(token) + try: + cookie_manager.delete(COOKIE_NAME) + except Exception: + pass + st.session_state.pop("current_user", None) + st.rerun() + +def _login_gate(): + _try_restore_login() + u = _current_user() + if u: + return + st.title("登录") + # Login page runs before the global CSS block below; inject minimal CSS here. + st.markdown( + """ + +""", + unsafe_allow_html=True, + ) + c1, c2 = st.columns([1, 2]) + with c1: + username = st.text_input("用户名", value="", key="login_user") + password = st.text_input("密码", value="", type="password", key="login_pass") + if st.button("登录", type="primary"): + au = db.authenticate_user(username.strip(), password) + if not au: + st.error("用户名或密码错误,或账号已禁用") + st.stop() + token = db.create_session(au["id"]) + try: + cookie_manager.set(COOKIE_NAME, token, max_age=7 * 24 * 3600) + except Exception: + # fallback: session-only + pass + _set_current_user(au) + st.rerun() + st.stop() + +_login_gate() + # ============================================================ # BGM 智能匹配函数 # ============================================================ @@ -109,6 +188,12 @@ st.markdown(""" .stTextInput input, .stTextArea textarea { border-radius: 4px; } + + /* Hide Streamlit password reveal (eye) buttons to avoid accidental exposure */ + button[aria-label="Show password text"], + button[aria-label="Hide password text"] { + display: none !important; + } """, unsafe_allow_html=True) @@ -150,9 +235,9 @@ def _ui_key(suffix: str) -> str: def load_project(project_id): """Load project state from DB""" - data = db.get_project(project_id) + data = db.get_project_for_user(project_id, _current_user()) if not data: - st.error("Project not found") + st.error("Project not found / no permission") return st.session_state.project_id = project_id @@ -203,6 +288,7 @@ def load_project(project_id): st.session_state.uploaded_images = [] # Restore assets + # RBAC: assets are project-scoped, so permission already checked above. assets = db.get_assets(project_id) images = {} videos = {} @@ -238,11 +324,107 @@ def load_project(project_id): else: st.session_state.current_step = 0 +# ============================================================ +# Helper Functions (must be defined before Sidebar uses them) +# ============================================================ +def _record_metrics(project_id: str, patch: dict): + """Persist lightweight timing/diagnostic metrics into project.product_info['_metrics'].""" + if not project_id or not isinstance(patch, dict) or not patch: + return + try: + proj = db.get_project(project_id) or {} + product_info = proj.get("product_info") or {} + metrics = product_info.get("_metrics") if isinstance(product_info.get("_metrics"), dict) else {} + metrics.update(patch) + metrics["updated_at"] = time.time() + product_info["_metrics"] = metrics + db.update_project_product_info(project_id, product_info) + except Exception: + # metrics must never break UX + pass + + +def _get_metrics(project_id: str) -> dict: + try: + proj = db.get_project(project_id) or {} + product_info = proj.get("product_info") or {} + m = product_info.get("_metrics") + return m if isinstance(m, dict) else {} + except Exception: + return {} + + +def _ensure_local_videos(project_id: str, scenes: list): + """ + Step 5 合成前的保障逻辑: + 检查分镜视频是否已下载到服务器本地。如果只有 URL 没有本地文件,则后台静默下载。 + """ + if not project_id or not scenes: + return + + vid_gen = VideoGenerator() + downloaded = 0 + missing_assets = [] + + for scene in scenes: + scene_id = scene["id"] + local_path = st.session_state.scene_videos.get(scene_id) + + # 如果本地文件不存在,尝试补全 + if not local_path or not os.path.exists(local_path): + asset = db.get_asset(project_id, scene_id, "video") + if not asset: + missing_assets.append(f"Scene {scene_id}") + continue + + meta = asset.get("metadata") or {} + video_url = meta.get("video_url") + task_id = asset.get("task_id") + + # 如果 DB 里没有 URL,但有 task_id,尝试现场查询一次 + if not video_url and task_id: + logger.info(f"Checking task {task_id} status for Scene {scene_id} during composition...") + status, url = vid_gen.check_task_status(task_id) + if status == "succeeded" and url: + video_url = url + db.update_asset_metadata(project_id, scene_id, "video", {"video_url": url, "volc_status": status}) + + if video_url: + out_name = path_utils.unique_filename( + prefix="scene_video", + ext="mp4", + project_id=project_id, + scene_id=scene_id, + extra=f"auto_{int(time.time())}" + ) + logger.info(f"Auto-downloading missing video for Scene {scene_id} from {video_url}") + target_dir = path_utils.project_videos_dir(project_id) + target_path = str(target_dir / out_name) + if vid_gen._download_video_to(video_url, target_path): + st.session_state.scene_videos[scene_id] = target_path + db.save_asset(project_id, scene_id, "video", "completed", local_path=target_path) + downloaded += 1 + else: + missing_assets.append(f"Scene {scene_id} (未生成/无URL)") + + if downloaded > 0: + logger.info(f"Successfully auto-downloaded {downloaded} missing videos for project {project_id}") + + if missing_assets: + msg = f"无法合成:以下分镜缺少视频素材,请先在第 4 步生成:{', '.join(missing_assets)}" + logger.warning(msg) + raise ValueError(msg) + # ============================================================ # Sidebar # ============================================================ with st.sidebar: st.title("📽️ Video Flow") + cu = _current_user() + if cu: + st.caption(f"登录用户: {cu.get('username')} ({cu.get('role')})") + if st.button("退出登录", type="secondary"): + _logout() # Mode Selection - 正确计算 index mode_options = ["🛠️ 工作台", "📜 历史任务", "⚙️ 设置"] @@ -262,7 +444,7 @@ with st.sidebar: if st.session_state.view_mode == "workspace": # Project Selection st.subheader("Current Project") - projects = db.list_projects() + projects = db.list_projects_for_user(_current_user()) proj_options = {p['id']: f"{p.get('name', 'Untitled')} ({p['id']})" for p in projects} selected_proj_id = st.selectbox( @@ -307,13 +489,6 @@ with st.sidebar: for k in keys: if k in m: st.caption(f"{k}: {m.get(k)}") - # 在线剪辑入口(React Editor) - web_base_url = os.getenv("WEB_BASE_URL", "http://localhost:3000").rstrip("/") - st.markdown( - f"[打开在线剪辑器]({web_base_url}/editor/{st.session_state.project_id})", - unsafe_allow_html=False, - ) - st.markdown("---") # Navigation / Progress @@ -335,35 +510,6 @@ with st.sidebar: if key != "view_mode": del st.session_state[key] st.rerun() -# ============================================================ -# Helper Functions -# ============================================================ -def _record_metrics(project_id: str, patch: dict): - """Persist lightweight timing/diagnostic metrics into project.product_info['_metrics'].""" - if not project_id or not isinstance(patch, dict) or not patch: - return - try: - proj = db.get_project(project_id) or {} - product_info = proj.get("product_info") or {} - metrics = product_info.get("_metrics") if isinstance(product_info.get("_metrics"), dict) else {} - metrics.update(patch) - metrics["updated_at"] = time.time() - product_info["_metrics"] = metrics - db.update_project_product_info(project_id, product_info) - except Exception: - # metrics must never break UX - pass - - -def _get_metrics(project_id: str) -> dict: - try: - proj = db.get_project(project_id) or {} - product_info = proj.get("product_info") or {} - m = product_info.get("_metrics") - return m if isinstance(m, dict) else {} - except Exception: - return {} - def save_uploaded_file(project_id: str, uploaded_file): """Save uploaded file to per-project upload dir (avoid overwrites across projects).""" if uploaded_file is None: @@ -467,13 +613,24 @@ if st.session_state.view_mode == "workspace": # DB: Create Project # 将 uploaded_images 保存到 product_info 以便持久化 product_info = {"category": category, "price": price, "tags": tags, "params": params, "style_hint": style_hint, "uploaded_images": image_paths} - db.create_project(st.session_state.project_id, product_name, product_info) + db.create_project( + st.session_state.project_id, + product_name, + product_info, + owner_user_id=(_current_user() or {}).get("id"), + ) # Call Script Generator with st.spinner(f"正在分析商品信息并生成脚本 ({selected_model_label})..."): gen = ScriptGenerator() t0 = perf_counter() - script = gen.generate_script(product_name, product_info, image_paths, model_provider=model_provider) + script = gen.generate_script( + product_name, + product_info, + image_paths, + model_provider=model_provider, + user_id=(_current_user() or {}).get("id"), + ) _record_metrics(st.session_state.project_id, { "script_gen_s": round(perf_counter() - t0, 3), "script_model": model_provider, @@ -689,9 +846,9 @@ if st.session_state.view_mode == "workspace": if not ok: st.warning("系统正在生成其他任务(生图并发已达上限),请稍后再试。") st.stop() - img_gen = ImageGenerator() - # Pass ALL uploaded images as reference - base_imgs = st.session_state.uploaded_images if st.session_state.uploaded_images else [] + img_gen = ImageGenerator() + # Pass ALL uploaded images as reference + base_imgs = st.session_state.uploaded_images if st.session_state.uploaded_images else [] if not base_imgs: 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) progress_bar = st.progress(0) status_text = st.empty() - + try: t0 = perf_counter() # 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, scene=scene, original_image_path=list(base_imgs), # ONLY merchant images - previous_image_path=None, + previous_image_path=None, model_provider=img_provider, visual_anchor=visual_anchor, 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})") try: 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: - img_path = None st.warning(f"Scene {scene_id} 生成失败:{e}") - - if img_path: - st.session_state.scene_images[scene_id] = img_path - db.save_asset(st.session_state.project_id, scene_id, "image", "completed", local_path=img_path) - # Invalidate stale video for this scene (image changed => old video is wrong) - db.clear_asset(st.session_state.project_id, scene_id, "video", status="pending") - st.session_state.scene_videos.pop(scene_id, None) - + progress_bar.progress(done / total_scenes) status_text.text("生图完成!") @@ -863,112 +1018,152 @@ if st.session_state.view_mode == "workspace": scenes = st.session_state.script_data.get("scenes", []) vid_gen = VideoGenerator() - # Submit-only (non-blocking) to avoid freezing Streamlit under concurrency - if st.button("🎬 提交图生视频任务(非阻塞)", type="primary"): + st.caption("简化策略:第 4 步完成“生成→轮询→下载到服务器本地”。当至少有一个分镜视频落盘后,才允许进入第 5 步合成。") + + if st.button("🎬 生成分镜视频并下载到服务器(阻塞)", type="primary"): with limits.acquire_video(blocking=False) as ok: if not ok: st.warning("系统正在处理其他视频任务(并发已达上限),请稍后再试。") st.stop() - t0 = perf_counter() - submitted = 0 + + if not st.session_state.project_id: + st.error("缺少 project_id,无法生成视频。") + st.stop() + + if not scenes: + st.error("脚本中没有分镜,无法生成视频。") + st.stop() + + # 先把 DB 中已完成且存在的本地视频恢复到 session for scene in scenes: scene_id = scene["id"] + existing = st.session_state.scene_videos.get(scene_id) + if existing and os.path.exists(existing): + continue + asset = db.get_asset(st.session_state.project_id, scene_id, "video") + local_path = (asset or {}).get("local_path") + if local_path and os.path.exists(local_path): + st.session_state.scene_videos[scene_id] = local_path + + t0 = perf_counter() + total = len(scenes) + done = sum(1 for _sid, p in (st.session_state.scene_videos or {}).items() if p and os.path.exists(p)) + progress = st.progress(0.0) + status_text = st.empty() + + # 收集/提交任务 + tasks = {} # scene_id -> task_id + for scene in scenes: + scene_id = scene["id"] + existing = st.session_state.scene_videos.get(scene_id) + if existing and os.path.exists(existing): + continue + + asset = db.get_asset(st.session_state.project_id, scene_id, "video") + task_id = (asset or {}).get("task_id") + if task_id: + tasks[scene_id] = task_id + continue + image_path = st.session_state.scene_images.get(scene_id) prompt = scene.get("video_prompt", "High quality video") - task_id = vid_gen.submit_scene_video_task( + new_task_id = vid_gen.submit_scene_video_task( st.session_state.project_id, scene_id, image_path, prompt ) - if task_id: - submitted += 1 - _record_metrics(st.session_state.project_id, { - "video_submit_s": round(perf_counter() - t0, 3), - "video_submitted": submitted, - }) - if submitted: - db.update_project_status(st.session_state.project_id, "videos_processing") - st.success(f"已提交 {submitted} 个分镜视频任务。可点击下方“刷新恢复”下载结果。") - time.sleep(0.5) - st.rerun() - else: - st.warning("未提交任何任务(可能缺少图片或接口失败)。") + if new_task_id: + tasks[scene_id] = new_task_id + + if tasks: + db.update_project_status(st.session_state.project_id, "videos_processing") + + out_dir = path_utils.project_videos_dir(st.session_state.project_id) + pending = set(tasks.keys()) + deadline = time.time() + 15 * 60 # 15 min + + while pending and time.time() < deadline: + status_text.text(f"视频生成/下载中:已完成 {done}/{total},队列中 {len(pending)} ...") + to_remove = [] + + for scene_id in list(pending): + task_id = tasks.get(scene_id) + if not task_id: + to_remove.append(scene_id) + continue - if 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) if status == "succeeded" and url: - break - time.sleep(0.5 * (2 ** attempt)) + out_name = path_utils.unique_filename( + prefix="scene_video", + ext="mp4", + project_id=st.session_state.project_id, + scene_id=scene_id, + extra=(task_id[-8:] if isinstance(task_id, str) else None), + ) + target_path = str(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} - if url: - meta_patch["video_url"] = url - db.update_asset_metadata(st.session_state.project_id, scene_id, "video", meta_patch) - updated += 1 + for sid in to_remove: + pending.discard(sid) + + progress.progress(min(1.0, done / max(total, 1))) + if pending: + time.sleep(5) + + if pending: + st.warning(f"仍有 {len(pending)} 个分镜未在本轮完成:{sorted(list(pending))}。可再次点击按钮继续轮询与下载。") _record_metrics(st.session_state.project_id, { - "video_recover_s": round(perf_counter() - t0, 3), - "video_recovered": updated, + "video_blocking_total_s": round(perf_counter() - t0, 3), + "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"): - with limits.acquire_video(blocking=False) as ok: - if not ok: - st.warning("系统正在处理其他视频任务(并发已达上限),请稍后再试。") - st.stop() - downloaded = 0 - for scene in scenes: - scene_id = scene["id"] - existing = st.session_state.scene_videos.get(scene_id) - if existing and os.path.exists(existing): - continue - asset = db.get_asset(st.session_state.project_id, scene_id, "video") - meta = (asset or {}).get("metadata") or {} - video_url = meta.get("video_url") - if not video_url: - continue - out_name = path_utils.unique_filename( - prefix="scene_video", - ext="mp4", - project_id=st.session_state.project_id, - scene_id=scene_id, - ) - 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} 段视频,可进入合成。") + if any(p and os.path.exists(p) for p in (st.session_state.scene_videos or {}).values()): + db.update_project_status(st.session_state.project_id, "videos_generated") + st.success("已生成并下载到服务器本地。进入第 5 步合成。") + st.session_state.current_step = 4 + st.rerun() else: - st.info("暂无可下载的视频(请先刷新状态获取 video_url)。") - time.sleep(0.5) - st.rerun() + st.error("未生成任何可用视频,请检查生视频接口或稍后重试。") # Display Videos (even when partially available) if st.session_state.scene_videos or scenes: @@ -987,43 +1182,14 @@ if st.session_state.view_mode == "workspace": if vid_path and os.path.exists(vid_path): st.video(vid_path) else: - # Try URL preview from DB metadata asset = db.get_asset(st.session_state.project_id, scene_id, "video") - meta = (asset or {}).get("metadata") or {} - video_url = meta.get("video_url") - if video_url: - # Detect stale mapping: if source image signature differs, warn and avoid misleading preview - stale = False - try: - cur_img = st.session_state.scene_images.get(scene_id) - if cur_img and os.path.exists(cur_img): - st_img = stat(cur_img) - cur_size = int(getattr(st_img, "st_size", 0) or 0) - cur_mtime = float(getattr(st_img, "st_mtime", 0.0) or 0.0) - src_size = meta.get("source_image_size") - src_mtime = meta.get("source_image_mtime") - if (src_size and cur_size and int(src_size) != cur_size) or (src_mtime and cur_mtime and abs(float(src_mtime) - cur_mtime) > 1e-3): - stale = True - except Exception: - stale = False - if stale: - st.warning("检测到该视频可能基于旧图片生成(图片已更新)。请点击“提交图生视频任务”重新生成,以避免主体不一致。") - st.caption("URL 直连预览(不经服务器落盘)") - st.video(video_url) + status = (asset or {}).get("status") or "pending" + task_id = (asset or {}).get("task_id") + if task_id: + st.caption(f"状态: {status} | Task: {str(task_id)[-6:]}") else: - st.warning("Video missing") - # --- Recovery Logic --- - if asset and asset.get("task_id"): - task_id = asset.get("task_id") - if st.button(f"🔍 刷新URL (Task {task_id[-6:]})", key=f"recov_{scene_id}"): - with st.spinner("查询任务状态中..."): - status, url = vid_gen.check_task_status(task_id) - patch = {"checked_at": time.time(), "volc_status": status} - if url: - patch["video_url"] = url - db.update_asset_metadata(st.session_state.project_id, scene_id, "video", patch) - st.success("已刷新任务状态。") - st.rerun() + st.caption(f"状态: {status}") + st.warning("暂无本地视频(请点击上方“生成分镜视频并下载到服务器”)。") # Per-scene regenerate button if st.button(f"🔄 重生 S{scene_id}", key=f"regen_vid_{scene_id}"): @@ -1046,14 +1212,11 @@ if st.session_state.view_mode == "workspace": scene_id=scene_id, extra=(t_id[-8:] if isinstance(t_id, str) else None), ) - new_path = vid_gen._download_video( - url, - out_name, - output_dir=path_utils.project_videos_dir(st.session_state.project_id), - ) - if new_path: - st.session_state.scene_videos[scene_id] = new_path - db.save_asset(st.session_state.project_id, scene_id, "video", "completed", local_path=new_path, task_id=t_id) + target_dir = path_utils.project_videos_dir(st.session_state.project_id) + target_path = str(target_dir / out_name) + if vid_gen._download_video_to(url, target_path) and os.path.exists(target_path): + st.session_state.scene_videos[scene_id] = target_path + db.save_asset(st.session_state.project_id, scene_id, "video", "completed", local_path=target_path, task_id=t_id) st.rerun() break elif status in ["failed", "cancelled"]: @@ -1067,18 +1230,23 @@ if st.session_state.view_mode == "workspace": c_act1, c_act2 = st.columns([1, 4]) with c_act1: - if st.button("🔄 重新生成所有视频", type="secondary"): + if st.button("🧹 清空视频并重新生成", type="secondary"): # Clear videos and rerun st.session_state.scene_videos = {} - # Also clear DB video assets to avoid stale URL preview + # Also clear DB video assets if st.session_state.project_id: db.clear_assets(st.session_state.project_id, "video", status="pending") st.rerun() with c_act2: - if st.button("下一步:合成最终成片", type="primary"): + can_compose = any( + p and os.path.exists(p) for p in (st.session_state.scene_videos or {}).values() + ) + if st.button("进入第 5 步:合成最终成片", type="primary", disabled=not can_compose): st.session_state.current_step = 4 st.rerun() + if not can_compose: + st.caption("⚠️ 需要至少一个分镜视频已下载到服务器本地后,才能进入合成。") # --- Step 5: Final Composition & Tuning --- if st.session_state.current_step >= 4: @@ -1174,6 +1342,9 @@ if st.session_state.view_mode == "workspace": if st.button("🔄 重新合成 (Re-Compose)", type="primary"): with st.spinner("正在应用修改并重新合成..."): + # 自动补齐视频下载逻辑 (关键优化) + _ensure_local_videos(st.session_state.project_id, st.session_state.script_data.get("scenes", [])) + composer = VideoComposer(voice_type=selected_voice) bgm_path = None @@ -1255,6 +1426,9 @@ if st.session_state.view_mode == "workspace": st.info("暂无合成视频,请先点击开始合成。") if st.button("✨ 开始首次合成", type="primary"): with st.spinner("正在进行多轨合成..."): + # 自动补齐视频下载逻辑 (关键优化) + _ensure_local_videos(st.session_state.project_id, st.session_state.script_data.get("scenes", [])) + # Default compose logic with smart BGM matching composer = VideoComposer(voice_type=config.VOLC_TTS_DEFAULT_VOICE) @@ -1290,6 +1464,9 @@ if st.session_state.view_mode == "workspace": else: st.success(f"共找到 {len(found_files)} 个历史版本") + # 提示用户:如果重新合成,系统会自动补全未下载的素材 + st.caption("💡 重新合成将应用当前的微调设置。如果分镜未下载,系统将尝试自动补全。") + # 遍历显示所有历史版本 for idx, vid_path in enumerate(found_files): mtime = os.path.getmtime(vid_path) @@ -1324,7 +1501,7 @@ if st.session_state.view_mode == "workspace": elif st.session_state.view_mode == "history": st.header("📜 历史任务") - projects = db.list_projects() + projects = db.list_projects_for_user(_current_user()) for proj in projects: with st.expander(f"{proj['name']} ({proj['updated_at']})"): @@ -1394,11 +1571,19 @@ elif st.session_state.view_mode == "settings": st.subheader("Prompt 配置") # Script Generation Prompt - current_prompt = db.get_config("prompt_script_gen") + cu = _current_user() or {} + user_prompt = None + try: + user_prompt = db.get_user_prompt(cu.get("id"), "prompt_script_gen") if cu.get("id") else None + except Exception: + user_prompt = None + current_prompt = user_prompt or db.get_config("prompt_script_gen") # 显示当前状态 - if current_prompt: - st.info("✅ 已加载自定义 Prompt(来自数据库)") + if user_prompt: + st.info("✅ 已加载自定义 Prompt(当前用户)") + elif current_prompt: + st.info("✅ 已加载自定义 Prompt(全局默认)") else: st.warning("⚠️ 使用默认 Prompt(数据库中无自定义配置)") # Load default from instance if not in DB @@ -1410,18 +1595,89 @@ elif st.session_state.view_mode == "settings": col_save, col_reset = st.columns([1, 3]) with col_save: if st.button("💾 保存配置", type="primary"): - db.set_config("prompt_script_gen", new_prompt, "System prompt for script generation step") - # 验证保存 - saved = db.get_config("prompt_script_gen") + # Save per-user prompt + db.set_user_prompt(cu.get("id"), "prompt_script_gen", new_prompt) + saved = db.get_user_prompt(cu.get("id"), "prompt_script_gen") if saved == new_prompt: - st.success("✅ 配置已保存并验证成功!下次生成脚本时将使用新 Prompt。") + st.success("✅ 已保存为“当前用户 Prompt”。下次生成脚本仅影响你自己的账号。") else: st.error("❌ 保存可能失败,请检查日志") with col_reset: if st.button("🔄 恢复默认"): temp_gen = ScriptGenerator() - db.set_config("prompt_script_gen", temp_gen.default_system_prompt, "System prompt for script generation step (DEFAULT)") - st.success("已恢复默认 Prompt,请刷新页面查看") + # Clear per-user override: set empty to remove effect + db.set_user_prompt(cu.get("id"), "prompt_script_gen", "") + st.success("已清除当前用户 Prompt,将回退到全局/默认 Prompt。") + st.rerun() + + st.markdown("---") + st.subheader("账号与权限") + + # Password change for current user + with st.expander("修改我的密码", expanded=False): + p1 = st.text_input("新密码", type="password", key=_ui_key("pwd_new")) + p2 = st.text_input("确认新密码", type="password", key=_ui_key("pwd_new2")) + if st.button("保存新密码", type="primary", key=_ui_key("pwd_save")): + if not p1 or len(p1) < 6: + st.error("密码至少 6 位") + elif p1 != p2: + st.error("两次输入不一致") + else: + db.reset_user_password(cu.get("id"), p1) + st.success("密码已更新,请重新登录。") + _logout() + + # Admin console + if (cu.get("role") == "admin"): + st.markdown("### Admin:账号管理") + users = db.list_users() + if users: + st.dataframe( + [{k: u.get(k) for k in ["username", "role", "is_active", "last_login_at", "created_at", "id"]} for u in users], + use_container_width=True, + ) + + with st.expander("创建/更新用户", expanded=False): + u_name = st.text_input("用户名", key=_ui_key("adm_u_name")) + u_role = st.selectbox("角色", ["user", "admin"], index=0, key=_ui_key("adm_u_role")) + u_active = st.selectbox("是否启用", ["启用", "禁用"], index=0, key=_ui_key("adm_u_active")) + u_pwd = st.text_input("初始/重置密码", type="password", key=_ui_key("adm_u_pwd")) + if st.button("保存用户", type="primary", key=_ui_key("adm_u_save")): + if not u_name: + st.error("用户名不能为空") + elif not u_pwd or len(u_pwd) < 6: + st.error("密码至少 6 位") + else: + uid = db.upsert_user( + username=u_name.strip(), + password=u_pwd, + role=u_role, + is_active=(1 if u_active == "启用" else 0), + ) + st.success(f"已保存用户:{u_name} (id={uid})") + st.rerun() + + with st.expander("重置/禁用用户(按用户名)", expanded=False): + uname = st.selectbox( + "选择用户", + options=[u.get("username") for u in users if u.get("username")], + key=_ui_key("adm_sel_user"), + ) if users else None + new_pass = st.text_input("新密码(可选)", type="password", key=_ui_key("adm_reset_pwd")) + new_active = st.selectbox("状态", ["不修改", "启用", "禁用"], key=_ui_key("adm_reset_active")) + if st.button("应用修改", type="secondary", key=_ui_key("adm_apply")): + target = next((u for u in users if u.get("username") == uname), None) + if not target: + st.error("未找到用户") + else: + if new_pass: + if len(new_pass) < 6: + st.error("密码至少 6 位") + st.stop() + db.reset_user_password(target.get("id"), new_pass) + if new_active != "不修改": + db.set_user_active(target.get("id"), 1 if new_active == "启用" else 0) + st.success("已更新") st.rerun() diff --git a/assets/stickers_builtin/arrow.svg b/assets/stickers_builtin/arrow.svg new file mode 100644 index 0000000..60d4ed2 --- /dev/null +++ b/assets/stickers_builtin/arrow.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/stickers_builtin/arrow_curve.svg b/assets/stickers_builtin/arrow_curve.svg new file mode 100644 index 0000000..675dce9 --- /dev/null +++ b/assets/stickers_builtin/arrow_curve.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/stickers_builtin/arrow_down.svg b/assets/stickers_builtin/arrow_down.svg new file mode 100644 index 0000000..92bb743 --- /dev/null +++ b/assets/stickers_builtin/arrow_down.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/stickers_builtin/arrow_left.svg b/assets/stickers_builtin/arrow_left.svg new file mode 100644 index 0000000..cf23364 --- /dev/null +++ b/assets/stickers_builtin/arrow_left.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/stickers_builtin/arrow_right.svg b/assets/stickers_builtin/arrow_right.svg new file mode 100644 index 0000000..b497ef0 --- /dev/null +++ b/assets/stickers_builtin/arrow_right.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/stickers_builtin/arrow_up.svg b/assets/stickers_builtin/arrow_up.svg new file mode 100644 index 0000000..45323f6 --- /dev/null +++ b/assets/stickers_builtin/arrow_up.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/stickers_builtin/benefit.svg b/assets/stickers_builtin/benefit.svg new file mode 100644 index 0000000..2a6d365 --- /dev/null +++ b/assets/stickers_builtin/benefit.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + 福利 + + + + + + + diff --git a/assets/stickers_builtin/bubble_buy.svg b/assets/stickers_builtin/bubble_buy.svg new file mode 100644 index 0000000..93a3ae0 --- /dev/null +++ b/assets/stickers_builtin/bubble_buy.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + 买它 + + + + + diff --git a/assets/stickers_builtin/bubble_go.svg b/assets/stickers_builtin/bubble_go.svg new file mode 100644 index 0000000..7e36d6e --- /dev/null +++ b/assets/stickers_builtin/bubble_go.svg @@ -0,0 +1,15 @@ + + + + + + + + + + 冲! + + + + + diff --git a/assets/stickers_builtin/bubble_link.svg b/assets/stickers_builtin/bubble_link.svg new file mode 100644 index 0000000..de20bdf --- /dev/null +++ b/assets/stickers_builtin/bubble_link.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + 上链接 + + + + + + diff --git a/assets/stickers_builtin/bubble_nice.svg b/assets/stickers_builtin/bubble_nice.svg new file mode 100644 index 0000000..58de2e4 --- /dev/null +++ b/assets/stickers_builtin/bubble_nice.svg @@ -0,0 +1,15 @@ + + + + + + + + + 真香 + + + + + + diff --git a/assets/stickers_builtin/bubble_order.svg b/assets/stickers_builtin/bubble_order.svg new file mode 100644 index 0000000..d025ac3 --- /dev/null +++ b/assets/stickers_builtin/bubble_order.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + 安排 + + + + + diff --git a/assets/stickers_builtin/coupon.svg b/assets/stickers_builtin/coupon.svg new file mode 100644 index 0000000..ecc549b --- /dev/null +++ b/assets/stickers_builtin/coupon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + 领券 + + + + + diff --git a/assets/stickers_builtin/follow.svg b/assets/stickers_builtin/follow.svg new file mode 100644 index 0000000..1512d44 --- /dev/null +++ b/assets/stickers_builtin/follow.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + 关注 + + + + + + + diff --git a/assets/stickers_builtin/hot.svg b/assets/stickers_builtin/hot.svg new file mode 100644 index 0000000..8c82521 --- /dev/null +++ b/assets/stickers_builtin/hot.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + 爆款 + + + + + diff --git a/assets/stickers_builtin/index.json b/assets/stickers_builtin/index.json new file mode 100644 index 0000000..bc8e2ee --- /dev/null +++ b/assets/stickers_builtin/index.json @@ -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": ["气泡", "弹幕", "链接"] } + ] + } + ] +} + + diff --git a/assets/stickers_builtin/like.svg b/assets/stickers_builtin/like.svg new file mode 100644 index 0000000..1928b56 --- /dev/null +++ b/assets/stickers_builtin/like.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/stickers_builtin/limit.svg b/assets/stickers_builtin/limit.svg new file mode 100644 index 0000000..b709ce7 --- /dev/null +++ b/assets/stickers_builtin/limit.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + 限时 + + + 抢购中 + + + + + + diff --git a/assets/stickers_builtin/look.svg b/assets/stickers_builtin/look.svg new file mode 100644 index 0000000..6349c0c --- /dev/null +++ b/assets/stickers_builtin/look.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + 看这里 + + + + + + diff --git a/assets/stickers_builtin/lowest.svg b/assets/stickers_builtin/lowest.svg new file mode 100644 index 0000000..290acae --- /dev/null +++ b/assets/stickers_builtin/lowest.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + 到手价 + + + + + + diff --git a/assets/stickers_builtin/new.svg b/assets/stickers_builtin/new.svg new file mode 100644 index 0000000..64ff662 --- /dev/null +++ b/assets/stickers_builtin/new.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + 新品 + + + + + diff --git a/assets/stickers_builtin/pointer.svg b/assets/stickers_builtin/pointer.svg new file mode 100644 index 0000000..184e6db --- /dev/null +++ b/assets/stickers_builtin/pointer.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/assets/stickers_builtin/sale.svg b/assets/stickers_builtin/sale.svg new file mode 100644 index 0000000..4872c9e --- /dev/null +++ b/assets/stickers_builtin/sale.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + SALE + + + + + diff --git a/assets/stickers_builtin/tap.svg b/assets/stickers_builtin/tap.svg new file mode 100644 index 0000000..03e1e52 --- /dev/null +++ b/assets/stickers_builtin/tap.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + 戳这里 + + + + + diff --git a/assets/stickers_builtin/wow.svg b/assets/stickers_builtin/wow.svg new file mode 100644 index 0000000..d948cee --- /dev/null +++ b/assets/stickers_builtin/wow.svg @@ -0,0 +1,16 @@ + + + + + + + + + + WOW + + + + + + diff --git a/config.py b/config.py index 377c0e2..c121079 100644 --- a/config.py +++ b/config.py @@ -38,6 +38,10 @@ DOUBAO_IMG_MODEL = "ep-20251203231641-wg9nb" SHUBIAOBIAO_KEY = os.getenv("SHUBIAOBIAO_KEY", "sk-aL167A8sQEyvs40yBfC140Fc0fDa4c198f029aAcF0429108") SHUBIAOBIAO_BASE_URL = os.getenv("SHUBIAOBIAO_BASE_URL", "https://api.shubiaobiao.cn/v1") SHUBIAOBIAO_MODEL_TEXT = "gemini-3-pro-preview" +# ShuBiaoBiao (OpenAI-compatible) chat timeout/retries +# Note: OpenAI Python SDK default timeout is 10 minutes; we cap it to avoid UI "hang" feeling. +SHUBIAOBIAO_CHAT_TIMEOUT_S = float(os.getenv("SHUBIAOBIAO_CHAT_TIMEOUT_S", "180")) +SHUBIAOBIAO_CHAT_MAX_RETRIES = int(os.getenv("SHUBIAOBIAO_CHAT_MAX_RETRIES", "2")) # Image Generation API (Updated) # Host: https://api.wuyinkeji.com/ diff --git a/docs/04-development/DEV-LEGACY-SCHEMA-20251215.md b/docs/04-development/DEV-LEGACY-SCHEMA-20251215.md new file mode 100644 index 0000000..7f2b0d6 --- /dev/null +++ b/docs/04-development/DEV-LEGACY-SCHEMA-20251215.md @@ -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 + diff --git a/docs/07-user/USER-002-贴纸库数据源与授权.md b/docs/07-user/USER-002-贴纸库数据源与授权.md new file mode 100644 index 0000000..883cec9 --- /dev/null +++ b/docs/07-user/USER-002-贴纸库数据源与授权.md @@ -0,0 +1,51 @@ +## 目标 +为编辑器内置一个**适合抖音场景**的贴纸库(PNG/SVG),并保证: +- **可商用/可分发**(许可清晰) +- **可本地托管**(不依赖外部 CDN) +- **所见即所得**:预览与导出一致(贴纸叠加到成片) + +## 推荐贴纸库(抖音场景友好) +### 方案 A:Microsoft Fluent UI Emoji(更“抖音感”) +- **风格**:高饱和、现代、偏 3D/大图标,适合“强调/氛围/卖点” +- **形态**:PNG/SVG(仓库提供多种风格/尺寸) +- **适用**:火/赞/心/星星/箭头/提示/表情等常用贴纸 +- **风险**:请在引入前再次核对仓库 LICENSE(不同仓库/分支可能不同) + +### 方案 B:Twemoji(稳定、覆盖全、但更像“emoji”) +- **风格**:标准 emoji +- **形态**:PNG/SVG +- **适用**:作为“基础补全库”非常合适 +- **风险**:通常需要署名(CC-BY 类);引入前核对 LICENSE + +### 不推荐(默认) +### OpenMoji +- **优点**:开源清晰、SVG 质量高 +- **缺点**:常见为 CC BY-SA(“同协议分享”约束强),对商业产品和二次分发不友好 + +## 贴纸库落地方式(本项目) +本项目支持两类贴纸: +- **内置贴纸**:放在 `assets/stickers_builtin/`,通过 `assets/stickers_builtin/index.json` 声明分类/标签/授权信息。 +- **自定义贴纸**:用户上传到 `assets/stickers_custom/`,可直接在 UI 里使用。 + +后端接口: +- `GET /api/assets/stickers`:返回贴纸列表(合并 builtin + custom) +- `POST /api/assets/stickers/upload`:上传 PNG/SVG/WEBP + +前端能力: +- 左侧 **“贴纸”** Tab:搜索/分类/缩略图;**拖拽到时间轴**生成贴纸片段 +- 时间轴新增 **“贴纸”轨道**:贴纸片段可移动/裁剪时长 +- 右侧属性:贴纸 **大小/旋转/X/Y** + +导出(WYSIWYG): +- FFmpeg 叠加贴纸:`overlay_multiple_images` +- SVG 会在导出侧被转换为 PNG(优先使用 `rsvg-convert`,Dockerfile 已加入 `librsvg2-bin`) + +## 下一步:把“推荐贴纸库”真正导入到 assets(需要一次性下载) +由于贴纸库体积很大(数千~上万文件),建议用脚本把需要的子集同步到 `assets/stickers_builtin/`: +- 先挑“抖音高频类目”:点赞/关注/箭头/爆款/促销/emoji 表情/弹幕气泡 +- 再逐步扩展 + +我建议你确认最终选用的库(Fluent vs Twemoji)后,我可以给你一个“按清单下载 + 生成 index.json”的脚本(可在服务器执行)。 + + + diff --git a/docs/EDITOR_README.md b/docs/EDITOR_README.md new file mode 100644 index 0000000..dce5729 --- /dev/null +++ b/docs/EDITOR_README.md @@ -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 + diff --git a/modules/auth.py b/modules/auth.py new file mode 100644 index 0000000..a36588e --- /dev/null +++ b/modules/auth.py @@ -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() + + + + + + + + + + + + + + diff --git a/modules/composer.py b/modules/composer.py index 5f7b7cf..ccbd5c1 100644 --- a/modules/composer.py +++ b/modules/composer.py @@ -502,11 +502,20 @@ class VideoComposer: current_video = fancy_path # 7. 添加 BGM + # 说明:add_bgm 的 ducking=True 路径使用 sidechaincompress,但该滤镜本身不做“混音”, + # 在某些 ffmpeg 版本/参数组合下会导致 BGM 听起来像“没加上”。 + # 我们在 compose() 里已禁用 ducking,这里保持一致,使用 amix 叠加并提高默认音量。 if bgm_path: bgm_output = str(Path(temp_root) / f"{output_name}_bgm.mp4") ffmpeg_utils.add_bgm( - current_video, bgm_path, bgm_output, - bgm_volume=0.15 + current_video, + bgm_path, + bgm_output, + bgm_volume=0.20, + ducking=False, + duck_gain_db=-6.0, + fade_in=1.0, + fade_out=1.0, ) self._add_temp(bgm_output) current_video = bgm_output diff --git a/modules/db_manager.py b/modules/db_manager.py index 2c43804..45eb4f4 100644 --- a/modules/db_manager.py +++ b/modules/db_manager.py @@ -6,14 +6,43 @@ import json import logging import time -from typing import Dict, List, Any, Optional +import secrets +from typing import Dict, List, Any, Optional, Tuple -from sqlalchemy import create_engine, Column, String, Integer, Text, Float, UniqueConstraint, func +from sqlalchemy import create_engine, Column, String, Integer, Text, Float, UniqueConstraint, func, text, inspect from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base from sqlalchemy.dialects.postgresql import JSONB import config +# NOTE: Some deployments do not ship `modules/auth.py`. +# Keep a minimal, local auth helper here to avoid hard dependency. +import hashlib +import hmac + + +def _hash_password(password: str, salt_hex: str = None) -> tuple[str, str]: + salt = bytes.fromhex(salt_hex) if salt_hex else secrets.token_bytes(16) + dk = hashlib.pbkdf2_hmac("sha256", (password or "").encode("utf-8"), salt, 120_000) + return dk.hex(), salt.hex() + + +def _verify_password(password: str, pwd_hash_hex: str, salt_hex: str) -> bool: + try: + cand, _ = _hash_password(password or "", salt_hex=salt_hex) + return hmac.compare_digest(cand, pwd_hash_hex or "") + except Exception: + return False + + +def _new_session_token() -> str: + return secrets.token_urlsafe(32) + + +def _hash_token(token: str) -> str: + # store only hash for sessions + return hashlib.sha256((token or "").encode("utf-8")).hexdigest() + logger = logging.getLogger(__name__) Base = declarative_base() @@ -26,6 +55,7 @@ class Project(Base): status = Column(String) # created, script_generated, images_generated, videos_generated, completed product_info = Column(Text) # JSON string (SQLite) or JSONB (PG - using Text for compat) script_data = Column(Text) # JSON string + owner_user_id = Column(String, index=True, nullable=True) created_at = Column(Float, default=time.time) updated_at = Column(Float, default=time.time, onupdate=time.time) @@ -54,6 +84,62 @@ class AppConfig(Base): description = Column(Text, nullable=True) updated_at = Column(Float, default=time.time, onupdate=time.time) + +class User(Base): + __tablename__ = "users" + id = Column(String, primary_key=True) # uuid hex + username = Column(String, unique=True, index=True) + password_hash = Column(String) + password_salt = Column(String) + role = Column(String, default="user") # admin/user + is_active = Column(Integer, default=1) # 1/0 for portability + created_at = Column(Float, default=time.time) + updated_at = Column(Float, default=time.time, onupdate=time.time) + last_login_at = Column(Float, nullable=True) + + +class UserSession(Base): + __tablename__ = "user_sessions" + id = Column(String, primary_key=True) # uuid hex + user_id = Column(String, index=True) + token_hash = Column(String, index=True) + expires_at = Column(Float) + created_at = Column(Float, default=time.time) + last_seen_at = Column(Float, default=time.time) + ip = Column(String, nullable=True) + user_agent = Column(String, nullable=True) + + +class UserPrompt(Base): + __tablename__ = "user_prompts" + id = Column(String, primary_key=True) # uuid hex + user_id = Column(String, index=True) + key = Column(String, index=True) + value = Column(Text) + updated_at = Column(Float, default=time.time, onupdate=time.time) + __table_args__ = (UniqueConstraint("user_id", "key", name="uix_user_prompt"),) + + +class RenderJob(Base): + """ + Render job status table (async compose pipeline). + This is intentionally minimal and portable across SQLite/Postgres. + """ + __tablename__ = "render_jobs" + + id = Column(String, primary_key=True) # task_id + project_id = Column(String, index=True) + status = Column(String, default="queued") # queued/running/success/failed/cancelled + progress = Column(Float, default=0.0) + message = Column(Text, default="") + output_path = Column(Text, nullable=True) + output_url = Column(Text, nullable=True) + error = Column(Text, nullable=True) + request_json = Column(Text, nullable=True) # JSON string of ComposeRequest + parent_id = Column(String, nullable=True, index=True) + created_at = Column(Float, default=time.time) + updated_at = Column(Float, default=time.time, onupdate=time.time) + class DBManager: def __init__(self, connection_string: str = None): if not connection_string: @@ -62,17 +148,59 @@ class DBManager: self.engine = create_engine(connection_string, pool_recycle=3600) self.Session = scoped_session(sessionmaker(bind=self.engine)) self._init_db() + # bootstrap admin (safe to call repeatedly) + try: + self._bootstrap_admin_id = self.ensure_admin_user("admin", "admin1234") + except Exception: + self._bootstrap_admin_id = None def _init_db(self): - """初始化表结构""" + """初始化表结构 + 轻量自迁移(不依赖 Alembic)""" Base.metadata.create_all(self.engine) + self._ensure_schema() + + def _ensure_schema(self) -> None: + """ + Ensure newer columns/tables exist in both Postgres and SQLite. + create_all will not add columns to existing tables, so we do a minimal ALTER here. + """ + try: + insp = inspect(self.engine) + # projects.owner_user_id + try: + cols = [c["name"] for c in insp.get_columns("projects")] + except Exception: + cols = [] + if "owner_user_id" not in cols: + logger.info("Migrating: add projects.owner_user_id") + with self.engine.begin() as conn: + conn.execute(text("ALTER TABLE projects ADD COLUMN owner_user_id VARCHAR")) + try: + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_projects_owner_user_id ON projects (owner_user_id)")) + except Exception: + # some engines (older sqlite) may not support IF NOT EXISTS + try: + conn.execute(text("CREATE INDEX ix_projects_owner_user_id ON projects (owner_user_id)")) + except Exception: + pass + except Exception as e: + logger.warning(f"Schema ensure skipped/failed (non-fatal): {e}") + + # Ensure render_jobs table exists (create_all should handle this; keep for safety) + try: + insp = inspect(self.engine) + if "render_jobs" not in insp.get_table_names(): + logger.info("Migrating: create render_jobs table") + Base.metadata.create_all(self.engine) + except Exception as e: + logger.warning(f"render_jobs ensure skipped/failed (non-fatal): {e}") def _get_session(self): return self.Session() # --- Project Operations --- - def create_project(self, project_id: str, name: str, product_info: Dict[str, Any]): + def create_project(self, project_id: str, name: str, product_info: Dict[str, Any], owner_user_id: Optional[str] = None): session = self._get_session() try: # Check if exists @@ -86,6 +214,7 @@ class DBManager: name=name, status="created", product_info=json.dumps(product_info, ensure_ascii=False), + owner_user_id=owner_user_id, created_at=time.time(), updated_at=time.time() ) @@ -157,6 +286,7 @@ class DBManager: "status": project.status, "product_info": json.loads(project.product_info) if project.product_info else {}, "script_data": json.loads(project.script_data) if project.script_data else None, + "owner_user_id": getattr(project, "owner_user_id", None), "created_at": project.created_at, "updated_at": project.updated_at } @@ -175,12 +305,288 @@ class DBManager: "id": p.id, "name": p.name, "status": p.status, + "owner_user_id": getattr(p, "owner_user_id", None), "updated_at": p.updated_at }) return results finally: session.close() + # --- User/Auth Operations --- + + def ensure_admin_user(self, username: str = "admin", password: str = "admin1234") -> str: + """Create bootstrap admin user if missing. Returns admin user_id.""" + session = self._get_session() + try: + u = session.query(User).filter_by(username=username).first() + if u: + return u.id + pwd_hash, salt_hex = _hash_password(password) + uid = secrets.token_hex(16) + u = User( + id=uid, + username=username, + password_hash=pwd_hash, + password_salt=salt_hex, + role="admin", + is_active=1, + created_at=time.time(), + updated_at=time.time(), + ) + session.add(u) + session.commit() + logger.warning("Bootstrap admin created (please change password asap).") + return uid + except Exception as e: + session.rollback() + logger.error(f"ensure_admin_user failed: {e}") + raise + finally: + session.close() + + def authenticate_user(self, username: str, password: str) -> Optional[Dict[str, Any]]: + session = self._get_session() + try: + u = session.query(User).filter_by(username=username).first() + if not u or int(getattr(u, "is_active", 1) or 0) != 1: + return None + if not _verify_password(password or "", getattr(u, "password_hash", ""), getattr(u, "password_salt", "")): + return None + u.last_login_at = time.time() + session.commit() + return {"id": u.id, "username": u.username, "role": u.role, "is_active": int(u.is_active or 0)} + except Exception as e: + session.rollback() + logger.error(f"authenticate_user error: {e}") + return None + finally: + session.close() + + def create_session(self, user_id: str, *, ttl_seconds: int = 7 * 24 * 3600, ip: str = None, user_agent: str = None) -> str: + """Returns raw session token (store hash in DB).""" + token = _new_session_token() + token_hash = _hash_token(token) + sid = secrets.token_hex(16) + session = self._get_session() + try: + now = time.time() + s = UserSession( + id=sid, + user_id=user_id, + token_hash=token_hash, + expires_at=now + int(ttl_seconds), + created_at=now, + last_seen_at=now, + ip=ip, + user_agent=user_agent, + ) + session.add(s) + session.commit() + return token + except Exception as e: + session.rollback() + logger.error(f"create_session error: {e}") + raise + finally: + session.close() + + def validate_session(self, token: str) -> Optional[Dict[str, Any]]: + if not token: + return None + token_hash = _hash_token(token) + session = self._get_session() + try: + now = time.time() + s = session.query(UserSession).filter_by(token_hash=token_hash).first() + if not s or (s.expires_at and float(s.expires_at) < now): + return None + u = session.query(User).filter_by(id=s.user_id).first() + if not u or int(getattr(u, "is_active", 1) or 0) != 1: + return None + s.last_seen_at = now + session.commit() + return {"id": u.id, "username": u.username, "role": u.role, "is_active": int(u.is_active or 0)} + except Exception as e: + session.rollback() + logger.error(f"validate_session error: {e}") + return None + finally: + session.close() + + def revoke_session(self, token: str) -> None: + if not token: + return + token_hash = _hash_token(token) + session = self._get_session() + try: + s = session.query(UserSession).filter_by(token_hash=token_hash).first() + if s: + session.delete(s) + session.commit() + except Exception as e: + session.rollback() + logger.error(f"revoke_session error: {e}") + finally: + session.close() + + def list_users(self) -> List[Dict[str, Any]]: + session = self._get_session() + try: + users = session.query(User).order_by(User.created_at.asc()).all() + return [ + { + "id": u.id, + "username": u.username, + "role": u.role, + "is_active": int(u.is_active or 0), + "created_at": u.created_at, + "last_login_at": u.last_login_at, + } + for u in users + ] + finally: + session.close() + + def upsert_user(self, username: str, password: str, *, role: str = "user", is_active: int = 1) -> str: + session = self._get_session() + try: + u = session.query(User).filter_by(username=username).first() + pwd_hash, salt_hex = _hash_password(password) + now = time.time() + if u: + u.password_hash = pwd_hash + u.password_salt = salt_hex + u.role = role + u.is_active = int(is_active) + u.updated_at = now + session.commit() + return u.id + uid = secrets.token_hex(16) + u = User( + id=uid, + username=username, + password_hash=pwd_hash, + password_salt=salt_hex, + role=role, + is_active=int(is_active), + created_at=now, + updated_at=now, + ) + session.add(u) + session.commit() + return uid + except Exception as e: + session.rollback() + logger.error(f"upsert_user error: {e}") + raise + finally: + session.close() + + def set_user_active(self, user_id: str, is_active: int) -> None: + session = self._get_session() + try: + u = session.query(User).filter_by(id=user_id).first() + if not u: + return + u.is_active = int(is_active) + u.updated_at = time.time() + session.commit() + except Exception as e: + session.rollback() + logger.error(f"set_user_active error: {e}") + finally: + session.close() + + def reset_user_password(self, user_id: str, new_password: str) -> None: + session = self._get_session() + try: + u = session.query(User).filter_by(id=user_id).first() + if not u: + return + pwd_hash, salt_hex = _hash_password(new_password) + u.password_hash = pwd_hash + u.password_salt = salt_hex + u.updated_at = time.time() + session.commit() + except Exception as e: + session.rollback() + logger.error(f"reset_user_password error: {e}") + finally: + session.close() + + def get_user_prompt(self, user_id: str, key: str) -> Optional[str]: + if not user_id or not key: + return None + session = self._get_session() + try: + p = session.query(UserPrompt).filter_by(user_id=user_id, key=key).first() + return p.value if p else None + finally: + session.close() + + def set_user_prompt(self, user_id: str, key: str, value: Any) -> None: + if not user_id or not key: + return + session = self._get_session() + try: + now = time.time() + v = json.dumps(value, ensure_ascii=False) if not isinstance(value, str) else value + p = session.query(UserPrompt).filter_by(user_id=user_id, key=key).first() + if p: + p.value = v + p.updated_at = now + else: + p = UserPrompt(id=secrets.token_hex(16), user_id=user_id, key=key, value=v, updated_at=now) + session.add(p) + session.commit() + except Exception as e: + session.rollback() + logger.error(f"set_user_prompt error: {e}") + finally: + session.close() + + # --- RBAC helpers --- + def list_projects_for_user(self, user: Dict[str, Any]) -> List[Dict[str, Any]]: + if not user: + return [] + if user.get("role") == "admin": + return self.list_projects() + uid = user.get("id") + session = self._get_session() + try: + projects = session.query(Project).filter_by(owner_user_id=uid).order_by(Project.updated_at.desc()).all() + return [{"id": p.id, "name": p.name, "status": p.status, "owner_user_id": p.owner_user_id, "updated_at": p.updated_at} for p in projects] + finally: + session.close() + + def get_project_for_user(self, project_id: str, user: Dict[str, Any]) -> Optional[Dict[str, Any]]: + data = self.get_project(project_id) + if not data or not user: + return None + if user.get("role") == "admin": + return data + if data.get("owner_user_id") != user.get("id"): + return None + return data + + def migrate_projects_owner_to(self, owner_user_id: str) -> int: + """Assign owner_user_id for legacy projects where it is NULL/empty.""" + session = self._get_session() + try: + q = session.query(Project).filter((Project.owner_user_id == None) | (Project.owner_user_id == "")) # noqa: E711 + rows = q.all() + for p in rows: + p.owner_user_id = owner_user_id + p.updated_at = time.time() + session.commit() + return len(rows) + except Exception as e: + session.rollback() + logger.error(f"migrate_projects_owner_to error: {e}") + return 0 + finally: + session.close() + # --- Asset/Task Operations --- def save_asset(self, project_id: str, scene_id: int, asset_type: str, @@ -253,6 +659,92 @@ class DBManager: finally: session.close() + # --- Render Job Operations --- + + def create_render_job( + self, + job_id: str, + project_id: str, + *, + status: str = "queued", + progress: float = 0.0, + message: str = "", + request: Optional[Dict[str, Any]] = None, + parent_id: Optional[str] = None, + ) -> None: + session = self._get_session() + try: + existing = session.query(RenderJob).filter_by(id=job_id).first() + if existing: + return + now = time.time() + rj = RenderJob( + id=job_id, + project_id=project_id, + status=status, + progress=float(progress or 0.0), + message=message or "", + request_json=json.dumps(request, ensure_ascii=False) if request is not None else None, + parent_id=parent_id, + created_at=now, + updated_at=now, + ) + session.add(rj) + session.commit() + except Exception as e: + session.rollback() + logger.error(f"create_render_job error: {e}") + raise + finally: + session.close() + + def update_render_job(self, job_id: str, patch: Dict[str, Any]) -> None: + if not job_id or not patch: + return + session = self._get_session() + try: + rj = session.query(RenderJob).filter_by(id=job_id).first() + if not rj: + return + for k, v in patch.items(): + if not hasattr(rj, k): + continue + setattr(rj, k, v) + rj.updated_at = time.time() + session.commit() + except Exception as e: + session.rollback() + logger.error(f"update_render_job error: {e}") + finally: + session.close() + + def get_render_job(self, job_id: str) -> Optional[Dict[str, Any]]: + session = self._get_session() + try: + rj = session.query(RenderJob).filter_by(id=job_id).first() + if not rj: + return None + try: + req = json.loads(rj.request_json) if rj.request_json else None + except Exception: + req = None + return { + "id": rj.id, + "project_id": rj.project_id, + "status": rj.status, + "progress": float(rj.progress or 0.0), + "message": rj.message or "", + "output_path": rj.output_path, + "output_url": rj.output_url, + "error": rj.error, + "request": req, + "parent_id": rj.parent_id, + "created_at": rj.created_at, + "updated_at": rj.updated_at, + } + finally: + session.close() + def get_asset(self, project_id: str, scene_id: int, asset_type: str) -> Optional[Dict[str, Any]]: session = self._get_session() try: @@ -279,6 +771,30 @@ class DBManager: finally: session.close() + def get_asset_by_id(self, asset_id: int) -> Optional[Dict[str, Any]]: + """通过自增 id 获取素材记录(用于 file proxy)。""" + session = self._get_session() + try: + a = session.query(SceneAsset).filter_by(id=int(asset_id)).first() + if not a: + return None + return { + "id": a.id, + "project_id": a.project_id, + "scene_id": a.scene_id, + "asset_type": a.asset_type, + "status": a.status, + "local_path": a.local_path, + "remote_url": a.remote_url, + "task_id": a.task_id, + "metadata": json.loads(a.metadata_json) if a.metadata_json else {}, + "updated_at": a.updated_at, + } + except Exception: + return None + finally: + session.close() + def update_asset_metadata(self, project_id: str, scene_id: int, asset_type: str, patch: Dict[str, Any]) -> None: """Merge-patch asset.metadata JSON without overwriting other fields.""" if not patch: diff --git a/modules/ffmpeg_utils.py b/modules/ffmpeg_utils.py index 83bc758..f392c11 100644 --- a/modules/ffmpeg_utils.py +++ b/modules/ffmpeg_utils.py @@ -172,7 +172,8 @@ def get_video_info(video_path: str) -> Dict[str, Any]: def concat_videos( video_paths: List[str], output_path: str, - target_size: Tuple[int, int] = (1080, 1920) + target_size: Tuple[int, int] = (1080, 1920), + fades: Optional[List[Dict[str, float]]] = None ) -> str: """ 使用 FFmpeg concat demuxer 拼接多段视频 @@ -197,10 +198,72 @@ def concat_videos( filter_parts = [] for i in range(len(video_paths)): # scale 保持宽高比,pad 填充黑边居中 - filter_parts.append( + chain = ( f"[{i}:v]scale={width}:{height}:force_original_aspect_ratio=decrease," - f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[v{i}]" + f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1" ) + # 可选:片段末尾“火山式转场”(不改时长、不重叠) + if fades and i < len(fades): + fx = fades[i] or {} + fi = float(fx.get("in", 0) or 0.0) + fo = float(fx.get("out", 0) or 0.0) + t_type = str(fx.get("type") or "") + t_dur = float(fx.get("dur") or 0.0) + + try: + dur = float(get_video_info(video_paths[i]).get("duration") or 0.0) + except Exception: + dur = 0.0 + + # 基础淡入/淡出 + if fi > 0: + chain += f",fade=t=in:st=0:d={fi}" + if fo > 0 and dur > 0: + st = max(dur - fo, 0.0) + chain += f",fade=t=out:st={st}:d={fo}" + + # 末尾动效(WYSIWYG:前端预览必须与此一致) + if t_type and t_dur > 0 and dur > 0: + st = max(dur - t_dur, 0.0) + td = max(t_dur, 0.001) + p = f"if(between(t\\,{st}\\,{dur})\\,(t-{st})/{td}\\,0)" + if t_type == "fade": + chain += f",fade=t=out:st={st}:d={t_dur}" + elif t_type == "fadeWhite": + chain += f",fade=t=out:st={st}:d={t_dur}:color=white" + elif t_type == "blurOut": + chain += f",gblur=sigma='10*{p}':steps=1" + elif t_type == "blurFade": + chain += f",gblur=sigma='8*{p}':steps=1,fade=t=out:st={st}:d={t_dur}" + elif t_type == "flash": + chain += f",eq=brightness='0.7*(1-abs(0.5-{p})*2)'" + elif t_type == "desaturate": + chain += f",hue=s='1-0.9*{p}'" + elif t_type == "colorPop": + chain += f",hue=s='1+0.8*{p}',eq=contrast='1+0.3*{p}'" + elif t_type == "hueShift": + chain += f",hue=h='60*{p}'" + elif t_type == "darken": + chain += f",eq=brightness='-0.4*{p}'" + elif t_type in ("slideLeft", "slideRight", "slideUp", "slideDown"): + off = 80 + if t_type == "slideLeft": + chain += f",pad={width+off}:{height}:{off/2}-{off}*{p}:0:black,crop={width}:{height}:{off/2}:0" + if t_type == "slideRight": + chain += f",pad={width+off}:{height}:{off/2}+{off}*{p}:0:black,crop={width}:{height}:{off/2}:0" + if t_type == "slideUp": + chain += f",pad={width}:{height+off}:0:{off/2}-{off}*{p}:black,crop={width}:{height}:0:{off/2}" + if t_type == "slideDown": + chain += f",pad={width}:{height+off}:0:{off/2}+{off}*{p}:black,crop={width}:{height}:0:{off/2}" + elif t_type in ("zoomOut", "zoomIn"): + if t_type == "zoomOut": + chain += f",scale=w='{width}*(1-0.10*{p})':h='{height}*(1-0.10*{p})':eval=frame,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black" + else: + chain += f",scale=w='{width}*(1+0.10*{p})':h='{height}*(1+0.10*{p})':eval=frame,crop={width}:{height}" + elif t_type == "rotateOut": + chain += f",rotate=a='0.12*{p}':c=black@1:ow={width}:oh={height}" + chain += f"[v{i}]" + filter_parts.append(chain) # 拼接所有视频流 concat_inputs = "".join([f"[v{i}]" for i in range(len(video_paths))]) @@ -232,7 +295,8 @@ def concat_videos( def concat_videos_with_audio( video_paths: List[str], output_path: str, - target_size: Tuple[int, int] = (1080, 1920) + target_size: Tuple[int, int] = (1080, 1920), + fades: Optional[List[Dict[str, float]]] = None ) -> str: """ 拼接视频并保留音频轨道 @@ -250,10 +314,70 @@ def concat_videos_with_audio( # 视频处理 for i in range(n): - filter_parts.append( + chain = ( f"[{i}:v]scale={width}:{height}:force_original_aspect_ratio=decrease," - f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[v{i}]" + f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1" ) + # 可选:片段末尾“火山式转场”(不改时长、不重叠) + if fades and i < len(fades): + fx = fades[i] or {} + fi = float(fx.get("in", 0) or 0.0) + fo = float(fx.get("out", 0) or 0.0) + t_type = str(fx.get("type") or "") + t_dur = float(fx.get("dur") or 0.0) + + try: + dur = float(get_video_info(video_paths[i]).get("duration") or 0.0) + except Exception: + dur = 0.0 + + if fi > 0: + chain += f",fade=t=in:st=0:d={fi}" + if fo > 0 and dur > 0: + st = max(dur - fo, 0.0) + chain += f",fade=t=out:st={st}:d={fo}" + + if t_type and t_dur > 0 and dur > 0: + st = max(dur - t_dur, 0.0) + td = max(t_dur, 0.001) + p = f"if(between(t\\,{st}\\,{dur})\\,(t-{st})/{td}\\,0)" + if t_type == "fade": + chain += f",fade=t=out:st={st}:d={t_dur}" + elif t_type == "fadeWhite": + chain += f",fade=t=out:st={st}:d={t_dur}:color=white" + elif t_type == "blurOut": + chain += f",gblur=sigma='10*{p}':steps=1" + elif t_type == "blurFade": + chain += f",gblur=sigma='8*{p}':steps=1,fade=t=out:st={st}:d={t_dur}" + elif t_type == "flash": + chain += f",eq=brightness='0.7*(1-abs(0.5-{p})*2)'" + elif t_type == "desaturate": + chain += f",hue=s='1-0.9*{p}'" + elif t_type == "colorPop": + chain += f",hue=s='1+0.8*{p}',eq=contrast='1+0.3*{p}'" + elif t_type == "hueShift": + chain += f",hue=h='60*{p}'" + elif t_type == "darken": + chain += f",eq=brightness='-0.4*{p}'" + elif t_type in ("slideLeft", "slideRight", "slideUp", "slideDown"): + off = 80 + if t_type == "slideLeft": + chain += f",pad={width+off}:{height}:{off/2}-{off}*{p}:0:black,crop={width}:{height}:{off/2}:0" + if t_type == "slideRight": + chain += f",pad={width+off}:{height}:{off/2}+{off}*{p}:0:black,crop={width}:{height}:{off/2}:0" + if t_type == "slideUp": + chain += f",pad={width}:{height+off}:0:{off/2}-{off}*{p}:black,crop={width}:{height}:0:{off/2}" + if t_type == "slideDown": + chain += f",pad={width}:{height+off}:0:{off/2}+{off}*{p}:black,crop={width}:{height}:0:{off/2}" + elif t_type in ("zoomOut", "zoomIn"): + if t_type == "zoomOut": + chain += f",scale=w='{width}*(1-0.10*{p})':h='{height}*(1-0.10*{p})':eval=frame,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black" + else: + chain += f",scale=w='{width}*(1+0.10*{p})':h='{height}*(1+0.10*{p})':eval=frame,crop={width}:{height}" + elif t_type == "rotateOut": + chain += f",rotate=a='0.12*{p}':c=black@1:ow={width}:oh={height}" + chain += f"[v{i}]" + filter_parts.append(chain) # 音频处理(静音填充如果没有音频) for i in range(n): @@ -469,6 +593,127 @@ def adjust_audio_duration( return output_path +def _atempo_chain(speed: float) -> str: + """ + 构造 atempo 链,支持 <0.5 或 >2.0 的倍速(通过链式 atempo)。 + """ + try: + s = float(speed) + except Exception: + s = 1.0 + if s <= 0: + s = 1.0 + parts = [] + # atempo 支持 0.5~2.0 + while s > 2.0: + parts.append("atempo=2.0") + s /= 2.0 + while s < 0.5: + parts.append("atempo=0.5") + s /= 0.5 + parts.append(f"atempo={s}") + return ",".join(parts) + + +def change_audio_speed(input_path: str, speed: float, output_path: str) -> str: + """改变音频播放倍速(纯播放倍速)。""" + if not os.path.exists(input_path): + return None + af = _atempo_chain(speed) + cmd = [FFMPEG_PATH, "-y", "-i", input_path, "-filter:a", af, output_path] + _run_ffmpeg(cmd) + return output_path + + +def fit_audio_to_duration_by_speed(input_path: str, target_duration: float, output_path: str) -> str: + """ + 通过“改变播放倍速”来贴合目标时长(可快可慢),并裁剪/补齐到严格时长。 + 适用于旁白:用户拉伸片段期望语速变化,而不是静音补齐。 + """ + if not os.path.exists(input_path): + return None + try: + td = float(target_duration or 0) + except Exception: + td = 0.0 + if td <= 0: + import shutil + shutil.copy(input_path, output_path) + return output_path + + cur = float(get_audio_info(input_path).get("duration") or 0.0) + if cur <= 0: + import shutil + shutil.copy(input_path, output_path) + return output_path + + speed = cur / td + af_speed = _atempo_chain(speed) + # 贴合后仍做一次 atrim+apad 保证严格时长(避免累计误差) + af = f"{af_speed},atrim=0:{td},apad=pad_dur=0,atrim=0:{td}" + cmd = [FFMPEG_PATH, "-y", "-i", input_path, "-filter:a", af, output_path] + _run_ffmpeg(cmd) + return output_path + + +def force_audio_duration(input_path: str, target_duration: float, output_path: str) -> str: + """不改变倍速,仅裁剪/补齐到严格时长(用于倍速已在上游完成的场景)。""" + if not os.path.exists(input_path): + return None + try: + td = float(target_duration or 0) + except Exception: + td = 0.0 + if td <= 0: + import shutil + shutil.copy(input_path, output_path) + return output_path + af = f"atrim=0:{td},apad=pad_dur=0,atrim=0:{td}" + cmd = [FFMPEG_PATH, "-y", "-i", input_path, "-filter:a", af, output_path] + _run_ffmpeg(cmd) + return output_path + + +def _which(cmd: str) -> Optional[str]: + try: + import shutil + return shutil.which(cmd) + except Exception: + return None + + +def normalize_sticker_to_png(input_path: str, output_path: str) -> str: + """ + 将贴纸规范化为 PNG(用于 ffmpeg overlay)。 + - PNG/WEBP:直接返回原图或拷贝 + - SVG:优先用 rsvg-convert 转 PNG;否则尝试 ffmpeg 直接解码 + """ + if not input_path or not os.path.exists(input_path): + return None + ext = Path(input_path).suffix.lower() + if ext in [".png"]: + return input_path + if ext in [".webp"]: + # 转 PNG,避免某些 ffmpeg build 对 webp 支持不一致 + cmd = [FFMPEG_PATH, "-y", "-i", input_path, output_path] + _run_ffmpeg(cmd) + return output_path + if ext == ".svg": + rsvg = _which("rsvg-convert") + if rsvg: + import subprocess + subprocess.check_call([rsvg, "-o", output_path, input_path]) + return output_path + # fallback: ffmpeg decode svg(依赖 build) + cmd = [FFMPEG_PATH, "-y", "-i", input_path, output_path] + _run_ffmpeg(cmd) + return output_path + # 其他格式:尽量用 ffmpeg 转 + cmd = [FFMPEG_PATH, "-y", "-i", input_path, output_path] + _run_ffmpeg(cmd) + return output_path + + def get_audio_info(file_path: str) -> Dict[str, Any]: """获取音频信息""" return get_video_info(file_path) @@ -830,6 +1075,12 @@ def add_bgm( loop: bool = True, ducking: bool = True, duck_gain_db: float = -6.0, + # 新增:按时间段闪避(更可控,和旁白时间轴严格对齐) + duck_volume: float = 0.25, + duck_ranges: Optional[List[Tuple[float, float]]] = None, + # 新增:BGM 片段可有起点/时长(不强制从 0 覆盖整段视频) + start_time: float = 0.0, + clip_duration: Optional[float] = None, fade_in: float = 1.0, fade_out: float = 1.0 ) -> str: @@ -856,30 +1107,46 @@ def add_bgm( info = get_video_info(video_path) video_duration = info["duration"] - if loop: - bgm_chain = ( - f"[1:a]aloop=-1:size=2e+09,asetpts=N/SR/TB," - f"atrim=0:{video_duration}," - f"afade=t=in:st=0:d={fade_in}," - f"afade=t=out:st={max(video_duration - fade_out, 0)}:d={fade_out}," - f"volume={bgm_volume}[bgm]" - ) - else: - bgm_chain = ( - f"[1:a]" - f"afade=t=in:st=0:d={fade_in}," - f"afade=t=out:st={max(video_duration - fade_out, 0)}:d={fade_out}," - f"volume={bgm_volume}[bgm]" - ) + # 片段时长:默认覆盖整段视频 + dur = float(clip_duration) if (clip_duration is not None and float(clip_duration) > 0) else float(video_duration) + st = max(0.0, float(start_time or 0.0)) + end_for_fade = max(dur - float(fade_out or 0.0), 0.0) - if ducking: - # 使用安全参数的 sidechaincompress,避免 unsupported 参数 + # 基础链:loop/trim -> fades -> base volume + if loop: + bgm_chain = f"[1:a]aloop=-1:size=2e+09,asetpts=N/SR/TB,atrim=0:{dur}" + else: + bgm_chain = f"[1:a]atrim=0:{dur}" + + bgm_chain += f",afade=t=in:st=0:d={float(fade_in or 0.0)},afade=t=out:st={end_for_fade}:d={float(fade_out or 0.0)},volume={bgm_volume}" + + # 延迟到 start_time + if st > 1e-6: + ms = int(st * 1000) + bgm_chain += f",adelay={ms}|{ms}" + + # 闪避(按时间段) + # 注意:使用 enable 让 filter 只在区间内生效(外部直接 passthrough) + if ducking and duck_ranges: + dv = max(0.05, min(1.0, float(duck_volume or 0.25))) + for (rs, re) in duck_ranges: + rsf = max(0.0, float(rs)) + ref = max(rsf, float(re)) + bgm_chain += f",volume={dv}:enable='between(t,{rsf},{ref})'" + + bgm_chain += "[bgm]" + + # 如果提供了 duck_ranges,就用确定性的 amix(ducking 已在 bgm_chain 内完成) + if ducking and duck_ranges: + filter_complex = f"{bgm_chain};[0:a][bgm]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[outa]" + elif ducking: + # 否则退回 sidechaincompress(对原视频音频进行侧链压缩) filter_complex = ( f"{bgm_chain};" f"[0:a][bgm]sidechaincompress=threshold=0.1:ratio=4:attack=5:release=250:makeup=1:mix=1:level_in=1:level_sc=1[outa]" ) else: - filter_complex = f"{bgm_chain};[0:a][bgm]amix=inputs=2:duration=first[outa]" + filter_complex = f"{bgm_chain};[0:a][bgm]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[outa]" cmd = [ FFMPEG_PATH, "-y", diff --git a/modules/legacy_normalizer.py b/modules/legacy_normalizer.py index 654e04c..6ab8ee4 100644 --- a/modules/legacy_normalizer.py +++ b/modules/legacy_normalizer.py @@ -246,3 +246,9 @@ def normalize_legacy_project(doc: Dict[str, Any]) -> Dict[str, Any]: + + + + + + diff --git a/modules/legacy_path_mapper.py b/modules/legacy_path_mapper.py index d4cf839..9d7d434 100644 --- a/modules/legacy_path_mapper.py +++ b/modules/legacy_path_mapper.py @@ -19,6 +19,7 @@ from typing import Optional, Tuple LEGACY_HOST_TEMP_PREFIX = "/root/video-flow/temp/" LEGACY_HOST_OUTPUT_PREFIX = "/root/video-flow/output/" +LEGACY_HOST_PREFIX = "/root/video-flow/" # Container mount points (see docker-compose.yml) LEGACY_CONTAINER_TEMP_DIR = "/legacy/temp" @@ -42,18 +43,27 @@ def map_legacy_local_path(local_path: Optional[str]) -> Tuple[Optional[str], Opt if os.path.exists(local_path): return local_path, None - # Legacy host -> container mapping by basename + # Legacy host path -> current container workspace path (same repo but different prefix) + # Example: + # /root/video-flow/temp/projects/... -> /app/temp/projects/... + # This covers cases where we don't mount /legacy/* but the files were copied into current stack. + if local_path.startswith(LEGACY_HOST_PREFIX): + rest = local_path[len(LEGACY_HOST_PREFIX):].lstrip("/") + candidate = str(Path("/app") / rest) + if os.path.exists(candidate): + return candidate, None + + # Legacy host -> container mapping (preserve relative path) if local_path.startswith(LEGACY_HOST_TEMP_PREFIX): - name = Path(local_path).name - container_path = str(Path(LEGACY_CONTAINER_TEMP_DIR) / name) - url = f"{LEGACY_STATIC_TEMP_PREFIX}{name}" - return container_path, url + rel = local_path[len(LEGACY_HOST_TEMP_PREFIX):].lstrip("/") + container_path = str(Path(LEGACY_CONTAINER_TEMP_DIR) / rel) + # 静态路由通常只覆盖目录根(不包含子目录);这里交给 /api/assets/file 做 FileResponse 更稳 + return container_path, None if local_path.startswith(LEGACY_HOST_OUTPUT_PREFIX): - name = Path(local_path).name - container_path = str(Path(LEGACY_CONTAINER_OUTPUT_DIR) / name) - url = f"{LEGACY_STATIC_OUTPUT_PREFIX}{name}" - return container_path, url + rel = local_path[len(LEGACY_HOST_OUTPUT_PREFIX):].lstrip("/") + container_path = str(Path(LEGACY_CONTAINER_OUTPUT_DIR) / rel) + return container_path, None # Unknown path: keep as-is return local_path, None @@ -64,3 +74,9 @@ def map_legacy_local_path(local_path: Optional[str]) -> Tuple[Optional[str], Opt + + + + + + diff --git a/modules/preview_proxy.py b/modules/preview_proxy.py new file mode 100644 index 0000000..c5ba75c --- /dev/null +++ b/modules/preview_proxy.py @@ -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 + + + + + + + + + + + + diff --git a/modules/script_gen.py b/modules/script_gen.py index 1a3f018..63fff71 100644 --- a/modules/script_gen.py +++ b/modules/script_gen.py @@ -6,6 +6,7 @@ import base64 import json import logging import os +import time import requests from typing import Dict, Any, List, Optional from pathlib import Path @@ -27,7 +28,10 @@ class ScriptGenerator: # OpenAI-compatible client for ShuBiaoBiao (supports multiple models incl. GPT) self.shubiaobiao_client = OpenAI( api_key=config.SHUBIAOBIAO_KEY, - base_url=config.SHUBIAOBIAO_BASE_URL + base_url=config.SHUBIAOBIAO_BASE_URL, + # IMPORTANT: OpenAI SDK default timeout is 10 minutes; cap it to keep UX responsive. + timeout=config.SHUBIAOBIAO_CHAT_TIMEOUT_S, + max_retries=config.SHUBIAOBIAO_CHAT_MAX_RETRIES, ) # Default System Prompt @@ -139,15 +143,23 @@ class ScriptGenerator: product_name: str, product_info: Dict[str, Any], image_paths: List[str] = None, - model_provider: str = "shubiaobiao" # "shubiaobiao" or "doubao" + model_provider: str = "shubiaobiao", # "shubiaobiao" or "doubao" + user_id: Optional[str] = None, ) -> Dict[str, Any]: """ 生成分镜脚本 """ logger.info(f"Generating script for: {product_name} (Provider: {model_provider})") - # 1. 构造 Prompt (优先从数据库读取配置) - system_prompt = db.get_config("prompt_script_gen", self.default_system_prompt) + # 1. 构造 Prompt (优先按 user_id 读取;否则回退到全局配置,再回退默认) + system_prompt = None + if user_id: + try: + system_prompt = db.get_user_prompt(user_id, "prompt_script_gen") + except Exception: + system_prompt = None + if not system_prompt: + system_prompt = db.get_config("prompt_script_gen", self.default_system_prompt) user_prompt = self._build_user_prompt(product_name, product_info) # Branch for Doubao (Volcengine) @@ -293,21 +305,40 @@ class ScriptGenerator: ShuBiaoBiao OpenAI-compatible multimodal chat. IMPORTANT: For ShuBiaoBiao models, we pass image URLs (R2 public URLs), not base64. """ + t0 = time.time() + # Use WARNING level so it shows up even if Streamlit/root logger is not configured to INFO. + logger.warning( + f"[script_gen] start shubiaobiao chat model={model_name} images={len(image_paths or [])} " + f"timeout_s={getattr(config, 'SHUBIAOBIAO_CHAT_TIMEOUT_S', 'n/a')} " + f"max_retries={getattr(config, 'SHUBIAOBIAO_CHAT_MAX_RETRIES', 'n/a')}" + ) messages = [{"role": "system", "content": system_prompt}] user_content: List[Dict[str, Any]] = [] # Images first (URL), then text + t_upload0 = time.time() urls = self._upload_images_to_r2(image_paths or [], limit=10) + logger.warning( + f"[script_gen] r2_upload done urls={len(urls)} elapsed_s={time.time() - t_upload0:.2f}" + ) for url in urls: user_content.append({"type": "image_url", "image_url": {"url": url}}) user_content.append({"type": "text", "text": user_prompt}) messages.append({"role": "user", "content": user_content}) try: - resp = self.shubiaobiao_client.chat.completions.create( + client = self.shubiaobiao_client.with_options( + timeout=config.SHUBIAOBIAO_CHAT_TIMEOUT_S, + max_retries=config.SHUBIAOBIAO_CHAT_MAX_RETRIES, + ) + t_call0 = time.time() + resp = client.chat.completions.create( model=model_name, messages=messages, temperature=0.7, ) + logger.warning( + f"[script_gen] shubiaobiao chat done elapsed_s={time.time() - t_call0:.2f} total_s={time.time() - t0:.2f}" + ) content_text = (resp.choices[0].message.content or "").strip() script_json = self._extract_json_from_response(content_text) if script_json is None: @@ -323,7 +354,9 @@ class ScriptGenerator: } return final_script except Exception as e: - logger.error(f"shubiaobiao script generation failed ({model_name}): {e}") + logger.error( + f"shubiaobiao script generation failed ({model_name}) after {time.time() - t0:.2f}s: {e}" + ) return None def _postprocess_selling_points(self, product_info: Dict[str, Any], selling_points: Any) -> List[str]: @@ -582,7 +615,33 @@ class ScriptGenerator: def _validate_and_fix_script(self, script: Dict[str, Any]) -> Dict[str, Any]: """校验并修复脚本结构""" - # 简单校验,确保必要字段存在 - if "scenes" not in script: + if not isinstance(script, dict): + return {"scenes": []} + + # Ensure fields exist + if "scenes" not in script or not isinstance(script.get("scenes"), list): script["scenes"] = [] + + # Normalize: keep visual_anchor at top-level, but avoid repeating the full anchor in every scene.visual_prompt. + # Reason: repeating a long anchor 4-5 times explodes tokens and makes UI look like "only three sections", + # while image generation already supports passing visual_anchor separately and prepending it at runtime. + visual_anchor = script.get("visual_anchor") or "" + if isinstance(visual_anchor, str) and visual_anchor.strip() and script["scenes"]: + anchor = visual_anchor.strip() + prefix = f"[{anchor}]" + for scene in script["scenes"]: + if not isinstance(scene, dict): + continue + vp = scene.get("visual_prompt") + if not isinstance(vp, str) or not vp.strip(): + continue + s = vp.strip() + # Strip exact "[anchor]" prefix if present + if s.startswith(prefix): + s = s[len(prefix):].lstrip() + # If the model output copied the raw anchor without brackets, strip it too + elif s.startswith(anchor): + s = s[len(anchor):].lstrip() + scene["visual_prompt"] = s + return script diff --git a/modules/text_renderer.py b/modules/text_renderer.py index 3b34421..06d8bed 100644 --- a/modules/text_renderer.py +++ b/modules/text_renderer.py @@ -90,6 +90,79 @@ class TextRenderer: return color return (0, 0, 0, 255) + def _wrap_text_to_width(self, text: str, font: ImageFont.FreeTypeFont, max_width: int) -> str: + """ + 将文本按最大宽度自动换行(支持中英文混排)。 + - 保留原始换行符为段落边界 + - 英文优先按空格断词;中文按字符贪心换行 + """ + try: + mw = int(max_width or 0) + except Exception: + mw = 0 + if mw <= 0: + return text + + # 兼容:去掉末尾多余空行 + raw_paras = (text or "").split("\n") + out_lines: List[str] = [] + + # 1x1 dummy draw 用于测量 + dummy_draw = ImageDraw.Draw(Image.new("RGBA", (1, 1))) + + def text_w(s: str) -> float: + try: + return float(dummy_draw.textlength(s, font=font)) + except Exception: + bbox = dummy_draw.textbbox((0, 0), s, font=font) + return float((bbox[2] - bbox[0]) if bbox else 0) + + for para in raw_paras: + p = (para or "").rstrip() + if not p: + out_lines.append("") + continue + + # 英文/混排:尝试按空格分词,否则按字符 + use_words = (" " in p) + tokens = p.split(" ") if use_words else list(p) + + cur = "" + for tok in tokens: + cand = (cur + (" " if (use_words and cur) else "") + tok) if cur else tok + if text_w(cand) <= mw: + cur = cand + continue + + # 当前行放不下:先落一行 + if cur: + out_lines.append(cur) + cur = tok + else: + # 单 token 超宽:强制按字符拆 + if use_words: + chars = list(tok) + else: + chars = [tok] + buf = "" + for ch in chars: + cand2 = buf + ch + if text_w(cand2) <= mw or not buf: + buf = cand2 + else: + out_lines.append(buf) + buf = ch + cur = buf + + if cur: + out_lines.append(cur) + + # 去掉尾部空行(保持中间空行) + while out_lines and out_lines[-1] == "": + out_lines.pop() + + return "\n".join(out_lines) + def render(self, text: str, style: Union[Dict[str, Any], str], cache: bool = True) -> str: """ 渲染文本并返回图片路径 @@ -122,14 +195,29 @@ class TextRenderer: font_size = style.get("font_size", 60) font = self._get_font(font_path, font_size) font_color = self._parse_color(style.get("font_color", "#FFFFFF")) + bold = bool(style.get("bold", False)) + italic = bool(style.get("italic", False)) + underline = bool(style.get("underline", False)) - # 3. 测量文本尺寸 + # 3. 自动换行(可选) + max_width = style.get("max_width") or style.get("maxWidth") or style.get("text_box_width") + try: + max_width = int(max_width) if max_width is not None else 0 + except Exception: + max_width = 0 + if max_width > 0: + text = self._wrap_text_to_width(text, font, max_width) + + # 4. 测量文本尺寸(支持多行) dummy_draw = ImageDraw.Draw(Image.new("RGBA", (1, 1))) - bbox = dummy_draw.textbbox((0, 0), text, font=font) - text_w = bbox[2] - bbox[0] - text_h = bbox[3] - bbox[1] + try: + bbox = dummy_draw.multiline_textbbox((0, 0), text, font=font, spacing=int(font_size * 0.25), align="center") + except Exception: + bbox = dummy_draw.textbbox((0, 0), text, font=font) + text_w = (bbox[2] - bbox[0]) if bbox else 0 + text_h = (bbox[3] - bbox[1]) if bbox else 0 - # 4. 计算总尺寸 (包含 padding, stroke, shadow) + # 5. 计算总尺寸 (包含 padding, stroke, shadow) strokes = style.get("stroke", []) if isinstance(strokes, dict): strokes = [strokes] # 兼容旧格式 @@ -156,7 +244,7 @@ class TextRenderer: canvas_w = content_w + extra_margin * 2 canvas_h = content_h + extra_margin * 2 - # 5. 创建画布 + # 6. 创建画布 img = Image.new("RGBA", (int(canvas_w), int(canvas_h)), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) @@ -164,7 +252,7 @@ class TextRenderer: center_x = canvas_w // 2 center_y = canvas_h // 2 - # 6. 绘制顺序: 阴影 -> 背景 -> 描边 -> 文本 + # 7. 绘制顺序: 阴影 -> 背景 -> 描边 -> 文本 # --- 绘制阴影 (针对整个块) --- if shadow: @@ -183,7 +271,11 @@ class TextRenderer: # 文字阴影 txt_x = center_x - text_w / 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: width = s.get("width", 0) @@ -217,10 +309,55 @@ class TextRenderer: width = s.get("width", 0) if width > 0: # 通过偏移模拟描边 (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. 裁剪多余透明区域 bbox = img.getbbox() diff --git a/requirements.txt b/requirements.txt index 4404880..33d2330 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,6 +23,7 @@ numpy>=1.24.0 # Web UI (Streamlit - 保留原有调试界面) streamlit>=1.29.0 +extra-streamlit-components>=0.1.71 # FastAPI Backend (新增前后端分离) fastapi>=0.109.0 diff --git a/scripts/import_stickers_manifest.py b/scripts/import_stickers_manifest.py new file mode 100644 index 0000000..6e8ba0a --- /dev/null +++ b/scripts/import_stickers_manifest.py @@ -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()) + + + diff --git a/scripts/migrate_projects.py b/scripts/migrate_projects.py new file mode 100644 index 0000000..e3a77a1 --- /dev/null +++ b/scripts/migrate_projects.py @@ -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) diff --git a/scripts/migrate_users_and_owner.py b/scripts/migrate_users_and_owner.py new file mode 100644 index 0000000..b9585bd --- /dev/null +++ b/scripts/migrate_users_and_owner.py @@ -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() + + diff --git a/scripts/scan_legacy_schema.py b/scripts/scan_legacy_schema.py new file mode 100644 index 0000000..8c913c9 --- /dev/null +++ b/scripts/scan_legacy_schema.py @@ -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()) + + + + + + + + + + + + + diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..5382eb3 --- /dev/null +++ b/web/Dockerfile @@ -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;"] + + + + + + + + + + + + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..0a86ed0 --- /dev/null +++ b/web/index.html @@ -0,0 +1,30 @@ + + + + + + + Video Flow Editor + + + + + +
+ + + + + + + + + + + + + + + + + diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..f7785bb --- /dev/null +++ b/web/nginx.conf @@ -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"; + } +} + + + + + + + + + + + + + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..689eaea --- /dev/null +++ b/web/package.json @@ -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" + } +} + diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..2edac0a --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,20 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + + + + + + + + + + + + + + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..310ab3b --- /dev/null +++ b/web/src/App.tsx @@ -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 ( + + + } /> + } /> + } /> + + + ) +} + +export default App + + + + + + + + + + + + + + diff --git a/web/src/ClipItem.tsx b/web/src/ClipItem.tsx new file mode 100644 index 0000000..142a5a4 --- /dev/null +++ b/web/src/ClipItem.tsx @@ -0,0 +1,6 @@ +// Back-compat re-export +export { ClipItem } from './components/Timeline/ClipItem' +export { default } from './components/Timeline/ClipItem' + + + diff --git a/web/src/EditorPage.tsx b/web/src/EditorPage.tsx new file mode 100644 index 0000000..eec1e7e --- /dev/null +++ b/web/src/EditorPage.tsx @@ -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' + + + diff --git a/web/src/Timeline.tsx b/web/src/Timeline.tsx new file mode 100644 index 0000000..2de3ffd --- /dev/null +++ b/web/src/Timeline.tsx @@ -0,0 +1,6 @@ +// Back-compat re-export +export { Timeline } from './components/Timeline/Timeline' +export { default } from './components/Timeline/Timeline' + + + diff --git a/web/src/TrackPanel.tsx b/web/src/TrackPanel.tsx new file mode 100644 index 0000000..1e546c1 --- /dev/null +++ b/web/src/TrackPanel.tsx @@ -0,0 +1,6 @@ +// Back-compat re-export +export { TrackPanel } from './components/Timeline/TrackPanel' +export { default } from './components/Timeline/TrackPanel' + + + diff --git a/web/src/TrackRow.tsx b/web/src/TrackRow.tsx new file mode 100644 index 0000000..5dfbaa6 --- /dev/null +++ b/web/src/TrackRow.tsx @@ -0,0 +1,6 @@ +// Back-compat re-export +export { TrackRow } from './components/Timeline/TrackRow' +export { default } from './components/Timeline/TrackRow' + + + diff --git a/web/src/VideoComposition.tsx b/web/src/VideoComposition.tsx new file mode 100644 index 0000000..49a2318 --- /dev/null +++ b/web/src/VideoComposition.tsx @@ -0,0 +1,6 @@ +// Back-compat re-export +export { VideoComposition } from './remotion/VideoComposition' +export { default } from './remotion/VideoComposition' + + + diff --git a/web/src/components/Timeline/ClipItem.tsx b/web/src/components/Timeline/ClipItem.tsx new file mode 100644 index 0000000..3ea7283 --- /dev/null +++ b/web/src/components/Timeline/ClipItem.tsx @@ -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 = ({ + clip, + trackType, + pixelsPerSecond, + trackHeight, + isSelected, + isLocked, + onSelect, + onDrag, + onResize, + onCommit, +}) => { + const clipRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) + const [isResizing, setIsResizing] = useState<'start' | 'end' | null>(null) + const [thumbUrl, setThumbUrl] = useState(null) + const [audioPeaks, setAudioPeaks] = useState(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 | 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((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((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() + 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 | 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() + 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 = { + fade: '淡出', + fadeWhite: '淡到白', + flash: '闪白', + zoomOut: '缩小', + zoomIn: '推进', + slideLeft: '左滑', + slideRight: '右滑', + slideUp: '上滑', + slideDown: '下滑', + rotateOut: '旋转', + blurOut: '模糊', + blurFade: '模糊淡出', + desaturate: '去饱和', + colorPop: '色彩增强', + hueShift: '色相偏移', + darken: '变暗', + } + const name = map[t] || t + return ( +
+ {name} +
+ ) + })() + + return ( +
+ {transitionBadge} + + {/* 左边调整手柄 */} +
handleResizeStart(e, 'start')} + /> + + {/* 内容 */} +
+ + {getClipContent()} + +
+ + {/* 音频波形(MVP) */} + {(trackType === 'audio' || trackType === 'bgm') && audioPeaks && ( +
+ + {audioPeaks.map((p, i) => { + const h = Math.max(1, Math.round(p * 18)) + const y = 10 - h / 2 + return + })} + +
+ )} + + {/* 右边调整手柄 */} +
handleResizeStart(e, 'end')} + /> +
+ ) +} + +export default ClipItem + diff --git a/web/src/components/Timeline/Playhead.tsx b/web/src/components/Timeline/Playhead.tsx new file mode 100644 index 0000000..eaee5cf --- /dev/null +++ b/web/src/components/Timeline/Playhead.tsx @@ -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) => void +} + +export const Playhead: React.FC = ({ + currentTime, + pixelsPerSecond, + height, + onPointerDown, +}) => { + const left = timeToPixels(currentTime, pixelsPerSecond) + + return ( +
+ {/* 增大命中区域,提升“拖拽跟手” */} +
+
+ ) +} + +export default Playhead + + + + + + + + + + + + + + diff --git a/web/src/components/Timeline/TimeRuler.tsx b/web/src/components/Timeline/TimeRuler.tsx new file mode 100644 index 0000000..3a2f421 --- /dev/null +++ b/web/src/components/Timeline/TimeRuler.tsx @@ -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 = ({ + 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 ( +
+ {ticks.map(({ time, isMajor }) => { + const x = time * pixelsPerSecond + + return ( +
+ {/* 刻度线 */} +
+ + {/* 时间标签 */} + {isMajor && ( + + {formatTimeShort(time)} + + )} +
+ ) + })} +
+ ) +} + +export default TimeRuler + diff --git a/web/src/components/Timeline/Timeline.tsx b/web/src/components/Timeline/Timeline.tsx new file mode 100644 index 0000000..da691ab --- /dev/null +++ b/web/src/components/Timeline/Timeline.tsx @@ -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(null) + const scrollContainerRef = useRef(null) + const contentRef = useRef(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) + const dragRafRef = useRef(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) => { + 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 ( +
+ {/* 时间标尺 */} +
+
+ {/* 轨道标签占位 */} +
+ + {/* 标尺 */} +
+ + + {/* 播放头顶部标记 */} +
+
+
+ + {/* 红线附近快捷操作:剪切/删除(不跟着素材 hover 走) */} +
e.stopPropagation()} + > + + +
+
+
+
+ + {/* 轨道区域 */} +
+
+ {/* 轨道标签 */} +
+ {visibleTracks.map(track => ( +
+
+ {track.name} +
+ ))} +
+ + {/* 轨道内容 */} +
{ e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }} + onDrop={handleDropAsset} + > + {visibleTracks.map((track, index) => ( + pushHistory()} + /> + ))} + + {/* 播放头线 */} + + + {/* 吸附/对齐辅助线 */} + {typeof snapGuideTime === 'number' && ( +
+ )} + + {/* 框选矩形 */} + {boxSel && ( +
+ )} +
+
+
+
+ ) +} + +export default Timeline + diff --git a/web/src/components/Timeline/TrackPanel.tsx b/web/src/components/Timeline/TrackPanel.tsx new file mode 100644 index 0000000..c6fe891 --- /dev/null +++ b/web/src/components/Timeline/TrackPanel.tsx @@ -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 }) => + 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) => { + 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 ( +
+ {/* 轨道列表 */} +
+

轨道

+

+ 剪切:移动红线到想切的位置,点“剪切”或按 S。拖动片段会自动把后面的片段推开。 +

+
+ {tracks.map(track => ( +
+
+ + {track.name} +
+
+ {showAdvanced && ( + + )} + + +
+
+ ))} +
+ +
+ + {/* 选中片段属性 */} + {selectedClip && ( +
+

片段

+ +
+ {/* 基础时间(新手友好) */} +
+
时间(M:SS)
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ 提示:更常用的是直接在时间轴拖动片段和边缘。 +
+
+ + {/* 高级:精确时间/裁剪(秒) */} + {showAdvanced && ( +
+
精确调整(秒)
+
+
+ + 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')} + /> +
+
+ + 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')} + /> +
+
+ {selectedClip.trackType === 'video' && ( +
+
+ + 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')} + /> +
+
+ + 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')} + /> +
+
+ )} + +
+ )} + + {/* 文本编辑 */} + {selectedClip.text !== undefined && ( +
+ + {(selectedClip.trackType === 'subtitle' || selectedClip.trackType === 'fancy_text') && !isEditing && ( +
+ 建议:在画面上双击文字直接编辑(更像抖音/剪映)。这里只保留为“高级兜底”。 +
+ )} + {isEditing ? ( +
+