fix: 视频预加载优化+配音时长校验提醒 (#1435) #1435

Merged
auto-approve-bot merged 2 commits from fix/preview-player-v9 into develop 2026-08-19 11:36:02 +08:00
6 changed files with 131 additions and 10 deletions
@@ -107,6 +107,20 @@ const GeneratePage: React.FC = () => {
[userTemplates, selectedTemplate],
)
/* ── 视频总时长计算(用于配音时长校验) ── */
const totalVideoDuration = useMemo(() => {
if (!previewAssets.length || !currentTemplate) return 0
const templateSegments = currentTemplate.segments || []
return previewAssets.reduce((sum, asset, i) => {
const assetDuration = asset.duration || asset.metadata?.duration || 30
const tplSeg = templateSegments[i] || templateSegments[templateSegments.length - 1]
const segDuration = tplSeg
? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration))
: Math.min(assetDuration, 10)
return sum + segDuration
}, 0)
}, [previewAssets, currentTemplate])
/* ── 配音音频 URL ── */
const { data: voiceMaterials = [] } = useQuery({
queryKey: ["assets", "voice"],
@@ -207,6 +221,7 @@ const GeneratePage: React.FC = () => {
duration={duration}
selectedVoice={selectedVoice}
onSelectedVoiceChange={setSelectedVoice}
totalVideoDuration={totalVideoDuration}
voiceMode={voiceMode}
onVoiceModeChange={setVoiceMode}
selectedClonedVoice={selectedClonedVoice}
@@ -140,7 +140,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
if (!audio || !audio.src || !isPlaying) return
// 用视频当前的全局时间对齐音频
audio.currentTime = currentTime
}, [currentSegmentIndex])
}, [currentSegmentIndex, currentTime, isPlaying])
// seek 时同步音频
const handleSeekTo = useCallback(
@@ -280,7 +280,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
objectFit: "contain",
background: "#000",
zIndex: 1,
display: i === currentSegmentIndex ? "block" : "none",
opacity: i === currentSegmentIndex ? 1 : 0,
pointerEvents: i === currentSegmentIndex ? "auto" : "none",
}}
playsInline
/>
@@ -54,6 +54,7 @@ export interface GenerateStepContentProps {
/* 配音 */
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
totalVideoDuration?: number
voiceMode: "preset" | "custom" | "clone"
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
@@ -106,6 +107,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
duration,
selectedVoice,
onSelectedVoiceChange,
totalVideoDuration,
voiceMode,
selectedClonedVoice,
clonedVoices,
@@ -146,6 +148,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
<Step3VoiceSelect
selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange}
totalVideoDuration={totalVideoDuration}
/>
)
case 4:
@@ -143,6 +143,7 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
)
const titleStyle = useMemo(
() => buildTitleStyle(titleSettings, containerHeight),
// eslint-disable-next-line react-hooks/exhaustive-deps -- 已逐字段列出 titleSettings 依赖
[
containerHeight,
titleSettings.font,
@@ -5,13 +5,15 @@
import React, { useState, useRef, useCallback } from "react"
import { useNavigate } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import { AudioOutlined, SoundOutlined } from "@ant-design/icons"
import { AudioOutlined, SoundOutlined, WarningOutlined } from "@ant-design/icons"
import { Modal } from "antd"
import { getAssetsByKind } from "@/api/assets"
import type { AssetItem } from "@/api/assets"
interface Step5VoiceSelectProps {
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
totalVideoDuration?: number
}
/** 格式化时长 mm:ss */
@@ -34,10 +36,13 @@ const formatFileSize = (bytes?: number): string => {
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
selectedVoice,
onSelectedVoiceChange,
totalVideoDuration = 0,
}) => {
const navigate = useNavigate()
const [playingId, setPlayingId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const [durationWarningOpen, setDurationWarningOpen] = useState(false)
const [pendingVoiceId, setPendingVoiceId] = useState<string | null>(null)
// 获取用户上传的配音素材
const { data: materials = [], isLoading } = useQuery({
@@ -78,14 +83,38 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
[playingId],
)
/** 选中素材 */
/** 选中素材(含时长校验) */
const handleSelect = useCallback(
(id: string) => {
// 如果启用了时长校验,且配音时长不足
if (totalVideoDuration > 0) {
const material = materials.find((m) => m.id === id)
if (material && (material.duration || 0) < totalVideoDuration) {
setPendingVoiceId(id)
setDurationWarningOpen(true)
return
}
}
onSelectedVoiceChange(id)
},
[onSelectedVoiceChange],
[onSelectedVoiceChange, totalVideoDuration, materials],
)
/** 确认使用时长不足的配音 */
const handleConfirmUseAnyway = useCallback(() => {
if (pendingVoiceId) {
onSelectedVoiceChange(pendingVoiceId)
}
setDurationWarningOpen(false)
setPendingVoiceId(null)
}, [pendingVoiceId, onSelectedVoiceChange])
/** 取消选择 */
const handleCancelSelection = useCallback(() => {
setDurationWarningOpen(false)
setPendingVoiceId(null)
}, [])
/** 跳转到配音库上传 */
const handleGoToUpload = useCallback(() => {
navigate("/app/voices")
@@ -238,15 +267,64 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
justifyContent: "space-between",
fontSize: 12,
color: "#999",
alignItems: "center",
}}
>
<span>{formatDuration(item.duration)}</span>
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
{formatDuration(item.duration)}
{totalVideoDuration > 0 && (item.duration || 0) < totalVideoDuration && (
<span
style={{
color: "#ff4d4f",
fontSize: 11,
fontWeight: 500,
display: "inline-flex",
alignItems: "center",
gap: 2,
}}
>
<WarningOutlined />
</span>
)}
</span>
<span>{formatFileSize(item.file_size)}</span>
</div>
</div>
)
})}
</div>
{/* 时长不足警告弹窗 */}
<Modal
title={
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<WarningOutlined style={{ color: "#faad14" }} />
</span>
}
open={durationWarningOpen}
onOk={handleConfirmUseAnyway}
onCancel={handleCancelSelection}
okText="仍要使用"
cancelText="重新选择"
okButtonProps={{ danger: true }}
>
{(() => {
const pendingMaterial = pendingVoiceId
? materials.find((m) => m.id === pendingVoiceId)
: null
return (
<p>
<strong>{pendingMaterial ? formatDuration(pendingMaterial.duration) : "--"}</strong>
<strong>{formatDuration(totalVideoDuration)}</strong>
</p>
)
})()}
</Modal>
</div>
)
}
@@ -188,9 +188,26 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
rafRef.current = requestAnimationFrame(tick)
const nextVideo = videoRefs.current[nextIndex]
if (nextVideo) {
nextVideo
.play()
.catch((e) => console.warn("[useSegmentScheduler] auto-play next segment failed:", e))
const canPlay = () => {
nextVideo
.play()
.catch((e) =>
console.warn("[useSegmentScheduler] auto-play next segment failed:", e),
)
}
if (nextVideo.readyState >= 3) {
canPlay()
} else {
const timeout = setTimeout(canPlay, 300)
nextVideo.addEventListener(
"canplay",
() => {
clearTimeout(timeout)
canPlay()
},
{ once: true },
)
}
}
})
const accumulatedTime =
@@ -296,12 +313,18 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
[canPlay, totalDuration, segments, currentSegmentIndex, switchToSegment],
)
// 确保 videoRefs 数组长度与 segments 一致
// 确保 videoRefs 数组长度与 segments 一致 + 强制预加载
useEffect(() => {
videoRefs.current = videoRefs.current.slice(0, segments.length)
while (videoRefs.current.length < segments.length) {
videoRefs.current.push(null)
}
// 强制预加载:所有 video 元素挂载后,调用 load() 确保浏览器真正开始加载数据
videoRefs.current.forEach((video) => {
if (video) {
video.load()
}
})
}, [segments])
// 组件卸载时清理