This commit is contained in:
Kevin Wong
2026-02-03 17:12:30 +08:00
parent cb10da52fc
commit 6c6fbae13a
12 changed files with 1225 additions and 131 deletions

22
backend/app/api/assets.py Normal file
View 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()}

View File

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

View File

@@ -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"

View File

@@ -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")

View 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

View File

@@ -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)}")
# 在线程池中运行子进程

View File

@@ -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,

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

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

View File

@@ -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*', // 转发静态资源(字体/音乐)
},
];
},
};

View File

@@ -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"

View File

@@ -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">