diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 8a52123e0..29491d027 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -13,7 +13,9 @@ import React, { useMemo } from "react" import { Modal, message } from "antd" import { useNavigate } from "react-router-dom" import type { VoiceClone } from "@/api/voice-clone" +import { useQuery } from "@tanstack/react-query" import { useCloneProgress } from "@/hooks/useCloneProgress" +import { getAssetsByKind } from "@/api/assets" import CloneModal from "@/components/voice/CloneModal" import GenerateHeader from "./components/GenerateHeader" import GenerateStepsBar from "./components/GenerateStepsBar" @@ -105,6 +107,18 @@ const GeneratePage: React.FC = () => { [userTemplates, selectedTemplate], ) + /* ── 配音音频 URL ── */ + const { data: voiceMaterials = [] } = useQuery({ + queryKey: ["assets", "voice"], + queryFn: () => getAssetsByKind("voice", { limit: 50 }), + }) + + const voiceAudioUrl = useMemo(() => { + if (!selectedVoice) return undefined + const asset = voiceMaterials.find((v) => v.id === selectedVoice) + return asset?.file_url || undefined + }, [selectedVoice, voiceMaterials]) + /* ── 步骤导航 ── */ const { goNext, goPrev } = useStepNavigation({ currentStep, @@ -236,6 +250,7 @@ const GeneratePage: React.FC = () => { assetsReady={previewAssetsReady} assetsLoading={previewAssetsLoading} titleSettings={titleSettings} + voiceAudioUrl={voiceAudioUrl} /> )} {currentStep >= 6 && ( diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx index 33143feba..479d18a7f 100644 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx @@ -21,6 +21,8 @@ interface FrontendPreviewPlayerProps { videoRatio: string /** 是否准备好播放(素材已加载) */ ready: boolean + /** 配音音频 URL */ + voiceAudioUrl?: string } /** 格式化时间 mm:ss */ @@ -76,6 +78,7 @@ const FrontendPreviewPlayer: React.FC = ({ template, videoRatio: _videoRatio, ready, + voiceAudioUrl, }) => { const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template]) @@ -90,6 +93,77 @@ const FrontendPreviewPlayer: React.FC = ({ videoRef, } = useSegmentScheduler(segments) + // ── 配音音频同步 ── + const audioRef = useRef(null) + const prevIsPlayingRef = useRef(false) + + // 创建/更新 Audio 元素 + useEffect(() => { + if (!voiceAudioUrl) { + // 没有配音,清理已有 audio + if (audioRef.current) { + audioRef.current.pause() + audioRef.current.src = "" + audioRef.current = null + } + return + } + + if (!audioRef.current) { + audioRef.current = new Audio() + audioRef.current.preload = "auto" + } + if (audioRef.current.src !== voiceAudioUrl) { + audioRef.current.src = voiceAudioUrl + } + }, [voiceAudioUrl]) + + // 同步播放状态 + useEffect(() => { + const audio = audioRef.current + if (!audio || !audio.src) return + + if (isPlaying && !prevIsPlayingRef.current) { + // 刚进入播放 + audio.currentTime = currentTime + audio.play().catch(() => {}) + } else if (!isPlaying && prevIsPlayingRef.current) { + // 刚暂停 + audio.pause() + } + prevIsPlayingRef.current = isPlaying + }, [isPlaying, currentTime]) + + // seek 时同步音频 + const handleSeekTo = useCallback( + (time: number) => { + seekTo(time) + const audio = audioRef.current + if (audio && audio.src) { + audio.currentTime = time + } + }, + [seekTo], + ) + + // 播放结束时暂停音频 + useEffect(() => { + if (!isPlaying) { + const audio = audioRef.current + if (audio) audio.pause() + } + }, [isPlaying]) + + // 清理 + useEffect(() => { + return () => { + if (audioRef.current) { + audioRef.current.pause() + audioRef.current.src = "" + } + } + }, []) + // 进度条拖拽 const [isDragging, setIsDragging] = useState(false) const progressRef = useRef(null) @@ -99,9 +173,9 @@ const FrontendPreviewPlayer: React.FC = ({ if (!progressRef.current || totalDuration <= 0) return const rect = progressRef.current.getBoundingClientRect() const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) - seekTo(ratio * totalDuration) + handleSeekTo(ratio * totalDuration) }, - [totalDuration, seekTo], + [totalDuration, handleSeekTo], ) const handleMouseDown = useCallback( @@ -118,7 +192,7 @@ const FrontendPreviewPlayer: React.FC = ({ if (!progressRef.current || totalDuration <= 0) return const rect = progressRef.current.getBoundingClientRect() const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) - seekTo(ratio * totalDuration) + handleSeekTo(ratio * totalDuration) } const handleMouseUp = () => setIsDragging(false) window.addEventListener("mousemove", handleMouseMove) @@ -127,7 +201,7 @@ const FrontendPreviewPlayer: React.FC = ({ window.removeEventListener("mousemove", handleMouseMove) window.removeEventListener("mouseup", handleMouseUp) } - }, [isDragging, totalDuration, seekTo]) + }, [isDragging, totalDuration, handleSeekTo]) const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0 diff --git a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx index b543fc0fe..d4677e80f 100644 --- a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx +++ b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx @@ -31,6 +31,8 @@ interface PreviewVideoPanelProps { assetsLoading: boolean /** 标题设置 — 用于 CSS 实时预览层 */ titleSettings?: TitleSettings + /** 配音音频 URL */ + voiceAudioUrl?: string } /* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */ @@ -165,6 +167,7 @@ export const PreviewVideoPanel: React.FC = ({ assetsReady, assetsLoading, titleSettings, + voiceAudioUrl, }) => { const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") } @@ -205,6 +208,7 @@ export const PreviewVideoPanel: React.FC = ({ template={template} videoRatio={videoRatio} ready={assetsReady} + voiceAudioUrl={voiceAudioUrl} /> {/* CSS 标题实时预览层 — z-index: 20,始终渲染在内容层之上 */} diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 2e85d49dd..4d1593067 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -910,6 +910,7 @@ inset: 0; background: radial-gradient(circle at 72% 28%, rgba(255, 255, 255, 0.2), transparent 40%); pointer-events: none; + z-index: 0; } .xx-preview-video video { diff --git a/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts b/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts index da60d47d8..0f245007c 100644 --- a/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts +++ b/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts @@ -237,6 +237,19 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul const seg = segments[currentSegmentIndex] if (!seg) return + // 提前 2 秒预加载下一段 + if (currentSegmentIndex + 1 < segments.length) { + const nextSeg = segments[currentSegmentIndex + 1] + if (!preloadVideoRef.current) { + preloadVideoRef.current = document.createElement("video") + preloadVideoRef.current.preload = "auto" + } + if (preloadVideoRef.current.src !== nextSeg.videoUrl) { + preloadVideoRef.current.src = nextSeg.videoUrl + preloadVideoRef.current.load() + } + } + // 检查是否到达出点(容差 0.15s) if (video.currentTime >= seg.endTime - 0.15) { const nextIndex = currentSegmentIndex + 1