diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 29491d027..687e531b4 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -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} diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx index 1dd476238..c73b74b22 100644 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx @@ -140,7 +140,7 @@ const FrontendPreviewPlayer: React.FC = ({ 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 = ({ objectFit: "contain", background: "#000", zIndex: 1, - display: i === currentSegmentIndex ? "block" : "none", + opacity: i === currentSegmentIndex ? 1 : 0, + pointerEvents: i === currentSegmentIndex ? "auto" : "none", }} playsInline /> diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index b79a8269d..cac82ca21 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -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 = (props) = duration, selectedVoice, onSelectedVoiceChange, + totalVideoDuration, voiceMode, selectedClonedVoice, clonedVoices, @@ -146,6 +148,7 @@ export const GenerateStepContent: React.FC = (props) = ) case 4: diff --git a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx index 37d8f5a1d..e5eee7426 100644 --- a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx +++ b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx @@ -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, diff --git a/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx b/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx index 9b56af3df..d799034bf 100644 --- a/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx +++ b/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx @@ -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 = ({ selectedVoice, onSelectedVoiceChange, + totalVideoDuration = 0, }) => { const navigate = useNavigate() const [playingId, setPlayingId] = useState(null) const audioRef = useRef(null) + const [durationWarningOpen, setDurationWarningOpen] = useState(false) + const [pendingVoiceId, setPendingVoiceId] = useState(null) // 获取用户上传的配音素材 const { data: materials = [], isLoading } = useQuery({ @@ -78,14 +83,38 @@ const Step5VoiceSelect: React.FC = ({ [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 = ({ justifyContent: "space-between", fontSize: 12, color: "#999", + alignItems: "center", }} > - {formatDuration(item.duration)} + + {formatDuration(item.duration)} + {totalVideoDuration > 0 && (item.duration || 0) < totalVideoDuration && ( + + + 时长不足 + + )} + {formatFileSize(item.file_size)} ) })} + + {/* 时长不足警告弹窗 */} + + + 配音时长不足 + + } + open={durationWarningOpen} + onOk={handleConfirmUseAnyway} + onCancel={handleCancelSelection} + okText="仍要使用" + cancelText="重新选择" + okButtonProps={{ danger: true }} + > + {(() => { + const pendingMaterial = pendingVoiceId + ? materials.find((m) => m.id === pendingVoiceId) + : null + return ( +

+ 该配音时长( + {pendingMaterial ? formatDuration(pendingMaterial.duration) : "--"} + )短于视频总时长( + {formatDuration(totalVideoDuration)} + ),播放时配音可能提前结束,建议选择更长的配音素材。 +

+ ) + })()} +
) } diff --git a/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts b/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts index 3a8e5f05c..74bde795e 100644 --- a/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts +++ b/apps/web/src/pages/generate/hooks/useSegmentScheduler.ts @@ -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]) // 组件卸载时清理