From 2c03ff05f251f620bef08ace4f98bb7b30e954ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=8F=E6=9D=B0?= Date: Wed, 19 Aug 2026 11:18:24 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E8=A7=86=E9=A2=91=E9=A2=84=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E4=BC=98=E5=8C=96+=E9=85=8D=E9=9F=B3=E6=97=B6?= =?UTF-8?q?=E9=95=BF=E6=A0=A1=E9=AA=8C=E6=8F=90=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. useSegmentScheduler: 挂载后强制调用 video.load() 预加载所有片段 2. useSegmentScheduler: 片段切换时先检查 readyState>=3 再 play(),否则监听 canplay + 300ms 超时兜底 3. FrontendPreviewPlayer: display:none/block 改为 opacity:0/1 + pointer-events + z-index,保持所有 video 在渲染树中 4. GeneratePage: 计算 totalVideoDuration 传给配音选择组件 5. Step5VoiceSelect: 新增 totalVideoDuration prop,配音时长不足时显示红色警告标签,选中时弹出 Modal 确认 --- apps/web/src/pages/generate/GeneratePage.tsx | 15 ++++ .../components/FrontendPreviewPlayer.tsx | 3 +- .../components/GenerateStepContent.tsx | 3 + .../generate/components/Step5VoiceSelect.tsx | 86 ++++++++++++++++++- .../generate/hooks/useSegmentScheduler.ts | 31 ++++++- 5 files changed, 129 insertions(+), 9 deletions(-) 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..e7b6637a2 100644 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx @@ -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/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]) // 组件卸载时清理 -- 2.54.0 From bd1baaaee32c97731ee00b4e5b3d86a480dc8b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=8F=E6=9D=B0?= Date: Wed, 19 Aug 2026 11:24:40 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20ESLint=20?= =?UTF-8?q?=E8=AD=A6=E5=91=8A=20(react-hooks/exhaustive-deps)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/src/pages/generate/components/FrontendPreviewPlayer.tsx | 2 +- apps/web/src/pages/generate/components/PreviewVideoPanel.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx index e7b6637a2..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( 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, -- 2.54.0