fix: 视频预加载优化+配音时长校验提醒 (#1435) #1435
@@ -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])
|
||||
|
||||
// 组件卸载时清理
|
||||
|
||||
Reference in New Issue
Block a user