diff --git a/backend/app/api/assets.py b/backend/app/api/assets.py new file mode 100644 index 0000000..e533cb9 --- /dev/null +++ b/backend/app/api/assets.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends + +from app.core.deps import get_current_user +from app.services.assets_service import list_styles, list_bgm + + +router = APIRouter() + + +@router.get("/subtitle-styles") +async def list_subtitle_styles(current_user: dict = Depends(get_current_user)): + return {"styles": list_styles("subtitle")} + + +@router.get("/title-styles") +async def list_title_styles(current_user: dict = Depends(get_current_user)): + return {"styles": list_styles("title")} + + +@router.get("/bgm") +async def list_bgm_items(current_user: dict = Depends(get_current_user)): + return {"bgm": list_bgm()} diff --git a/backend/app/api/videos.py b/backend/app/api/videos.py index a6b67a6..2f9eba2 100644 --- a/backend/app/api/videos.py +++ b/backend/app/api/videos.py @@ -8,13 +8,19 @@ import traceback import time import httpx import os -from app.services.tts_service import TTSService -from app.services.video_service import VideoService -from app.services.lipsync_service import LipSyncService -from app.services.voice_clone_service import voice_clone_service -from app.services.storage import storage_service -from app.services.whisper_service import whisper_service -from app.services.remotion_service import remotion_service +from app.services.tts_service import TTSService +from app.services.video_service import VideoService +from app.services.lipsync_service import LipSyncService +from app.services.voice_clone_service import voice_clone_service +from app.services.assets_service import ( + get_style, + get_default_style, + resolve_bgm_path, + prepare_style_for_remotion, +) +from app.services.storage import storage_service +from app.services.whisper_service import whisper_service +from app.services.remotion_service import remotion_service from app.core.config import settings from app.core.deps import get_current_user @@ -28,9 +34,15 @@ class GenerateRequest(BaseModel): tts_mode: str = "edgetts" # "edgetts" | "voiceclone" ref_audio_id: Optional[str] = None # 参考音频 storage path ref_text: Optional[str] = None # 参考音频的转写文字 - # 字幕和标题功能 - title: Optional[str] = None # 视频标题(片头显示) - enable_subtitles: bool = True # 是否启用逐字高亮字幕 + # 字幕和标题功能 + title: Optional[str] = None # 视频标题(片头显示) + enable_subtitles: bool = True # 是否启用逐字高亮字幕 + subtitle_style_id: Optional[str] = None # 字幕样式 ID + title_style_id: Optional[str] = None # 标题样式 ID + subtitle_font_size: Optional[int] = None # 字幕字号(覆盖样式) + title_font_size: Optional[int] = None # 标题字号(覆盖样式) + bgm_id: Optional[str] = None # 背景音乐 ID + bgm_volume: Optional[float] = 0.2 # 背景音乐音量 (0-1) tasks = {} # In-memory task store @@ -52,15 +64,15 @@ async def _check_lipsync_ready(force: bool = False) -> bool: now = time.time() # 5分钟缓存 - if not force and _lipsync_ready is not None and (now - _lipsync_last_check) < 300: - return _lipsync_ready + if not force and _lipsync_ready is not None and (now - _lipsync_last_check) < 300: + return bool(_lipsync_ready) lipsync = _get_lipsync_service() health = await lipsync.check_health() _lipsync_ready = health.get("ready", False) _lipsync_last_check = now print(f"[LipSync] Health check: ready={_lipsync_ready}") - return _lipsync_ready + return bool(_lipsync_ready) async def _download_material(path_or_url: str, temp_path: Path): """下载素材到临时文件 (流式下载,节省内存)""" @@ -194,25 +206,79 @@ async def _process_video_generation(task_id: str, req: GenerateRequest, user_id: logger.warning(f"Whisper alignment failed, skipping subtitles: {e}") captions_path = None - tasks[task_id]["progress"] = 85 + tasks[task_id]["progress"] = 85 + + # 3.5 背景音乐混音(不影响唇形与字幕对齐) + video = VideoService() + final_audio_path = audio_path + if req.bgm_id: + tasks[task_id]["message"] = "正在合成背景音乐..." + tasks[task_id]["progress"] = 86 + + bgm_path = resolve_bgm_path(req.bgm_id) + if bgm_path: + mix_output_path = temp_dir / f"{task_id}_audio_mix.wav" + temp_files.append(mix_output_path) + volume = req.bgm_volume if req.bgm_volume is not None else 0.2 + volume = max(0.0, min(float(volume), 1.0)) + try: + video.mix_audio( + voice_path=str(audio_path), + bgm_path=str(bgm_path), + output_path=str(mix_output_path), + bgm_volume=volume + ) + final_audio_path = mix_output_path + except Exception as e: + logger.warning(f"BGM mix failed, fallback to voice only: {e}") + else: + logger.warning(f"BGM not found: {req.bgm_id}") - # 4. Remotion 视频合成(字幕 + 标题)- 进度 85% -> 95% - # 判断是否需要使用 Remotion(有字幕或标题时使用) - use_remotion = (captions_path and captions_path.exists()) or req.title + # 4. Remotion 视频合成(字幕 + 标题)- 进度 85% -> 95% + # 判断是否需要使用 Remotion(有字幕或标题时使用) + use_remotion = (captions_path and captions_path.exists()) or req.title + + subtitle_style = None + title_style = None + if req.enable_subtitles: + subtitle_style = get_style("subtitle", req.subtitle_style_id) or get_default_style("subtitle") + if req.title: + title_style = get_style("title", req.title_style_id) or get_default_style("title") + + if req.subtitle_font_size and req.enable_subtitles: + if subtitle_style is None: + subtitle_style = {} + subtitle_style["font_size"] = int(req.subtitle_font_size) + + if req.title_font_size and req.title: + if title_style is None: + title_style = {} + title_style["font_size"] = int(req.title_font_size) + + if use_remotion: + subtitle_style = prepare_style_for_remotion( + subtitle_style, + temp_dir, + f"{task_id}_subtitle_font" + ) + title_style = prepare_style_for_remotion( + title_style, + temp_dir, + f"{task_id}_title_font" + ) final_output_local_path = temp_dir / f"{task_id}_output.mp4" temp_files.append(final_output_local_path) - if use_remotion: - tasks[task_id]["message"] = "正在合成视频 (Remotion)..." - tasks[task_id]["progress"] = 87 + if use_remotion: + tasks[task_id]["message"] = "正在合成视频 (Remotion)..." + tasks[task_id]["progress"] = 87 # 先用 FFmpeg 合成音视频(Remotion 需要带音频的视频) composed_video_path = temp_dir / f"{task_id}_composed.mp4" temp_files.append(composed_video_path) - video = VideoService() - await video.compose(str(lipsync_video_path), str(audio_path), str(composed_video_path)) + await video.compose(str(lipsync_video_path), str(final_audio_path), str(composed_video_path)) # 检查 Remotion 是否可用 remotion_health = await remotion_service.check_health() @@ -223,16 +289,18 @@ async def _process_video_generation(task_id: str, req: GenerateRequest, user_id: mapped = 87 + int(percent * 0.08) tasks[task_id]["progress"] = mapped - await remotion_service.render( - video_path=str(composed_video_path), - output_path=str(final_output_local_path), - captions_path=str(captions_path) if captions_path else None, - title=req.title, - title_duration=3.0, - fps=25, - enable_subtitles=req.enable_subtitles, - on_progress=on_remotion_progress - ) + await remotion_service.render( + video_path=str(composed_video_path), + output_path=str(final_output_local_path), + captions_path=str(captions_path) if captions_path else None, + title=req.title, + title_duration=3.0, + fps=25, + enable_subtitles=req.enable_subtitles, + subtitle_style=subtitle_style, + title_style=title_style, + on_progress=on_remotion_progress + ) print(f"[Pipeline] Remotion render completed") except Exception as e: logger.warning(f"Remotion render failed, using FFmpeg fallback: {e}") @@ -248,8 +316,7 @@ async def _process_video_generation(task_id: str, req: GenerateRequest, user_id: tasks[task_id]["message"] = "正在合成最终视频..." tasks[task_id]["progress"] = 90 - video = VideoService() - await video.compose(str(lipsync_video_path), str(audio_path), str(final_output_local_path)) + await video.compose(str(lipsync_video_path), str(final_audio_path), str(final_output_local_path)) total_time = time.time() - start_time diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b7ca06e..b26bc9a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -3,9 +3,10 @@ from pathlib import Path class Settings(BaseSettings): # 基础路径配置 - BASE_DIR: Path = Path(__file__).resolve().parent.parent - UPLOAD_DIR: Path = BASE_DIR.parent / "uploads" - OUTPUT_DIR: Path = BASE_DIR.parent / "outputs" + BASE_DIR: Path = Path(__file__).resolve().parent.parent + UPLOAD_DIR: Path = BASE_DIR.parent / "uploads" + OUTPUT_DIR: Path = BASE_DIR.parent / "outputs" + ASSETS_DIR: Path = BASE_DIR.parent / "assets" # 数据库/缓存 REDIS_URL: str = "redis://localhost:6379/0" diff --git a/backend/app/main.py b/backend/app/main.py index ec35185..f30fd3b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware from app.core import config -from app.api import materials, videos, publish, login_helper, auth, admin, ref_audios, ai, tools +from app.api import materials, videos, publish, login_helper, auth, admin, ref_audios, ai, tools, assets from loguru import logger import os @@ -41,12 +41,14 @@ app.add_middleware( ) # Create dirs -settings.UPLOAD_DIR.mkdir(parents=True, exist_ok=True) -settings.OUTPUT_DIR.mkdir(parents=True, exist_ok=True) -(settings.UPLOAD_DIR / "materials").mkdir(exist_ok=True) +settings.UPLOAD_DIR.mkdir(parents=True, exist_ok=True) +settings.OUTPUT_DIR.mkdir(parents=True, exist_ok=True) +(settings.UPLOAD_DIR / "materials").mkdir(exist_ok=True) +settings.ASSETS_DIR.mkdir(parents=True, exist_ok=True) -app.mount("/outputs", StaticFiles(directory=str(settings.OUTPUT_DIR)), name="outputs") -app.mount("/uploads", StaticFiles(directory=str(settings.UPLOAD_DIR)), name="uploads") +app.mount("/outputs", StaticFiles(directory=str(settings.OUTPUT_DIR)), name="outputs") +app.mount("/uploads", StaticFiles(directory=str(settings.UPLOAD_DIR)), name="uploads") +app.mount("/assets", StaticFiles(directory=str(settings.ASSETS_DIR)), name="assets") # 注册路由 app.include_router(materials.router, prefix="/api/materials", tags=["Materials"]) @@ -55,9 +57,10 @@ app.include_router(publish.router, prefix="/api/publish", tags=["Publish"]) app.include_router(login_helper.router, prefix="/api", tags=["LoginHelper"]) app.include_router(auth.router) # /api/auth app.include_router(admin.router) # /api/admin -app.include_router(ref_audios.router, prefix="/api/ref-audios", tags=["RefAudios"]) -app.include_router(ai.router) # /api/ai -app.include_router(tools.router, prefix="/api/tools", tags=["Tools"]) +app.include_router(ref_audios.router, prefix="/api/ref-audios", tags=["RefAudios"]) +app.include_router(ai.router) # /api/ai +app.include_router(tools.router, prefix="/api/tools", tags=["Tools"]) +app.include_router(assets.router, prefix="/api/assets", tags=["Assets"]) @app.on_event("startup") diff --git a/backend/app/services/assets_service.py b/backend/app/services/assets_service.py new file mode 100644 index 0000000..d96db5b --- /dev/null +++ b/backend/app/services/assets_service.py @@ -0,0 +1,128 @@ +import json +import shutil +from pathlib import Path +from typing import Optional, List, Dict, Any + +from loguru import logger + +from app.core.config import settings + + +BGM_EXTENSIONS = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".webm"} + + +def _style_file_path(style_type: str) -> Path: + return settings.ASSETS_DIR / "styles" / f"{style_type}.json" + + +def _load_style_file(style_type: str) -> List[Dict[str, Any]]: + style_path = _style_file_path(style_type) + if not style_path.exists(): + return [] + try: + with open(style_path, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + return data + except Exception as e: + logger.error(f"Failed to load style file {style_path}: {e}") + return [] + + +def list_styles(style_type: str) -> List[Dict[str, Any]]: + return _load_style_file(style_type) + + +def get_style(style_type: str, style_id: Optional[str]) -> Optional[Dict[str, Any]]: + if not style_id: + return None + for item in _load_style_file(style_type): + if item.get("id") == style_id: + return item + return None + + +def get_default_style(style_type: str) -> Optional[Dict[str, Any]]: + styles = _load_style_file(style_type) + if not styles: + return None + for item in styles: + if item.get("is_default"): + return item + return styles[0] + + +def list_bgm() -> List[Dict[str, Any]]: + bgm_root = settings.ASSETS_DIR / "bgm" + if not bgm_root.exists(): + return [] + + items: List[Dict[str, Any]] = [] + for path in bgm_root.rglob("*"): + if not path.is_file(): + continue + if path.suffix.lower() not in BGM_EXTENSIONS: + continue + rel = path.relative_to(bgm_root).as_posix() + items.append({ + "id": rel, + "name": path.stem, + "ext": path.suffix.lower().lstrip(".") + }) + + items.sort(key=lambda x: x.get("name", "")) + return items + + +def resolve_bgm_path(bgm_id: str) -> Optional[Path]: + if not bgm_id: + return None + bgm_root = settings.ASSETS_DIR / "bgm" + candidate = (bgm_root / bgm_id).resolve() + try: + candidate.relative_to(bgm_root.resolve()) + except ValueError: + return None + if candidate.exists() and candidate.is_file(): + return candidate + return None + + +def prepare_style_for_remotion( + style: Optional[Dict[str, Any]], + temp_dir: Path, + prefix: str +) -> Optional[Dict[str, Any]]: + if not style: + return None + + prepared = dict(style) + font_file = prepared.get("font_file") + if not font_file: + return prepared + + source_font = (settings.ASSETS_DIR / "fonts" / font_file).resolve() + try: + source_font.relative_to((settings.ASSETS_DIR / "fonts").resolve()) + except ValueError: + logger.warning(f"Font path outside assets: {font_file}") + return prepared + + if not source_font.exists(): + logger.warning(f"Font file missing: {source_font}") + return prepared + + temp_dir.mkdir(parents=True, exist_ok=True) + ext = source_font.suffix.lower() + target_name = f"{prefix}{ext}" + target_path = temp_dir / target_name + + try: + shutil.copy(source_font, target_path) + prepared["font_file"] = target_name + if not prepared.get("font_family"): + prepared["font_family"] = prefix + except Exception as e: + logger.warning(f"Failed to copy font {source_font} -> {target_path}: {e}") + + return prepared diff --git a/backend/app/services/remotion_service.py b/backend/app/services/remotion_service.py index 43787f6..ca1462a 100644 --- a/backend/app/services/remotion_service.py +++ b/backend/app/services/remotion_service.py @@ -4,6 +4,7 @@ Remotion 视频渲染服务 """ import asyncio +import json import subprocess from pathlib import Path from typing import Optional @@ -30,6 +31,8 @@ class RemotionService: title_duration: float = 3.0, fps: int = 25, enable_subtitles: bool = True, + subtitle_style: Optional[dict] = None, + title_style: Optional[dict] = None, on_progress: Optional[callable] = None ) -> str: """ @@ -64,6 +67,12 @@ class RemotionService: cmd.extend(["--title", title]) cmd.extend(["--titleDuration", str(title_duration)]) + if subtitle_style: + cmd.extend(["--subtitleStyle", json.dumps(subtitle_style, ensure_ascii=False)]) + + if title_style: + cmd.extend(["--titleStyle", json.dumps(title_style, ensure_ascii=False)]) + logger.info(f"Running Remotion render: {' '.join(cmd)}") # 在线程池中运行子进程 diff --git a/backend/app/services/video_service.py b/backend/app/services/video_service.py index 8a6981f..5b43645 100644 --- a/backend/app/services/video_service.py +++ b/backend/app/services/video_service.py @@ -1,9 +1,10 @@ """ 视频合成服务 """ -import os -import subprocess -import json +import os +import subprocess +import json +import shlex from pathlib import Path from loguru import logger from typing import Optional @@ -12,18 +13,18 @@ class VideoService: def __init__(self): pass - def _run_ffmpeg(self, cmd: list) -> bool: - cmd_str = ' '.join(f'"{c}"' if ' ' in c or '\\' in c else c for c in cmd) - logger.debug(f"FFmpeg CMD: {cmd_str}") - try: - # Synchronous call for BackgroundTasks compatibility - result = subprocess.run( - cmd_str, - shell=True, - capture_output=True, - text=True, - encoding='utf-8', - ) + def _run_ffmpeg(self, cmd: list) -> bool: + cmd_str = ' '.join(shlex.quote(str(c)) for c in cmd) + logger.debug(f"FFmpeg CMD: {cmd_str}") + try: + # Synchronous call for BackgroundTasks compatibility + result = subprocess.run( + cmd, + shell=False, + capture_output=True, + text=True, + encoding='utf-8', + ) if result.returncode != 0: logger.error(f"FFmpeg Error: {result.stderr}") return False @@ -32,9 +33,9 @@ class VideoService: logger.error(f"FFmpeg Exception: {e}") return False - def _get_duration(self, file_path: str) -> float: - # Synchronous call for BackgroundTasks compatibility - cmd = f'ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "{file_path}"' + def _get_duration(self, file_path: str) -> float: + # Synchronous call for BackgroundTasks compatibility + cmd = f'ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "{file_path}"' try: result = subprocess.run( cmd, @@ -44,7 +45,39 @@ class VideoService: ) return float(result.stdout.strip()) except Exception: - return 0.0 + return 0.0 + + def mix_audio( + self, + voice_path: str, + bgm_path: str, + output_path: str, + bgm_volume: float = 0.2 + ) -> str: + """混合人声与背景音乐""" + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + + volume = max(0.0, min(float(bgm_volume), 1.0)) + filter_complex = ( + f"[0:a]volume=1.0[a0];" + f"[1:a]volume={volume}[a1];" + f"[a0][a1]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[aout]" + ) + + cmd = [ + "ffmpeg", "-y", + "-i", voice_path, + "-stream_loop", "-1", "-i", bgm_path, + "-filter_complex", filter_complex, + "-map", "[aout]", + "-c:a", "pcm_s16le", + "-shortest", + output_path, + ] + + if self._run_ffmpeg(cmd): + return output_path + raise RuntimeError("FFmpeg audio mix failed") async def compose( self, diff --git a/backend/assets/styles/subtitle.json b/backend/assets/styles/subtitle.json new file mode 100644 index 0000000..dfcb52e --- /dev/null +++ b/backend/assets/styles/subtitle.json @@ -0,0 +1,58 @@ +[ + { + "id": "subtitle_classic_yellow", + "label": "经典黄字", + "font_file": "title/思源黑体/SourceHanSansCN-Bold思源黑体免费.otf", + "font_family": "SourceHanSansCN-Bold", + "font_size": 52, + "highlight_color": "#FFE600", + "normal_color": "#FFFFFF", + "stroke_color": "#000000", + "stroke_size": 3, + "letter_spacing": 2, + "bottom_margin": 80, + "is_default": true + }, + { + "id": "subtitle_cyan", + "label": "清爽青蓝", + "font_file": "DingTalk Sans.ttf", + "font_family": "DingTalkSans", + "font_size": 48, + "highlight_color": "#00E5FF", + "normal_color": "#FFFFFF", + "stroke_color": "#000000", + "stroke_size": 3, + "letter_spacing": 1, + "bottom_margin": 76, + "is_default": false + }, + { + "id": "subtitle_orange", + "label": "活力橙", + "font_file": "simhei.ttf", + "font_family": "SimHei", + "font_size": 50, + "highlight_color": "#FF8A00", + "normal_color": "#FFFFFF", + "stroke_color": "#000000", + "stroke_size": 3, + "letter_spacing": 2, + "bottom_margin": 80, + "is_default": false + }, + { + "id": "subtitle_clean_white", + "label": "纯白轻描", + "font_file": "DingTalk JinBuTi.ttf", + "font_family": "DingTalkJinBuTi", + "font_size": 46, + "highlight_color": "#FFFFFF", + "normal_color": "#FFFFFF", + "stroke_color": "#111111", + "stroke_size": 2, + "letter_spacing": 1, + "bottom_margin": 72, + "is_default": false + } +] diff --git a/backend/assets/styles/title.json b/backend/assets/styles/title.json new file mode 100644 index 0000000..3f8a6d8 --- /dev/null +++ b/backend/assets/styles/title.json @@ -0,0 +1,58 @@ +[ + { + "id": "title_bold_white", + "label": "黑体大标题", + "font_file": "title/思源黑体/SourceHanSansCN-Heavy思源黑体免费.otf", + "font_family": "SourceHanSansCN-Heavy", + "font_size": 72, + "color": "#FFFFFF", + "stroke_color": "#000000", + "stroke_size": 8, + "letter_spacing": 4, + "top_margin": 60, + "font_weight": 900, + "is_default": true + }, + { + "id": "title_serif_gold", + "label": "宋体金色", + "font_file": "title/思源宋体/SourceHanSerifCN-SemiBold思源宋体免费.otf", + "font_family": "SourceHanSerifCN-SemiBold", + "font_size": 70, + "color": "#FDE68A", + "stroke_color": "#2B1B00", + "stroke_size": 8, + "letter_spacing": 3, + "top_margin": 58, + "font_weight": 800, + "is_default": false + }, + { + "id": "title_douyin", + "label": "抖音活力", + "font_file": "title/抖音美好体开源.otf", + "font_family": "DouyinMeiHao", + "font_size": 72, + "color": "#FFFFFF", + "stroke_color": "#1F0A00", + "stroke_size": 8, + "letter_spacing": 4, + "top_margin": 60, + "font_weight": 900, + "is_default": false + }, + { + "id": "title_pop", + "label": "站酷快乐体", + "font_file": "title/站酷快乐体.ttf", + "font_family": "ZCoolHappy", + "font_size": 74, + "color": "#FFFFFF", + "stroke_color": "#000000", + "stroke_size": 8, + "letter_spacing": 5, + "top_margin": 62, + "font_weight": 900, + "is_default": false + } +] diff --git a/frontend/next.config.ts b/frontend/next.config.ts index a572b5d..6ad4f96 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -16,6 +16,10 @@ const nextConfig: NextConfig = { source: '/outputs/:path*', destination: 'http://localhost:8006/outputs/:path*', // 转发生成的视频 }, + { + source: '/assets/:path*', + destination: 'http://localhost:8006/assets/:path*', // 转发静态资源(字体/音乐) + }, ]; }, }; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 2109dc3..2027880 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -10,7 +10,9 @@ import AccountSettingsDropdown from "@/components/AccountSettingsDropdown"; import VideoPreviewModal from "@/components/VideoPreviewModal"; import ScriptExtractionModal from "@/components/ScriptExtractionModal"; -const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8006'; +const API_BASE = typeof window === 'undefined' + ? (process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8006') + : ''; const isAbsoluteUrl = (url: string) => /^https?:\/\//i.test(url); @@ -26,6 +28,52 @@ const resolveMediaUrl = (url?: string | null) => { return joinBaseUrl(API_BASE, url); }; +const encodePathSegments = (value: string) => value.split('/').map(encodeURIComponent).join('/'); + +const resolveAssetUrl = (assetPath?: string | null) => { + if (!assetPath) return null; + const encoded = encodePathSegments(assetPath); + return joinBaseUrl(API_BASE, `/assets/${encoded}`); +}; + +const resolveBgmUrl = (bgmId?: string | null) => { + if (!bgmId) return null; + return resolveAssetUrl(`bgm/${bgmId}`); +}; + +const getFontFormat = (fontFile?: string) => { + if (!fontFile) return 'truetype'; + const ext = fontFile.split('.').pop()?.toLowerCase(); + if (ext === 'otf') return 'opentype'; + return 'truetype'; +}; + +const buildTextShadow = (color: string, size: number) => { + return [ + `-${size}px -${size}px 0 ${color}`, + `${size}px -${size}px 0 ${color}`, + `-${size}px ${size}px 0 ${color}`, + `${size}px ${size}px 0 ${color}`, + `0 0 ${size * 4}px rgba(0,0,0,0.9)`, + `0 4px 8px rgba(0,0,0,0.6)` + ].join(','); +}; + +const scrollContainerToItem = (container: HTMLDivElement, item: HTMLDivElement) => { + const containerRect = container.getBoundingClientRect(); + const itemRect = item.getBoundingClientRect(); + const itemTop = itemRect.top - containerRect.top + container.scrollTop; + const itemBottom = itemTop + itemRect.height; + const viewTop = container.scrollTop; + const viewBottom = viewTop + container.clientHeight; + + if (itemTop < viewTop) { + container.scrollTo({ top: Math.max(itemTop - 8, 0), behavior: 'smooth' }); + } else if (itemBottom > viewBottom) { + container.scrollTo({ top: itemBottom - container.clientHeight + 8, behavior: 'smooth' }); + } +}; + // 类型定义 interface Material { id: string; @@ -60,6 +108,42 @@ interface RefAudio { created_at: number; } +interface SubtitleStyleOption { + id: string; + label: string; + font_family?: string; + font_file?: string; + font_size?: number; + highlight_color?: string; + normal_color?: string; + stroke_color?: string; + stroke_size?: number; + letter_spacing?: number; + bottom_margin?: number; + is_default?: boolean; +} + +interface TitleStyleOption { + id: string; + label: string; + font_family?: string; + font_file?: string; + font_size?: number; + color?: string; + stroke_color?: string; + stroke_size?: number; + letter_spacing?: number; + font_weight?: number; + top_margin?: number; + is_default?: boolean; +} + +interface BgmItem { + id: string; + name: string; + ext?: string; +} + // 格式化日期(避免 Hydration 错误) const formatDate = (timestamp: number) => { const d = new Date(timestamp * 1000); @@ -98,6 +182,24 @@ export default function Home() { // 字幕和标题相关状态 const [videoTitle, setVideoTitle] = useState(""); const [enableSubtitles, setEnableSubtitles] = useState(true); + const [subtitleStyles, setSubtitleStyles] = useState([]); + const [titleStyles, setTitleStyles] = useState([]); + const [selectedSubtitleStyleId, setSelectedSubtitleStyleId] = useState(""); + const [selectedTitleStyleId, setSelectedTitleStyleId] = useState(""); + const [subtitleFontSize, setSubtitleFontSize] = useState(52); + const [titleFontSize, setTitleFontSize] = useState(72); + const [subtitleSizeLocked, setSubtitleSizeLocked] = useState(false); + const [titleSizeLocked, setTitleSizeLocked] = useState(false); + const [showStylePreview, setShowStylePreview] = useState(false); + + // 背景音乐相关状态 + const [bgmList, setBgmList] = useState([]); + const [selectedBgmId, setSelectedBgmId] = useState(""); + const [enableBgm, setEnableBgm] = useState(false); + const [bgmVolume, setBgmVolume] = useState(0.2); + const [playingBgmId, setPlayingBgmId] = useState(null); + const [bgmLoading, setBgmLoading] = useState(false); + const [bgmError, setBgmError] = useState(""); // 声音克隆相关状态 const [ttsMode, setTtsMode] = useState<'edgetts' | 'voiceclone'>('edgetts'); @@ -112,11 +214,23 @@ export default function Home() { const [editName, setEditName] = useState(""); const [playingAudioId, setPlayingAudioId] = useState(null); const audioPlayerRef = useRef(null); + const bgmPlayerRef = useRef(null); + const bgmItemRefs = useRef>({}); + const bgmListContainerRef = useRef(null); + const materialItemRefs = useRef>({}); + const videoItemRefs = useRef>({}); // 播放/暂停预览 const togglePlayPreview = (audio: RefAudio, e: React.MouseEvent) => { e.stopPropagation(); + if (bgmPlayerRef.current) { + bgmPlayerRef.current.pause(); + bgmPlayerRef.current.currentTime = 0; + bgmPlayerRef.current = null; + setPlayingBgmId(null); + } + if (playingAudioId === audio.id) { // 停止 if (audioPlayerRef.current) { @@ -137,6 +251,48 @@ export default function Home() { } }; + // 播放/暂停背景音乐预览 + const toggleBgmPreview = (bgm: BgmItem, e: React.MouseEvent) => { + e.stopPropagation(); + setSelectedBgmId(bgm.id); + setEnableBgm(true); + + const bgmUrl = resolveBgmUrl(bgm.id); + if (!bgmUrl) { + alert("无法播放该背景音乐"); + return; + } + + if (playingBgmId === bgm.id) { + if (bgmPlayerRef.current) { + bgmPlayerRef.current.pause(); + bgmPlayerRef.current.currentTime = 0; + } + bgmPlayerRef.current = null; + setPlayingBgmId(null); + return; + } + + if (audioPlayerRef.current) { + audioPlayerRef.current.pause(); + audioPlayerRef.current.currentTime = 0; + audioPlayerRef.current = null; + setPlayingAudioId(null); + } + + if (bgmPlayerRef.current) { + bgmPlayerRef.current.pause(); + bgmPlayerRef.current.currentTime = 0; + } + + const player = new Audio(bgmUrl); + player.volume = Math.max(0, Math.min(bgmVolume, 1)); + player.onended = () => setPlayingBgmId(null); + player.play().catch(e => alert("播放失败: " + e)); + bgmPlayerRef.current = player; + setPlayingBgmId(bgm.id); + }; + // 重命名参考音频 const startEditing = (audio: RefAudio, e: React.MouseEvent) => { e.stopPropagation(); @@ -201,24 +357,38 @@ export default function Home() { // 加载素材列表和历史视频 useEffect(() => { + if (isAuthLoading) return; fetchMaterials(); fetchGeneratedVideos(); fetchRefAudios(); + fetchSubtitleStyles(); + fetchTitleStyles(); + fetchBgmList(); + }, [isAuthLoading]); + + useEffect(() => { + if (typeof window === 'undefined') return; + if ('scrollRestoration' in window.history) { + window.history.scrollRestoration = 'manual'; + } + window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); }, []); // 监听任务完成,自动显示视频 useEffect(() => { if (currentTask?.status === 'completed' && currentTask.download_url) { const resolvedUrl = resolveMediaUrl(currentTask.download_url); + const completedVideoId = currentTask.task_id ? `${currentTask.task_id}_output` : null; if (resolvedUrl) { setGeneratedVideo(resolvedUrl); } - if (currentTask.task_id) { - setSelectedVideoId(`${currentTask.task_id}_output`); + if (completedVideoId) { + setSelectedVideoId(completedVideoId); + localStorage.setItem(`vigent_${storageKey}_selectedVideoId`, completedVideoId); } - fetchGeneratedVideos(); // 刷新历史视频列表 + fetchGeneratedVideos(completedVideoId || undefined); // 刷新历史视频列表 } - }, [currentTask?.status, currentTask?.download_url, currentTask?.task_id]); + }, [currentTask?.status, currentTask?.download_url, currentTask?.task_id, storageKey]); // 从 localStorage 恢复用户输入(等待认证完成后) useEffect(() => { @@ -233,6 +403,14 @@ export default function Home() { const savedTtsMode = localStorage.getItem(`vigent_${storageKey}_ttsMode`); const savedVoice = localStorage.getItem(`vigent_${storageKey}_voice`); const savedMaterial = localStorage.getItem(`vigent_${storageKey}_material`); + const savedSubtitleStyle = localStorage.getItem(`vigent_${storageKey}_subtitleStyle`); + const savedTitleStyle = localStorage.getItem(`vigent_${storageKey}_titleStyle`); + const savedSubtitleFontSize = localStorage.getItem(`vigent_${storageKey}_subtitleFontSize`); + const savedTitleFontSize = localStorage.getItem(`vigent_${storageKey}_titleFontSize`); + const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`); + const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`); + const savedBgmVolume = localStorage.getItem(`vigent_${storageKey}_bgmVolume`); + const savedEnableBgm = localStorage.getItem(`vigent_${storageKey}_enableBgm`); console.log("[Home] localStorage 数据:", { savedText, savedTitle, savedSubtitles, savedTtsMode, savedVoice, savedMaterial }); @@ -243,6 +421,26 @@ export default function Home() { setTtsMode((savedTtsMode as 'edgetts' | 'voiceclone') || 'edgetts'); setVoice(savedVoice || "zh-CN-YunxiNeural"); if (savedMaterial) setSelectedMaterial(savedMaterial); + if (savedSubtitleStyle) setSelectedSubtitleStyleId(savedSubtitleStyle); + if (savedTitleStyle) setSelectedTitleStyleId(savedTitleStyle); + if (savedSubtitleFontSize) { + const parsed = parseInt(savedSubtitleFontSize, 10); + if (!Number.isNaN(parsed)) { + setSubtitleFontSize(parsed); + setSubtitleSizeLocked(true); + } + } + if (savedTitleFontSize) { + const parsed = parseInt(savedTitleFontSize, 10); + if (!Number.isNaN(parsed)) { + setTitleFontSize(parsed); + setTitleSizeLocked(true); + } + } + if (savedBgmId) setSelectedBgmId(savedBgmId); + if (savedBgmVolume) setBgmVolume(parseFloat(savedBgmVolume)); + if (savedEnableBgm !== null) setEnableBgm(savedEnableBgm === 'true'); + if (savedSelectedVideoId) setSelectedVideoId(savedSelectedVideoId); // 恢复完成后才允许保存 setIsRestored(true); @@ -282,6 +480,57 @@ export default function Home() { } }, [selectedMaterial, storageKey, isRestored]); + useEffect(() => { + if (isRestored && selectedSubtitleStyleId) { + localStorage.setItem(`vigent_${storageKey}_subtitleStyle`, selectedSubtitleStyleId); + } + }, [selectedSubtitleStyleId, storageKey, isRestored]); + + useEffect(() => { + if (isRestored && selectedTitleStyleId) { + localStorage.setItem(`vigent_${storageKey}_titleStyle`, selectedTitleStyleId); + } + }, [selectedTitleStyleId, storageKey, isRestored]); + + useEffect(() => { + if (isRestored) { + localStorage.setItem(`vigent_${storageKey}_subtitleFontSize`, String(subtitleFontSize)); + } + }, [subtitleFontSize, storageKey, isRestored]); + + useEffect(() => { + if (isRestored) { + localStorage.setItem(`vigent_${storageKey}_titleFontSize`, String(titleFontSize)); + } + }, [titleFontSize, storageKey, isRestored]); + + useEffect(() => { + if (isRestored) { + localStorage.setItem(`vigent_${storageKey}_bgmId`, selectedBgmId); + } + }, [selectedBgmId, storageKey, isRestored]); + + useEffect(() => { + if (isRestored) { + localStorage.setItem(`vigent_${storageKey}_bgmVolume`, String(bgmVolume)); + } + }, [bgmVolume, storageKey, isRestored]); + + useEffect(() => { + if (isRestored) { + localStorage.setItem(`vigent_${storageKey}_enableBgm`, String(enableBgm)); + } + }, [enableBgm, storageKey, isRestored]); + + useEffect(() => { + if (!isRestored) return; + if (selectedVideoId) { + localStorage.setItem(`vigent_${storageKey}_selectedVideoId`, selectedVideoId); + } else { + localStorage.removeItem(`vigent_${storageKey}_selectedVideoId`); + } + }, [selectedVideoId, storageKey, isRestored]); + const fetchMaterials = async () => { try { setFetchError(null); @@ -304,10 +553,34 @@ export default function Home() { }; // 获取已生成的视频列表(持久化) - const fetchGeneratedVideos = async () => { + const fetchGeneratedVideos = async (preferVideoId?: string) => { try { const { data } = await api.get('/api/videos/generated'); - setGeneratedVideos(data.videos || []); + const videos: GeneratedVideo[] = data.videos || []; + setGeneratedVideos(videos); + + const savedSelectedVideoId = localStorage.getItem(`vigent_${storageKey}_selectedVideoId`); + const currentId = preferVideoId || selectedVideoId || savedSelectedVideoId || null; + let nextId: string | null = null; + let nextUrl: string | null = null; + + if (currentId) { + const found = videos.find(v => v.id === currentId); + if (found) { + nextId = found.id; + nextUrl = resolveMediaUrl(found.path); + } + } + + if (!nextId && videos.length > 0) { + nextId = videos[0].id; + nextUrl = resolveMediaUrl(videos[0].path); + } + + if (nextId) { + setSelectedVideoId(nextId); + setGeneratedVideo(nextUrl); + } } catch (error) { console.error("获取历史视频失败:", error); } @@ -326,6 +599,126 @@ export default function Home() { } }; + // 获取字幕样式列表 + const fetchSubtitleStyles = async () => { + try { + const { data } = await api.get('/api/assets/subtitle-styles'); + const styles: SubtitleStyleOption[] = data.styles || []; + setSubtitleStyles(styles); + if (!selectedSubtitleStyleId) { + const defaultStyle = styles.find(s => s.is_default) || styles[0]; + if (defaultStyle) setSelectedSubtitleStyleId(defaultStyle.id); + } + } catch (error) { + console.error("获取字幕样式失败:", error); + } + }; + + // 获取标题样式列表 + const fetchTitleStyles = async () => { + try { + const { data } = await api.get('/api/assets/title-styles'); + const styles: TitleStyleOption[] = data.styles || []; + setTitleStyles(styles); + if (!selectedTitleStyleId) { + const defaultStyle = styles.find(s => s.is_default) || styles[0]; + if (defaultStyle) setSelectedTitleStyleId(defaultStyle.id); + } + } catch (error) { + console.error("获取标题样式失败:", error); + } + }; + + useEffect(() => { + if (subtitleSizeLocked || subtitleStyles.length === 0) return; + const active = subtitleStyles.find(s => s.id === selectedSubtitleStyleId) + || subtitleStyles.find(s => s.is_default) + || subtitleStyles[0]; + if (active?.font_size) { + setSubtitleFontSize(active.font_size); + } + }, [subtitleStyles, selectedSubtitleStyleId, subtitleSizeLocked]); + + useEffect(() => { + if (titleSizeLocked || titleStyles.length === 0) return; + const active = titleStyles.find(s => s.id === selectedTitleStyleId) + || titleStyles.find(s => s.is_default) + || titleStyles[0]; + if (active?.font_size) { + setTitleFontSize(active.font_size); + } + }, [titleStyles, selectedTitleStyleId, titleSizeLocked]); + + // 获取背景音乐列表 + const fetchBgmList = async () => { + setBgmLoading(true); + setBgmError(""); + try { + const { data } = await api.get('/api/assets/bgm'); + const items: BgmItem[] = Array.isArray(data.bgm) ? data.bgm : []; + setBgmList(items); + const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`); + setSelectedBgmId((prev) => { + if (prev && items.some((item) => item.id === prev)) { + return prev; + } + if (savedBgmId && items.some((item) => item.id === savedBgmId)) { + return savedBgmId; + } + return prev || (items[0]?.id || ""); + }); + } catch (error: any) { + const message = error?.response?.data?.detail || error?.message || '加载失败'; + setBgmError(message); + setBgmList([]); + console.error("获取背景音乐失败:", error); + } finally { + setBgmLoading(false); + } + }; + + useEffect(() => { + if (!enableBgm || selectedBgmId || bgmList.length === 0) return; + const savedBgmId = localStorage.getItem(`vigent_${storageKey}_bgmId`); + const savedItem = savedBgmId && bgmList.find((item) => item.id === savedBgmId); + if (savedItem) { + setSelectedBgmId(savedBgmId); + return; + } + setSelectedBgmId(bgmList[0].id); + }, [enableBgm, selectedBgmId, bgmList, storageKey]); + + useEffect(() => { + if (bgmPlayerRef.current) { + bgmPlayerRef.current.volume = Math.max(0, Math.min(bgmVolume, 1)); + } + }, [bgmVolume]); + + useEffect(() => { + if (!selectedBgmId) return; + const container = bgmListContainerRef.current; + const target = bgmItemRefs.current[selectedBgmId]; + if (container && target) { + scrollContainerToItem(container, target); + } + }, [selectedBgmId, bgmList]); + + useEffect(() => { + if (!selectedMaterial) return; + const target = materialItemRefs.current[selectedMaterial]; + if (target) { + target.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }, [selectedMaterial, materials]); + + useEffect(() => { + if (!selectedVideoId) return; + const target = videoItemRefs.current[selectedVideoId]; + if (target) { + target.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }, [selectedVideoId, generatedVideos]); + // 自动选择参考音频 (恢复上次选择 或 默认最新的) useEffect(() => { // 只有在数据加载完成且尚未选择时才执行 @@ -584,6 +977,11 @@ export default function Home() { } } + if (enableBgm && !selectedBgmId) { + alert("请选择背景音乐"); + return; + } + setGeneratedVideo(null); try { @@ -603,6 +1001,27 @@ export default function Home() { enable_subtitles: enableSubtitles, }; + if (enableSubtitles && selectedSubtitleStyleId) { + payload.subtitle_style_id = selectedSubtitleStyleId; + } + + if (enableSubtitles && subtitleFontSize) { + payload.subtitle_font_size = Math.round(subtitleFontSize); + } + + if (videoTitle.trim() && selectedTitleStyleId) { + payload.title_style_id = selectedTitleStyleId; + } + + if (videoTitle.trim() && titleFontSize) { + payload.title_font_size = Math.round(titleFontSize); + } + + if (enableBgm && selectedBgmId) { + payload.bgm_id = selectedBgmId; + payload.bgm_volume = bgmVolume; + } + if (ttsMode === 'edgetts') { payload.voice = voice; } else { @@ -625,6 +1044,38 @@ export default function Home() { } }; + const activeSubtitleStyle = subtitleStyles.find(s => s.id === selectedSubtitleStyleId) + || subtitleStyles.find(s => s.is_default) + || subtitleStyles[0]; + + const activeTitleStyle = titleStyles.find(s => s.id === selectedTitleStyleId) + || titleStyles.find(s => s.is_default) + || titleStyles[0]; + + const previewTitleText = videoTitle.trim() || "这里是标题预览"; + const subtitleHighlightText = "最近,一个叫Cloudbot"; + const subtitleNormalText = "的开源项目在GitHub上彻底火了"; + + const subtitleHighlightColor = activeSubtitleStyle?.highlight_color || "#FFE600"; + const subtitleNormalColor = activeSubtitleStyle?.normal_color || "#FFFFFF"; + const subtitleStrokeColor = activeSubtitleStyle?.stroke_color || "#000000"; + const subtitleStrokeSize = activeSubtitleStyle?.stroke_size ?? 3; + const subtitleLetterSpacing = activeSubtitleStyle?.letter_spacing ?? 2; + const subtitleFontFamilyName = `SubtitlePreview-${activeSubtitleStyle?.id || "default"}`; + const subtitleFontUrl = activeSubtitleStyle?.font_file + ? resolveAssetUrl(`fonts/${activeSubtitleStyle.font_file}`) + : null; + + const titleColor = activeTitleStyle?.color || "#FFFFFF"; + const titleStrokeColor = activeTitleStyle?.stroke_color || "#000000"; + const titleStrokeSize = activeTitleStyle?.stroke_size ?? 8; + const titleLetterSpacing = activeTitleStyle?.letter_spacing ?? 4; + const titleFontWeight = activeTitleStyle?.font_weight ?? 900; + const titleFontFamilyName = `TitlePreview-${activeTitleStyle?.id || "default"}`; + const titleFontUrl = activeTitleStyle?.font_file + ? resolveAssetUrl(`fonts/${activeTitleStyle.font_file}`) + : null; + return (
{/* Header
@@ -752,48 +1203,53 @@ export default function Home() {

) : ( -
+
{materials.map((m) => (
{ + materialItemRefs.current[m.id] = el; + }} + className={`p-3 rounded-lg border transition-all flex items-center justify-between group ${selectedMaterial === m.id ? "border-purple-500 bg-purple-500/20" : "border-white/10 bg-white/5 hover:border-white/30" }`} > - - +
+ + +
))}
@@ -839,9 +1295,66 @@ export default function Home() { {/* 标题和字幕设置 */}
-

- 🎬 标题与字幕 -

+
+

+ 🎬 标题与字幕 +

+ +
+ + {showStylePreview && ( +
+ {(titleFontUrl || subtitleFontUrl) && ( + + )} +
+
+
+ {previewTitleText} +
+ +
+ {enableSubtitles ? ( + <> + {subtitleHighlightText} + {subtitleNormalText} + + ) : ( + 字幕已关闭 + )} +
+
+
+ )} {/* 视频标题输入 */}
@@ -857,8 +1370,98 @@ export default function Home() { />
+ {/* 标题样式选择 */} + {titleStyles.length > 0 && ( +
+ +
+ {titleStyles.map((style) => ( + + ))} +
+
+ + { + setTitleFontSize(parseInt(e.target.value, 10)); + setTitleSizeLocked(true); + }} + className="w-full accent-purple-500" + /> +
+
+ )} + + {/* 字幕样式选择 */} + {enableSubtitles && subtitleStyles.length > 0 && ( +
+ +
+ {subtitleStyles.map((style) => ( + + ))} +
+
+ + { + setSubtitleFontSize(parseInt(e.target.value, 10)); + setSubtitleSizeLocked(true); + }} + className="w-full accent-purple-500" + /> +
+
+ )} + {/* 字幕开关 */} -
+
逐字高亮字幕

@@ -1115,6 +1718,114 @@ export default function Home() { )}

+ {/* 背景音乐 */} +
+
+

+ 🎵 背景音乐 +

+
+ + +
+
+ + {bgmLoading ? ( +
+ 正在加载背景音乐... +
+ ) : bgmError ? ( +
+ 加载失败:{bgmError} + +
+ ) : bgmList.length === 0 ? ( +
+ 暂无背景音乐,请先导入素材 +
+ ) : ( +
+ {bgmList.map((bgm) => ( +
{ + bgmItemRefs.current[bgm.id] = el; + }} + className={`p-3 rounded-lg border transition-all flex items-center justify-between group ${selectedBgmId === bgm.id + ? "border-purple-500 bg-purple-500/20" + : "border-white/10 bg-white/5 hover:border-white/30" + }`} + > + +
+ + {selectedBgmId === bgm.id && ( + 已选 + )} +
+
+ ))} +
+ )} + + {enableBgm && ( +
+ + setBgmVolume(parseFloat(e.target.value))} + className="w-full accent-purple-500" + /> +
+ 当前: {Math.round(bgmVolume * 100)}% +
+
+ )} +
+ {/* 生成按钮 */} -
-
- + + 发布管理 + + +
+
+