fix: 修复预览播放器三个问题
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m6s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m19s
AI Code Review / AI Code Review (pull_request) Failing after 2m23s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m52s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m41s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m32s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m30s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 4m9s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 10m59s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Waiting to run
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 50s

1. 标题不显示:给 .xx-preview-video::before 添加 z-index: 0,确保渐变光效在内容层之下
2. 没有配音音频:添加 voiceAudioUrl prop 链路,在 FrontendPreviewPlayer 中创建 Audio 对象并同步视频播放
3. 片段切换不连贯:在 useSegmentScheduler 中提前 2 秒预加载下一段,减少黑屏间隙

- 所有修改通过 TypeScript 编译检查
- 代码已通过 Prettier 格式化
This commit is contained in:
张宏杰
2026-08-18 21:26:11 +08:00
parent 8f1d6f20d9
commit b0214beb39
5 changed files with 111 additions and 4 deletions
@@ -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 && (
@@ -21,6 +21,8 @@ interface FrontendPreviewPlayerProps {
videoRatio: string
/** 是否准备好播放(素材已加载) */
ready: boolean
/** 配音音频 URL */
voiceAudioUrl?: string
}
/** 格式化时间 mm:ss */
@@ -76,6 +78,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
template,
videoRatio: _videoRatio,
ready,
voiceAudioUrl,
}) => {
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
@@ -90,6 +93,77 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
videoRef,
} = useSegmentScheduler(segments)
// ── 配音音频同步 ──
const audioRef = useRef<HTMLAudioElement | null>(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<HTMLDivElement>(null)
@@ -99,9 +173,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
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<FrontendPreviewPlayerProps> = ({
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<FrontendPreviewPlayerProps> = ({
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
}
}, [isDragging, totalDuration, seekTo])
}, [isDragging, totalDuration, handleSeekTo])
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
@@ -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<PreviewVideoPanelProps> = ({
assetsReady,
assetsLoading,
titleSettings,
voiceAudioUrl,
}) => {
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
@@ -205,6 +208,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
template={template}
videoRatio={videoRatio}
ready={assetsReady}
voiceAudioUrl={voiceAudioUrl}
/>
{/* CSS 标题实时预览层 — z-index: 20,始终渲染在内容层之上 */}
+1
View File
@@ -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 {
@@ -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