From 8278456cd4e7dc7e4de77c5ec28b8cc7247d11e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Mon, 14 Sep 2026 01:12:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E4=BF=AE=E5=A4=8D=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=8F=98=E4=BD=93=E9=A2=84=E8=A7=883=E4=B8=AAP0=20bug?= =?UTF-8?q?=E2=80=94=E2=80=94=E9=85=8D=E9=9F=B3=E5=8F=82=E6=95=B0=E4=BC=A0?= =?UTF-8?q?=E9=80=92/=E9=9F=B3=E7=94=BB=E6=97=B6=E9=95=BF=E5=AF=B9?= =?UTF-8?q?=E9=BD=90/=E7=B1=BB=E5=9E=8B=E8=A1=A5=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/api/generation/variantPlans.ts | 6 + apps/web/src/pages/generate/GeneratePage.tsx | 10 + .../generate/components/CanvasPreviewGrid.tsx | 8 + .../components/FrontendPreviewPlayer.tsx | 175 ++++++++++++++++-- .../generate/hooks/useBatchVariantPlans.ts | 61 +++++- 5 files changed, 244 insertions(+), 16 deletions(-) diff --git a/apps/web/src/api/generation/variantPlans.ts b/apps/web/src/api/generation/variantPlans.ts index b8fa4b116..554ad09f1 100644 --- a/apps/web/src/api/generation/variantPlans.ts +++ b/apps/web/src/api/generation/variantPlans.ts @@ -26,6 +26,10 @@ export interface BatchVariantPlansRequest { count: number /** 源剪辑计划 ID:优先取预览/草稿关联的 plan;不传由后端按 template_id+user 兜底最新 plan */ source_edit_plan_id?: string + /** 统一配音 ID(共用配音模式);独立配音模式不传,改传 voice_library_ids */ + voice_library_id?: string + /** 独立配音 ID 列表(长度=count,按变体序号一一对应);共用配音模式不传 */ + voice_library_ids?: string[] } /** 单个变体的计划片段 */ @@ -36,6 +40,8 @@ export interface VariantPlan { plan_id: string /** 该变体的真实片段(顺序/素材/起点与正式成片一致) */ clips: EditPlanClip[] + /** 该变体实际配音时长(秒),用于前端预览按配音时长对齐音画;后端暂未返回时缺省 */ + voice_duration?: number } /** 批量变体计划响应 */ diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 54c0863b9..7f70ef6d2 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -231,9 +231,15 @@ const GeneratePage: React.FC = () => { /* ── 批量变体真实片段(#1744):后端独立选片,预览即成片;失败静默降级本地模拟 ── 仅批量(N>1)且在第 4 步预览时申请,避免选素材阶段频繁请求; 变体 0 沿用草稿 plan(与单视频一致),变体 1..N-1 后端 reselect 独立选片 */ + // P0 fix:批量变体计划请求需携带配音参数,避免后端按"无配音"选片导致 clips 时长与配音错位 + const batchVoiceLibraryId = + voiceMode === "clone" + ? selectedClonedVoice || selectedVoice || "" + : selectedVoice || "" const { clipsByVariant: variantClips, planIdsByVariant: variantPlanIds, + voiceDurationsByVariant: variantVoiceDurations, loading: variantClipsLoading, error: variantClipsError, retry: retryVariantClips, @@ -243,6 +249,9 @@ const GeneratePage: React.FC = () => { templateId: selectedTemplate || "", assetIds: previewAssetIds, sourcePlanId: storedSourceEditPlanId || sourceEditPlanId || "", + voiceLibraryId: batchVoiceLibraryId, + voiceLibraryIds: voiceLibraryIds || [], + voiceModePerVideo, }) /* ── 批量变体配音预览 URL(#1750):独立模式每变体挂各自配音,共用模式全挂同一条; @@ -476,6 +485,7 @@ const GeneratePage: React.FC = () => { titles={previewTitles} titleSettings={titleSettings} voiceAudioUrls={variantVoiceAudioUrls} + voiceDurations={variantVoiceDurations} variantClips={variantClips} clipsLoading={variantClipsLoading} clipsError={variantClipsError} diff --git a/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx b/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx index bcf2876c2..20a9891ef 100644 --- a/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx +++ b/apps/web/src/pages/generate/components/CanvasPreviewGrid.tsx @@ -31,6 +31,12 @@ interface CanvasPreviewGridProps { * 元素为 null 表示该变体暂无音频(AI 音色 TTS 合成中)) */ voiceAudioUrls?: (string | null)[] + /** + * 各变体配音时长(秒):后端返回 voice_duration 优先;未返回则为 undefined, + * 由 FrontendPreviewPlayer 在 audio loadedmetadata 时自测兜底。 + * 长度=count,undefined 项表示该变体未提供后端时长。 + */ + voiceDurations?: (number | undefined)[] /** * 各变体的后端真实片段(#1744/#1750):长度=count。 * 仅 clipsLoading=false 且 clipsError=false 时才会传给播放器。 @@ -56,6 +62,7 @@ const CanvasPreviewGrid: React.FC = ({ titles, titleSettings, voiceAudioUrls, + voiceDurations, variantClips, clipsLoading = false, clipsError = false, @@ -120,6 +127,7 @@ const CanvasPreviewGrid: React.FC = ({ serverClips={variantClips[i]} variantTitle={titles[i] || ""} voiceAudioUrl={voiceAudioUrls?.[i] || undefined} + voiceDurationHint={voiceDurations?.[i]} activePlayToken={activePlayToken} onPlayTokenChange={setActivePlayToken} compact diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx index 432861953..c2c3f7adb 100644 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx @@ -58,6 +58,11 @@ interface FrontendPreviewPlayerProps { activePlayToken?: number | null /** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */ onPlayTokenChange?: (token: number | null) => void + /** + * 后端返回的配音时长(秒)P0 对齐:优先以该值作为音画时长锚点; + * 未提供则在 audio loadedmetadata 后自测兜底。 + */ + voiceDurationHint?: number } function formatTime(seconds: number): string { @@ -116,6 +121,7 @@ const FrontendPreviewPlayer: React.FC = ({ ready, serverClips, voiceAudioUrl, + voiceDurationHint, titleSettings, onTitlePositionChange, playToken, @@ -124,17 +130,26 @@ const FrontendPreviewPlayer: React.FC = ({ activePlayToken = null, onPlayTokenChange, }) => { - // #1754:测量配音时长,计算缩放因子 - const [voiceDuration, setVoiceDuration] = useState(0) + // #1754→P0:配音时长作为音画时长锚点。 + // 优先使用后端返回的 voiceDurationHint;音频 loadedmetadata 后再以自测值覆盖(更精确)。 + const [voiceDuration, setVoiceDuration] = useState(() => + voiceDurationHint && voiceDurationHint > 0 ? voiceDurationHint : 0, + ) + // 当外部 hint 变化且尚未拿到自测值时先同步 + useEffect(() => { + if (voiceDurationHint && voiceDurationHint > 0) { + setVoiceDuration((prev) => (prev > 0 ? prev : voiceDurationHint)) + } + }, [voiceDurationHint]) useEffect(() => { if (!voiceAudioUrl) { - setVoiceDuration(0) + setVoiceDuration((prev) => (voiceDurationHint && voiceDurationHint > 0 ? voiceDurationHint : 0)) return } const audio = new Audio() audio.preload = "metadata" const onLoaded = () => { - if (audio.duration && isFinite(audio.duration)) { + if (audio.duration && isFinite(audio.duration) && audio.duration > 0) { setVoiceDuration(audio.duration) } } @@ -143,7 +158,7 @@ const FrontendPreviewPlayer: React.FC = ({ return () => { audio.removeEventListener("loadedmetadata", onLoaded) } - }, [voiceAudioUrl]) + }, [voiceAudioUrl, voiceDurationHint]) // #1756:clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位) const rawClipsDuration = useMemo(() => { @@ -351,6 +366,13 @@ const FrontendPreviewPlayer: React.FC = ({ const canPlay = effectiveUseWebCodecs ? canvasState.isReady : videoCanPlay const isBuffering = effectiveUseWebCodecs ? canvasState.isBuffering : false + // P0 fix:以配音时长为音画同步锚点。有配音时,播放器总时长 = 配音时长; + // - 视频比配音短:播完后末帧冻结,直至配音结束自动暂停 + // - 视频比配音长:到配音时长点硬停,截断视频 + // 无配音时沿用视频总时长(单视频原声兜底路径)。 + const effectiveTotalDuration = + !!voiceAudioUrl && voiceDuration > 0 ? voiceDuration : totalDuration + // ── 配音音频同步 ── const audioRef = useRef(null) const prevIsPlayingRef = useRef(false) @@ -400,8 +422,83 @@ const FrontendPreviewPlayer: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [segmentSyncKey, isPlaying]) + // P0 fix:视频比配音短时的「末帧冻结+音频续播」模式。 + // 视频调度器播完最后一段会自动 pause + setIsPlaying(false),此时若配音仍在播, + // 用虚拟时钟继续推进 currentTime 直到配音时长,期间保持音频播放、视频停在末帧。 + const [tailCurrentTime, setTailCurrentTime] = useState(null) + const tailStartRef = useRef(0) + const tailBaseRef = useRef(0) + useEffect(() => { + // 进入尾段条件:有配音 + 视频已停 + 配音还没播完 + const needTail = + !!voiceAudioUrl && + voiceDuration > 0 && + !isPlaying && + !isDragging && + typeof currentTime === "number" && + currentTime >= totalDuration - 0.1 && + currentTime < voiceDuration - 0.1 + if (needTail && tailCurrentTime === null) { + tailBaseRef.current = currentTime + tailStartRef.current = performance.now() + setTailCurrentTime(currentTime) + const a = audioRef.current + if (a && a.src) { + try { + if (Math.abs(a.currentTime - currentTime) > 0.3) a.currentTime = currentTime + a.play().catch(() => {}) + } catch { + /* ignore */ + } + } + return + } + if (!needTail && tailCurrentTime !== null) { + setTailCurrentTime(null) + } + }, [isPlaying, currentTime, totalDuration, voiceDuration, voiceAudioUrl, isDragging, tailCurrentTime]) + + useEffect(() => { + if (tailCurrentTime === null) return + let raf = 0 + const tick = () => { + const elapsed = (performance.now() - tailStartRef.current) / 1000 + const t = Math.min(tailBaseRef.current + elapsed, voiceDuration || tailBaseRef.current) + setTailCurrentTime(t) + const a = audioRef.current + if (a && a.src && !a.paused && Math.abs(a.currentTime - t) > 0.5) { + try { + a.currentTime = t + } catch { + /* ignore */ + } + } + if (t >= (voiceDuration || 0) - 0.05) { + // 到达配音结尾:暂停音频,复位 + if (a) { + try { + a.pause() + } catch { + /* ignore */ + } + } + if (playToken != null) onPlayTokenChange?.(null) + setTailCurrentTime(null) + return + } + raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [tailCurrentTime, voiceDuration, playToken, onPlayTokenChange]) + + // 呈现给 UI/进度条的「当前时间」:尾段用虚拟时间,否则用视频/Canvas 时间 + const displayCurrentTime = tailCurrentTime !== null ? tailCurrentTime : currentTime + const handleSeekTo = useCallback( (time: number) => { + // 退出尾段模式,回到视频驱动 + setTailCurrentTime(null) if (effectiveUseWebCodecs) { canvasControls.seek(time) } else { @@ -458,12 +555,12 @@ const FrontendPreviewPlayer: React.FC = ({ const handleProgressClick = useCallback( (e: React.MouseEvent) => { - if (!progressRef.current || totalDuration <= 0) return + if (!progressRef.current || effectiveTotalDuration <= 0) return const rect = progressRef.current.getBoundingClientRect() const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) - handleSeekTo(ratio * totalDuration) + handleSeekTo(ratio * effectiveTotalDuration) }, - [totalDuration, handleSeekTo], + [effectiveTotalDuration, handleSeekTo], ) const handleMouseDown = useCallback( @@ -477,10 +574,10 @@ const FrontendPreviewPlayer: React.FC = ({ useEffect(() => { if (!isDragging) return const handleMouseMove = (e: MouseEvent) => { - if (!progressRef.current || totalDuration <= 0) return + if (!progressRef.current || effectiveTotalDuration <= 0) return const rect = progressRef.current.getBoundingClientRect() const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) - handleSeekTo(ratio * totalDuration) + handleSeekTo(ratio * effectiveTotalDuration) } const handleMouseUp = () => setIsDragging(false) window.addEventListener("mousemove", handleMouseMove) @@ -489,13 +586,65 @@ const FrontendPreviewPlayer: React.FC = ({ window.removeEventListener("mousemove", handleMouseMove) window.removeEventListener("mouseup", handleMouseUp) } - }, [isDragging, totalDuration, handleSeekTo]) + }, [isDragging, effectiveTotalDuration, handleSeekTo]) - const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0 + + const progressPercent = effectiveTotalDuration > 0 ? (displayCurrentTime / effectiveTotalDuration) * 100 : 0 // ── Canvas 容器 ref(保留声明,WebCodecs 兜底路径仍引用) ── const canvasContainerRef = useRef(null) + // P0 fix:音画同步看门狗——有配音时,播放时间达到配音时长立即暂停视频+音频(末帧冻结), + // 防止视频比配音长时继续播放、或视频播完后音频继续拖尾;音频自身 ended 也走同一暂停路径。 + useEffect(() => { + if (!isPlaying) return + if (!voiceAudioUrl || voiceDuration <= 0) return + if (displayCurrentTime < voiceDuration - 0.08) return + if (effectiveUseWebCodecs) { + canvasControls.pause() + } else { + videoPause() + } + const a = audioRef.current + if (a) { + try { + a.pause() + } catch { + /* ignore */ + } + } + if (playToken != null) onPlayTokenChange?.(null) + }, [ + isPlaying, + displayCurrentTime, + voiceAudioUrl, + voiceDuration, + effectiveUseWebCodecs, + canvasControls, + videoPause, + playToken, + onPlayTokenChange, + ]) + + // 音频自然结束时同步暂停视频(兜底:浏览器音频 ended 可能早于/晚于 currentTime 看门狗触发) + useEffect(() => { + const a = audioRef.current + if (!a) return + const onEnded = () => { + if (!isPlaying) return + if (effectiveUseWebCodecs) { + canvasControls.pause() + } else { + videoPause() + } + if (playToken != null) onPlayTokenChange?.(null) + } + a.addEventListener("ended", onEnded) + return () => { + a.removeEventListener("ended", onEnded) + } + }, [isPlaying, effectiveUseWebCodecs, canvasControls, videoPause, playToken, onPlayTokenChange]) + // ── 未就绪 ── if (!ready || !assets.length) { return ( @@ -888,7 +1037,7 @@ const FrontendPreviewPlayer: React.FC = ({ letterSpacing: 0.2, }} > - {formatTime(currentTime)} / {formatTime(totalDuration)} + {formatTime(displayCurrentTime)} / {formatTime(effectiveTotalDuration)}
([]) const [planIdsByVariant, setPlanIdsByVariant] = useState([]) + const [voiceDurationsByVariant, setVoiceDurationsByVariant] = useState<(number | undefined)[]>([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(false) @@ -70,17 +82,30 @@ export function useBatchVariantPlans({ setLoading(true) setError(false) try { + // 配音参数:与 useGenerateVideo 保持一致的传参逻辑 + // - 独立配音模式 + voiceLibraryIds 非空:传 voice_library_ids + // - 统一配音:传 voice_library_id + // - 都没选:不传 + const voiceParam: { voice_library_id?: string; voice_library_ids?: string[] } = {} + if (voiceModePerVideo && voiceLibraryIds.length > 0) { + voiceParam.voice_library_ids = voiceLibraryIds + } else if (voiceLibraryId) { + voiceParam.voice_library_id = voiceLibraryId + } + const resp = await createBatchVariantPlans({ template_id: templateId, asset_ids: assetIds, count, ...(sourcePlanId ? { source_edit_plan_id: sourcePlanId } : {}), + ...voiceParam, }) if (seq !== requestSeqRef.current) return const items: VariantPlan[] = Array.isArray(resp.items) ? resp.items : [] const clips: EditPlanClip[][] = Array.from({ length: count }, () => []) const planIds: string[] = Array.from({ length: count }, () => "") + const voiceDurs: (number | undefined)[] = Array.from({ length: count }, () => undefined) for (const item of items) { const idx = item.variant_index if (idx < 0 || idx >= count) continue @@ -88,6 +113,9 @@ export function useBatchVariantPlans({ clips[idx] = (item.clips || []) .filter((c) => c && c.asset_id && c.status === "ready") .sort((a, b) => a.order - b.order) + if (typeof item.voice_duration === "number" && item.voice_duration > 0) { + voiceDurs[idx] = item.voice_duration + } } // 数据完整性校验:每个变体都必须有真实片段,否则视为失败(不允许假数据冒充) const incomplete = clips.some((list) => list.length === 0) @@ -95,10 +123,12 @@ export function useBatchVariantPlans({ console.warn("[useBatchVariantPlans] 变体计划数据不完整(存在空片段变体),标记加载失败") setClipsByVariant([]) setPlanIdsByVariant([]) + setVoiceDurationsByVariant([]) setError(true) } else { setClipsByVariant(clips) setPlanIdsByVariant(planIds) + setVoiceDurationsByVariant(voiceDurs) setError(false) } } catch (err) { @@ -107,11 +137,20 @@ export function useBatchVariantPlans({ console.warn("[useBatchVariantPlans] 申请变体计划失败,预览加载失败:", err) setClipsByVariant([]) setPlanIdsByVariant([]) + setVoiceDurationsByVariant([]) setError(true) } finally { if (seq === requestSeqRef.current) setLoading(false) } - }, [templateId, count, sourcePlanId, assetIds]) + }, [ + templateId, + count, + sourcePlanId, + assetIds, + voiceLibraryId, + voiceLibraryIds, + voiceModePerVideo, + ]) /** 用户点击「重试」:nonce +1 驱动 effect 重新发起请求(effect 内 lastKey 校验保证只发一次) */ const retry = useCallback(() => { @@ -125,24 +164,40 @@ export function useBatchVariantPlans({ // 避免父组件传入内联字面量数组导致 effect 每次 render 触发 → 无限 setState 循环 setClipsByVariant((prev) => (prev.length === 0 ? prev : [])) setPlanIdsByVariant((prev) => (prev.length === 0 ? prev : [])) + setVoiceDurationsByVariant((prev) => (prev.length === 0 ? prev : [])) setLoading((prev) => (prev === false ? prev : false)) setError((prev) => (prev === false ? prev : false)) lastKeyRef.current = "" return } + const voiceKey = voiceModePerVideo + ? `per:${[...voiceLibraryIds].sort().join(",")}` + : `one:${voiceLibraryId}` const key = `${retryNonce}|${templateId}|${count}|${sourcePlanId}|${[...assetIds] .sort() - .join(",")}` + .join(",")}|${voiceKey}` if (key === lastKeyRef.current) return lastKeyRef.current = key load() - }, [enabled, templateId, count, sourcePlanId, assetIds, load, retryNonce]) + }, [ + enabled, + templateId, + count, + sourcePlanId, + assetIds, + load, + retryNonce, + voiceLibraryId, + voiceLibraryIds, + voiceModePerVideo, + ]) const ready = !error && !loading && clipsByVariant.every((list) => list.length > 0) return { clipsByVariant, planIdsByVariant, + voiceDurationsByVariant, loading, ready, error,