更新
This commit is contained in:
22
backend/app/api/assets.py
Normal file
22
backend/app/api/assets.py
Normal file
@@ -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()}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
128
backend/app/services/assets_service.py
Normal file
128
backend/app/services/assets_service.py
Normal file
@@ -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
|
||||
@@ -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)}")
|
||||
|
||||
# 在线程池中运行子进程
|
||||
|
||||
@@ -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,
|
||||
|
||||
58
backend/assets/styles/subtitle.json
Normal file
58
backend/assets/styles/subtitle.json
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
58
backend/assets/styles/title.json
Normal file
58
backend/assets/styles/title.json
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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*', // 转发静态资源(字体/音乐)
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<string>("");
|
||||
const [enableSubtitles, setEnableSubtitles] = useState<boolean>(true);
|
||||
const [subtitleStyles, setSubtitleStyles] = useState<SubtitleStyleOption[]>([]);
|
||||
const [titleStyles, setTitleStyles] = useState<TitleStyleOption[]>([]);
|
||||
const [selectedSubtitleStyleId, setSelectedSubtitleStyleId] = useState<string>("");
|
||||
const [selectedTitleStyleId, setSelectedTitleStyleId] = useState<string>("");
|
||||
const [subtitleFontSize, setSubtitleFontSize] = useState<number>(52);
|
||||
const [titleFontSize, setTitleFontSize] = useState<number>(72);
|
||||
const [subtitleSizeLocked, setSubtitleSizeLocked] = useState<boolean>(false);
|
||||
const [titleSizeLocked, setTitleSizeLocked] = useState<boolean>(false);
|
||||
const [showStylePreview, setShowStylePreview] = useState<boolean>(false);
|
||||
|
||||
// 背景音乐相关状态
|
||||
const [bgmList, setBgmList] = useState<BgmItem[]>([]);
|
||||
const [selectedBgmId, setSelectedBgmId] = useState<string>("");
|
||||
const [enableBgm, setEnableBgm] = useState<boolean>(false);
|
||||
const [bgmVolume, setBgmVolume] = useState<number>(0.2);
|
||||
const [playingBgmId, setPlayingBgmId] = useState<string | null>(null);
|
||||
const [bgmLoading, setBgmLoading] = useState<boolean>(false);
|
||||
const [bgmError, setBgmError] = useState<string>("");
|
||||
|
||||
// 声音克隆相关状态
|
||||
const [ttsMode, setTtsMode] = useState<'edgetts' | 'voiceclone'>('edgetts');
|
||||
@@ -112,11 +214,23 @@ export default function Home() {
|
||||
const [editName, setEditName] = useState("");
|
||||
const [playingAudioId, setPlayingAudioId] = useState<string | null>(null);
|
||||
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||
const bgmPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||
const bgmItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const bgmListContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const materialItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const videoItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
// 播放/暂停预览
|
||||
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 (
|
||||
<div className="min-h-dvh">
|
||||
{/* Header <header className="border-b border-white/10 bg-black/20 backdrop-blur-sm">
|
||||
@@ -752,48 +1203,53 @@ export default function Home() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar">
|
||||
{materials.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-left relative group ${selectedMaterial === m.id
|
||||
ref={(el) => {
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelectedMaterial(m.id)}
|
||||
className="w-full text-left"
|
||||
className="flex-1 text-left"
|
||||
>
|
||||
<div className="text-white font-medium truncate pr-6">
|
||||
<div className="text-white text-sm truncate">
|
||||
{m.scene || m.name}
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm mt-1">
|
||||
<div className="text-gray-400 text-xs">
|
||||
{m.size_mb.toFixed(1)} MB
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (m.path) {
|
||||
setPreviewMaterial(resolveMediaUrl(m.path));
|
||||
}
|
||||
}}
|
||||
className="absolute top-2 right-10 p-1 text-gray-500 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="预览视频"
|
||||
>
|
||||
👁️
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteMaterial(m.id);
|
||||
}}
|
||||
className="absolute top-2 right-2 p-1 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="删除素材"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (m.path) {
|
||||
setPreviewMaterial(resolveMediaUrl(m.path));
|
||||
}
|
||||
}}
|
||||
className="p-1 text-gray-500 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="预览视频"
|
||||
>
|
||||
👁️
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteMaterial(m.id);
|
||||
}}
|
||||
className="p-1 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="删除素材"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -839,9 +1295,66 @@ export default function Home() {
|
||||
|
||||
{/* 标题和字幕设置 */}
|
||||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
🎬 标题与字幕
|
||||
</h2>
|
||||
<div className="flex items-center justify-between mb-4 gap-2">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||
🎬 标题与字幕
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowStylePreview((prev) => !prev)}
|
||||
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300"
|
||||
>
|
||||
{showStylePreview ? "收起预览" : "👁️ 预览样式"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showStylePreview && (
|
||||
<div className="mb-4 rounded-xl border border-white/10 bg-black/40 p-4 relative overflow-hidden">
|
||||
{(titleFontUrl || subtitleFontUrl) && (
|
||||
<style>{`
|
||||
${titleFontUrl ? `@font-face { font-family: '${titleFontFamilyName}'; src: url('${titleFontUrl}') format('${getFontFormat(activeTitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
|
||||
${subtitleFontUrl ? `@font-face { font-family: '${subtitleFontFamilyName}'; src: url('${subtitleFontUrl}') format('${getFontFormat(activeSubtitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
|
||||
`}</style>
|
||||
)}
|
||||
<div className="absolute inset-0 opacity-20 bg-gradient-to-br from-purple-500/40 via-transparent to-pink-500/30" />
|
||||
<div className="relative z-10 flex flex-col items-center justify-between min-h-[180px]">
|
||||
<div className="w-full text-center" style={{
|
||||
color: titleColor,
|
||||
fontSize: `${titleFontSize}px`,
|
||||
fontWeight: titleFontWeight,
|
||||
fontFamily: titleFontUrl
|
||||
? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
|
||||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||||
textShadow: buildTextShadow(titleStrokeColor, titleStrokeSize),
|
||||
letterSpacing: `${titleLetterSpacing}px`,
|
||||
lineHeight: 1.2,
|
||||
paddingTop: '6px',
|
||||
opacity: videoTitle.trim() ? 1 : 0.7,
|
||||
}}>
|
||||
{previewTitleText}
|
||||
</div>
|
||||
|
||||
<div className="w-full text-center" style={{
|
||||
fontSize: `${subtitleFontSize}px`,
|
||||
fontFamily: subtitleFontUrl
|
||||
? `'${subtitleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
|
||||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||||
textShadow: buildTextShadow(subtitleStrokeColor, subtitleStrokeSize),
|
||||
letterSpacing: `${subtitleLetterSpacing}px`,
|
||||
lineHeight: 1.35,
|
||||
paddingBottom: '8px',
|
||||
}}>
|
||||
{enableSubtitles ? (
|
||||
<>
|
||||
<span style={{ color: subtitleHighlightColor }}>{subtitleHighlightText}</span>
|
||||
<span style={{ color: subtitleNormalColor }}>{subtitleNormalText}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">字幕已关闭</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频标题输入 */}
|
||||
<div className="mb-4">
|
||||
@@ -857,8 +1370,98 @@ export default function Home() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标题样式选择 */}
|
||||
{titleStyles.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<label className="text-sm text-gray-300 mb-2 block">
|
||||
标题样式
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{titleStyles.map((style) => (
|
||||
<button
|
||||
key={style.id}
|
||||
onClick={() => setSelectedTitleStyleId(style.id)}
|
||||
className={`p-2 rounded-lg border transition-all text-left ${selectedTitleStyleId === style.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<div className="text-white text-sm truncate">
|
||||
{style.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 truncate">
|
||||
{style.font_family || style.font_file || ""}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="text-xs text-gray-400 mb-2 block">
|
||||
标题字号: {titleFontSize}px
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="48"
|
||||
max="110"
|
||||
step="1"
|
||||
value={titleFontSize}
|
||||
onChange={(e) => {
|
||||
setTitleFontSize(parseInt(e.target.value, 10));
|
||||
setTitleSizeLocked(true);
|
||||
}}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 字幕样式选择 */}
|
||||
{enableSubtitles && subtitleStyles.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<label className="text-sm text-gray-300 mb-2 block">
|
||||
字幕样式
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{subtitleStyles.map((style) => (
|
||||
<button
|
||||
key={style.id}
|
||||
onClick={() => setSelectedSubtitleStyleId(style.id)}
|
||||
className={`p-2 rounded-lg border transition-all text-left ${selectedSubtitleStyleId === style.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<div className="text-white text-sm truncate">
|
||||
{style.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 truncate">
|
||||
{style.font_family || style.font_file || ""}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="text-xs text-gray-400 mb-2 block">
|
||||
字幕字号: {subtitleFontSize}px
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="32"
|
||||
max="90"
|
||||
step="1"
|
||||
value={subtitleFontSize}
|
||||
onChange={(e) => {
|
||||
setSubtitleFontSize(parseInt(e.target.value, 10));
|
||||
setSubtitleSizeLocked(true);
|
||||
}}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 字幕开关 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mt-4 pt-4 border-t border-white/10 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm text-gray-300">逐字高亮字幕</span>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
@@ -1115,6 +1718,114 @@ export default function Home() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 背景音乐 */}
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
🎵 背景音乐
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={fetchBgmList}
|
||||
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300"
|
||||
>
|
||||
🔄 刷新
|
||||
</button>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableBgm}
|
||||
onChange={(e) => setEnableBgm(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bgmLoading ? (
|
||||
<div className="text-center py-4 text-gray-400 text-sm">
|
||||
正在加载背景音乐...
|
||||
</div>
|
||||
) : bgmError ? (
|
||||
<div className="text-center py-4 text-red-300 text-sm">
|
||||
加载失败:{bgmError}
|
||||
<button
|
||||
onClick={fetchBgmList}
|
||||
className="ml-2 px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : bgmList.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500 text-sm">
|
||||
暂无背景音乐,请先导入素材
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={bgmListContainerRef}
|
||||
className={`space-y-2 max-h-64 overflow-y-auto hide-scrollbar ${enableBgm ? '' : 'opacity-70'}`}
|
||||
>
|
||||
{bgmList.map((bgm) => (
|
||||
<div
|
||||
key={bgm.id}
|
||||
ref={(el) => {
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedBgmId(bgm.id);
|
||||
}}
|
||||
className="flex-1 text-left"
|
||||
>
|
||||
<div className="text-white text-sm truncate">
|
||||
{bgm.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">.{bgm.ext || 'audio'}</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<button
|
||||
onClick={(e) => toggleBgmPreview(bgm, e)}
|
||||
className="p-1 text-gray-500 hover:text-purple-400 transition-colors"
|
||||
title="试听"
|
||||
>
|
||||
{playingBgmId === bgm.id ? '⏸️' : '▶️'}
|
||||
</button>
|
||||
{selectedBgmId === bgm.id && (
|
||||
<span className="text-xs text-purple-300">已选</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enableBgm && (
|
||||
<div className="mt-4">
|
||||
<label className="text-sm text-gray-300 mb-2 block">
|
||||
音量
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={bgmVolume}
|
||||
onChange={(e) => setBgmVolume(parseFloat(e.target.value))}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
当前: {Math.round(bgmVolume * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 生成按钮 */}
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
@@ -1216,7 +1927,7 @@ export default function Home() {
|
||||
📂 历史视频
|
||||
</h2>
|
||||
<button
|
||||
onClick={fetchGeneratedVideos}
|
||||
onClick={() => fetchGeneratedVideos()}
|
||||
className="px-3 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300"
|
||||
>
|
||||
🔄 刷新
|
||||
@@ -1231,6 +1942,9 @@ export default function Home() {
|
||||
{generatedVideos.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
ref={(el) => {
|
||||
videoItemRefs.current[v.id] = el;
|
||||
}}
|
||||
className={`p-3 rounded-lg border transition-all flex items-center justify-between group ${selectedVideoId === v.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import useSWR from 'swr';
|
||||
import Link from "next/link";
|
||||
import api from "@/lib/axios";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import api from "@/lib/axios";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AccountSettingsDropdown from "@/components/AccountSettingsDropdown";
|
||||
|
||||
// SWR fetcher 使用 axios(自动处理 401/403)
|
||||
const fetcher = (url: string) => api.get(url).then((res) => res.data);
|
||||
@@ -57,11 +58,19 @@ export default function PublishPage() {
|
||||
// 是否已从 localStorage 恢复完成
|
||||
const [isRestored, setIsRestored] = useState(false);
|
||||
|
||||
// 加载账号和视频列表
|
||||
useEffect(() => {
|
||||
fetchAccounts();
|
||||
fetchVideos();
|
||||
}, []);
|
||||
// 加载账号和视频列表
|
||||
useEffect(() => {
|
||||
fetchAccounts();
|
||||
fetchVideos();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if ('scrollRestoration' in window.history) {
|
||||
window.history.scrollRestoration = 'manual';
|
||||
}
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||
}, []);
|
||||
|
||||
// 获取存储 key 的前缀(登录用户使用 userId,未登录使用 guest)
|
||||
const storageKey = userId || 'guest';
|
||||
@@ -296,7 +305,7 @@ export default function PublishPage() {
|
||||
)}
|
||||
|
||||
{/* Header - 统一样式 */}
|
||||
<header className="border-b border-white/10 bg-black/20 backdrop-blur-sm">
|
||||
<header className="border-b border-white/10 bg-black/20 backdrop-blur-sm relative z-[100]">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-between">
|
||||
<Link href="/" className="text-xl sm:text-2xl font-bold text-white flex items-center gap-2 sm:gap-3 hover:opacity-80 transition-opacity">
|
||||
<span className="text-3xl sm:text-4xl">🎬</span>
|
||||
@@ -309,25 +318,13 @@ export default function PublishPage() {
|
||||
>
|
||||
返回创作
|
||||
</Link>
|
||||
<span className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg font-semibold">
|
||||
发布管理
|
||||
</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (confirm('确定要退出登录吗?')) {
|
||||
try {
|
||||
await api.post('/api/auth/logout');
|
||||
} catch (e) { }
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}}
|
||||
className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-red-500/10 hover:bg-red-500/20 text-red-200 rounded-lg transition-colors"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<span className="px-2 sm:px-4 py-1 sm:py-2 text-sm sm:text-base bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg font-semibold">
|
||||
发布管理
|
||||
</span>
|
||||
<AccountSettingsDropdown />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-6xl mx-auto px-6 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
|
||||
Reference in New Issue
Block a user