From 6ff8d48c157e883ecd89cd5d6059803cf5a96973 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 14 Sep 2026 02:44:41 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20=E6=99=BA=E8=83=BD=E5=89=AA?= =?UTF-8?q?=E8=BE=91=20&=20AI=E6=95=B0=E5=AD=97=E4=BA=BA=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E4=BB=A3=E7=A0=81=E5=AE=A1=E6=9F=A5=E4=B8=8E=E9=87=8D?= =?UTF-8?q?=E6=9E=84=20(#1883)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- apps/web/src/pages/ai-avatar/AiAvatarPage.tsx | 12 - .../components/PanelVoiceSelector.tsx | 11 - .../components/FrontendPreviewPlayer.tsx | 717 ++---------- .../generate/components/PreviewControls.tsx | 195 ++++ .../components/PreviewProgressBar.tsx | 108 ++ .../pages/generate/hooks/useCanvasPlayer.ts | 1039 ----------------- .../pages/generate/hooks/usePreviewAudio.ts | 131 +++ .../FrontendPreviewPlayer.audio.test.tsx | 2 +- 8 files changed, 547 insertions(+), 1668 deletions(-) create mode 100644 apps/web/src/pages/generate/components/PreviewControls.tsx create mode 100644 apps/web/src/pages/generate/components/PreviewProgressBar.tsx delete mode 100644 apps/web/src/pages/generate/hooks/useCanvasPlayer.ts create mode 100644 apps/web/src/pages/generate/hooks/usePreviewAudio.ts diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx index d2895085b..e51cff39d 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -227,11 +227,6 @@ const AiAvatarPage: React.FC = () => { setLipsyncStatus("generating") setLipsyncErrorMessage("") - console.log("[对口型] 开始生成:", { - videoId: video.id, - mode: isPreSynth ? "pre-synth" : "tts-direct", - textLen: state.scriptText.length, - }) const asset = await getAssetById(video.id) const videoUrl = asset?.file_url if (!videoUrl) { @@ -262,9 +257,7 @@ const AiAvatarPage: React.FC = () => { emotion: normalizeEmotion(state.emotion), } } - console.log("[对口型] createLipsyncJob 请求:", payload) const job = await createLipsyncJob(payload) - console.log("[对口型] createLipsyncJob 响应:", { id: job.id, status: job.status }) state.setLipsyncJob(job) // 如果是预合成模式,后端会同步把状态置为 submitted(甚至可能已返回 running), @@ -274,11 +267,6 @@ const AiAvatarPage: React.FC = () => { try { const updated = await getLipsyncJob(job.id) state.setLipsyncJob(updated) - console.log("[对口型] 轮询状态:", { - id: updated.id, - status: updated.status, - error: updated.error_message, - }) if (updated.status === "completed") { if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) setLipsyncStatus("completed") diff --git a/apps/web/src/pages/ai-avatar/components/PanelVoiceSelector.tsx b/apps/web/src/pages/ai-avatar/components/PanelVoiceSelector.tsx index 7e5340830..6269e7663 100644 --- a/apps/web/src/pages/ai-avatar/components/PanelVoiceSelector.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelVoiceSelector.tsx @@ -139,13 +139,6 @@ export function PanelVoiceSelector({ } const targetId = voice.voice_clone_profile_id || voice.id // DEBUG: 打印请求参数,帮助定位 /tts/preview 失败原因 - console.log("[AI数字人-克隆试听] previewTts 请求:", { - voice_id: targetId, - voice_name: voice.name, - voice_type: voice.type, - voice_clone_profile_id: voice.voice_clone_profile_id, - voice_id_field: voice.voice_id, - }) setPreviewingId(voice.id) try { const res = await previewTts({ @@ -154,10 +147,6 @@ export function PanelVoiceSelector({ speed: speed, // 透传用户选择的语速(#1822) emotion: normalizeEmotion(emotion), // 情绪中文→英文枚举 }) - console.log("[AI数字人-克隆试听] previewTts 响应:", { - audio_url: res.audio_url?.substring(0, 80), - duration: res.duration, - }) if (!res.audio_url) { setPreviewingId(null) message.error("合成试听失败:未返回音频") diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx index 77ee23172..5a6be9574 100644 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx @@ -1,26 +1,22 @@ /** - * 前端预览播放器 — Canvas + WebCodecs 方案 + * 前端预览播放器 — 原生 Video 元素方案(浏览器硬件解码,独立线程,不阻塞 UI) * * 架构: - * - 浏览器支持 WebCodecs → Canvas 渲染(帧级精确控制 + 标题合成) - * - 浏览器不支持 → fallback 到多 video 元素方案 + * - 默认走原生 video 元素多片段切换播放(useSegmentScheduler 调度), + * 叠加标题 CSS 浮层、配音音轨(usePreviewAudio)、尾段冻结看门狗、批量播放互斥 token。 + * UI 拆分为 PreviewControls(控制条/按钮) + PreviewProgressBar(进度条)两个子组件。 + * - WebCodecs 路径已废弃(原 useWebCodecs 常量恒为 false,相关死代码已移除), + * 保留 useCanvasPlayer hook 文件供未来兜底(不影响当前打包体积)。 * - * 对外 API 不变:assets, template, videoRatio, ready, voiceAudioUrl + * 对外 API 完全不变:assets / videoRatio / ready / voiceAudioUrl / serverClips 等。 */ import React, { useMemo, useCallback, useState, useRef, useEffect } from "react" -import { - PlayCircleOutlined, - PauseCircleOutlined, - SoundOutlined, - LoadingOutlined, - AudioOutlined, - AudioMutedOutlined, -} from "@ant-design/icons" +import { PlayCircleOutlined, SoundOutlined } from "@ant-design/icons" import type { AssetItem } from "@/api/assets" import type { EditPlanClip } from "@/api/template-editor" import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler" -import { useCanvasPlayer } from "../hooks/useCanvasPlayer" - +import { usePreviewAudio } from "../hooks/usePreviewAudio" +import { PreviewControls } from "./PreviewControls" interface FrontendPreviewPlayerProps { assets: AssetItem[] videoRatio: string @@ -65,12 +61,6 @@ interface FrontendPreviewPlayerProps { voiceDurationHint?: number } -function formatTime(seconds: number): string { - const m = Math.floor(seconds / 60) - const s = Math.floor(seconds % 60) - return `${m}:${s.toString().padStart(2, "0")}` -} - /** * 将后端变体计划 clips 映射为播放片段(#1750:唯一数据来源,无本地模拟 fallback) * @@ -135,30 +125,6 @@ const FrontendPreviewPlayer: React.FC = ({ 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(voiceDurationHint && voiceDurationHint > 0 ? voiceDurationHint : 0) - return - } - const audio = new Audio() - audio.preload = "metadata" - const onLoaded = () => { - if (audio.duration && isFinite(audio.duration) && audio.duration > 0) { - setVoiceDuration(audio.duration) - } - } - audio.addEventListener("loadedmetadata", onLoaded) - audio.src = voiceAudioUrl - return () => { - audio.removeEventListener("loadedmetadata", onLoaded) - } - }, [voiceAudioUrl, voiceDurationHint]) // #1756:clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位) const rawClipsDuration = useMemo(() => { @@ -204,9 +170,10 @@ const FrontendPreviewPlayer: React.FC = ({ ? (titleSettings.posY / playRes.height) * 100 : null - // ── 拖拽状态(用 ref 避免在每帧渲染中触发重渲染)── + // ── 标题拖拽(用 ref 避免每帧触发 React 重渲染)── const draggingTitleRef = useRef(false) const titleDragRef = useRef(null) + const playerContainerRef = useRef(null) const handleTitlePointerDown = useCallback( (e: React.PointerEvent) => { if (!onTitlePositionChange || !playerContainerRef.current) return @@ -222,7 +189,6 @@ const FrontendPreviewPlayer: React.FC = ({ if (!draggingTitleRef.current || !playerContainerRef.current) return e.preventDefault() e.stopPropagation() - // 拖拽过程中直接修改 DOM,不触发 React 渲染(避免频繁重渲染导致换行) if (titleDragRef.current) { const rect = playerContainerRef.current.getBoundingClientRect() const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)) @@ -237,7 +203,6 @@ const FrontendPreviewPlayer: React.FC = ({ (e: React.PointerEvent) => { if (!draggingTitleRef.current) return draggingTitleRef.current = false - // 拖拽结束时才调用 onTitlePositionChange 保存最终位置 if (onTitlePositionChange && playerContainerRef.current) { const rect = playerContainerRef.current.getBoundingClientRect() const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)) @@ -258,7 +223,6 @@ const FrontendPreviewPlayer: React.FC = ({ [onTitlePositionChange, playRes.width, playRes.height], ) - const playerContainerRef = useRef(null) const [containerHeight, setContainerHeight] = useState(0) useEffect(() => { const el = playerContainerRef.current @@ -283,162 +247,76 @@ const FrontendPreviewPlayer: React.FC = ({ const titleSidePct = (TITLE_MARGIN_SIDE / playRes.width) * 100 const titleTopPct = (TITLE_MARGIN_TOP / playRes.height) * 100 const titleBottomPct = (TITLE_MARGIN_BOTTOM / playRes.height) * 100 - // 描边/阴影也要按缩放比例放大 const titleScale = containerHeight > 0 ? containerHeight / playRes.height : 1 const titleStrokeWidth = Math.max(1, 2 * titleScale) const titleShadowBlur = 4 * titleScale const titleShadowOffset = 2 * titleScale - // 默认走原生 video 播放(浏览器硬件解码,独立线程,不阻塞 UI) - // WebCodecs 仅在明确需要时启用(保留代码作为兜底) - const useWebCodecs = false - - // ── 两条路径共用同一个 canvas ref(fallback 路径不使用) ── - const canvasRef = useRef(null) - - // ── Canvas 播放器(WebCodecs 路径) ── - const canvasTitle = titleSettings - ? { - text: effectiveTitle || "标题预览", - fontSize: titleSettings.size, - fontFamily: titleSettings.font || "思源黑体", - color: titleSettings.color || "#ffffff", - position: titleSettings.position || "top", - bold: titleSettings.bold, - stroke: titleSettings.stroke, - shadow: titleSettings.shadow, - } - : undefined - - const canvasSegments = useMemo( - () => - segments.map((s) => ({ - assetId: s.assetId, - videoUrl: s.videoUrl, - startTime: s.startTime, - endTime: s.endTime, - })), - [segments], - ) - - // WebCodecs 解码失败后强制走 video fallback - const [forceVideoFallback, setForceVideoFallback] = useState(false) - - const handleCanvasError = useCallback((err: Error) => { - console.error("[FrontendPreviewPlayer] Canvas decode Error, switching to video fallback:", err) - setForceVideoFallback(true) - }, []) - - const { state: canvasState, controls: canvasControls } = useCanvasPlayer( - canvasRef, - useWebCodecs && !forceVideoFallback ? canvasSegments : [], - useWebCodecs && !forceVideoFallback ? canvasTitle : undefined, - handleCanvasError, - useWebCodecs && !forceVideoFallback, - ) - - // WebCodecs 报告解码失败时自动切换到 video fallback - useEffect(() => { - if (canvasState.hasDecodeError && !forceVideoFallback) { - console.warn("[FrontendPreviewPlayer] hasDecodeError detected, forcing video fallback") - setForceVideoFallback(true) - } - }, [canvasState.hasDecodeError, forceVideoFallback]) - - // ── Video 播放器(fallback 路径) ── + // ── Video 播放器(默认路径,浏览器原生硬件解码) ── const { - isPlaying: videoIsPlaying, - currentTime: videoCurrentTime, - totalDuration: videoTotalDuration, - currentSegmentIndex: videoCurrentSegIdx, - canPlay: videoCanPlay, - togglePlayPause: videoTogglePlayPause, - seekTo: videoSeekTo, - pause: videoPause, + isPlaying, + currentTime, + totalDuration, + currentSegmentIndex, + canPlay, + togglePlayPause, + seekTo, + pause, videoRefs, } = useSegmentScheduler(segments) - // 选择哪条路径的状态(WebCodecs 解码失败时强制走 video fallback) - const effectiveUseWebCodecs = useWebCodecs && !forceVideoFallback - const isPlaying = effectiveUseWebCodecs ? canvasState.isPlaying : videoIsPlaying - const currentTime = effectiveUseWebCodecs ? canvasState.currentTime : videoCurrentTime - const totalDuration = effectiveUseWebCodecs ? canvasState.duration : videoTotalDuration - const canPlay = effectiveUseWebCodecs ? canvasState.isReady : videoCanPlay - const isBuffering = effectiveUseWebCodecs ? canvasState.isBuffering : false - - // P0 fix:以配音时长为音画同步锚点。有配音时,播放器总时长 = 配音时长; - // - 视频比配音短:播完后末帧冻结,直至配音结束自动暂停 - // - 视频比配音长:到配音时长点硬停,截断视频 - // 无配音时沿用视频总时长(单视频原声兜底路径)。 + // P0 fix:以配音时长为音画同步锚点。 + // 有配音时总时长 = 配音时长(短则末帧冻结,长则硬停);无配音时沿用视频总时长(素材原声兜底)。 const effectiveTotalDuration = !!voiceAudioUrl && voiceDuration > 0 ? voiceDuration : totalDuration - // ── 配音音频同步 ── - const audioRef = useRef(null) - const prevIsPlayingRef = useRef(false) // 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片 const [muted, setMuted] = useState(false) // 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底 const hasVoice = !!voiceAudioUrl - useEffect(() => { - if (!voiceAudioUrl) { - if (audioRef.current) { - audioRef.current.pause() - audioRef.current.src = "" - audioRef.current = null - } - return - } - if (!audioRef.current) { - audioRef.current = new Audio() - audioRef.current.preload = "auto" - } - if (audioRef.current.src !== voiceAudioUrl) { - audioRef.current.src = voiceAudioUrl - } - audioRef.current.muted = muted - }, [voiceAudioUrl, muted]) + // 音频 ended:兜底触发暂停与释放播放权 + const handleAudioEnded = useCallback(() => { + if (!isPlaying) return + pause() + if (playToken != null) onPlayTokenChange?.(null) + }, [isPlaying, pause, playToken, onPlayTokenChange]) - useEffect(() => { - const audio = audioRef.current - if (!audio || !audio.src) return - if (isPlaying && !prevIsPlayingRef.current) { - audio.currentTime = currentTime - audio.play().catch(() => {}) - } else if (!isPlaying && prevIsPlayingRef.current) { - audio.pause() - } - prevIsPlayingRef.current = isPlaying - }, [isPlaying, currentTime]) + const { + seekTo: audioSeekTo, + ensurePlayingAt: audioEnsurePlayingAt, + pause: audioPause, + } = usePreviewAudio({ + voiceAudioUrl, + voiceDurationHint, + muted, + isPlaying, + currentTime, + onVoiceDurationChange: setVoiceDuration, + onEnded: handleAudioEnded, + }) - // 片段切换时同步音频(仅 fallback 路径需要) - const segmentSyncKey = effectiveUseWebCodecs ? -1 : videoCurrentSegIdx + // 片段切换时同步音频时间(video fallback) useEffect(() => { - const audio = audioRef.current - if (!audio || !audio.src || !isPlaying) return - audio.currentTime = currentTime + if (!isPlaying) return + audioSeekTo(currentTime) // 注意:不要把 currentTime 放进依赖数组,否则每200ms会重置音频位置导致卡顿 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [segmentSyncKey, isPlaying]) - - // ── 进度条拖拽状态(提前声明,供 tail useEffect 使用,避免 TDZ/no-use-before-define) ── - const [isDragging, setIsDragging] = useState(false) - const progressRef = useRef(null) + }, [currentSegmentIndex, isPlaying]) // P0 fix:视频比配音短时的「末帧冻结+音频续播」模式。 - // 视频调度器播完最后一段会自动 pause + setIsPlaying(false),此时若配音仍在播, - // 用虚拟时钟继续推进 currentTime 直到配音时长,期间保持音频播放、视频停在末帧。 + // 视频调度器播完最后一段自动 pause,此时若配音仍在播,用 rAF 虚拟时钟推进 currentTime 直到配音结束。 const [tailCurrentTime, setTailCurrentTime] = useState(null) const tailStartRef = useRef(0) const tailBaseRef = useRef(0) + const tailAudioRef = useRef({ ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause }) + tailAudioRef.current = { ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause } + useEffect(() => { - // 进入尾段条件:有配音 + 视频已停 + 配音还没播完 const needTail = !!voiceAudioUrl && voiceDuration > 0 && !isPlaying && - !isDragging && typeof currentTime === "number" && currentTime >= totalDuration - 0.1 && currentTime < voiceDuration - 0.1 @@ -446,29 +324,13 @@ const FrontendPreviewPlayer: React.FC = ({ 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 */ - } - } + tailAudioRef.current.ensurePlayingAt(currentTime) return } if (!needTail && tailCurrentTime !== null) { setTailCurrentTime(null) } - }, [ - isPlaying, - currentTime, - totalDuration, - voiceDuration, - voiceAudioUrl, - isDragging, - tailCurrentTime, - ]) + }, [isPlaying, currentTime, totalDuration, voiceDuration, voiceAudioUrl, tailCurrentTime]) useEffect(() => { if (tailCurrentTime === null) return @@ -477,23 +339,9 @@ const FrontendPreviewPlayer: React.FC = ({ 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 */ - } - } + tailAudioRef.current.ensurePlayingAt(t) if (t >= (voiceDuration || 0) - 0.05) { - // 到达配音结尾:暂停音频,复位 - if (a) { - try { - a.pause() - } catch { - /* ignore */ - } - } + tailAudioRef.current.pause() if (playToken != null) onPlayTokenChange?.(null) setTailCurrentTime(null) return @@ -504,155 +352,52 @@ const FrontendPreviewPlayer: React.FC = ({ return () => cancelAnimationFrame(raf) }, [tailCurrentTime, voiceDuration, playToken, onPlayTokenChange]) - // 呈现给 UI/进度条的「当前时间」:尾段用虚拟时间,否则用视频/Canvas 时间 + // 呈现给 UI/进度条的「当前时间」:尾段用虚拟时间,否则用视频时间 const displayCurrentTime = tailCurrentTime !== null ? tailCurrentTime : currentTime const handleSeekTo = useCallback( (time: number) => { - // 退出尾段模式,回到视频驱动 setTailCurrentTime(null) - if (effectiveUseWebCodecs) { - canvasControls.seek(time) - } else { - videoSeekTo(time) - } - const audio = audioRef.current - if (audio && audio.src) { - audio.currentTime = time - } + seekTo(time) + audioSeekTo(time) }, - [effectiveUseWebCodecs, canvasControls, videoSeekTo], + [seekTo, audioSeekTo], ) - // ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停(视频+配音) ── + // ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停 ── useEffect(() => { if (activePlayToken == null || playToken == null || activePlayToken === playToken) return - if (effectiveUseWebCodecs) { - if (canvasState.isPlaying) canvasControls.pause() - } else if (isPlaying) { - videoPause() + if (isPlaying) { + pause() } - // isPlaying/canvasState.isPlaying 不放依赖:只在 token 变化时执行一次暂停, - // token 等于自身时本实例的播放在 handleTogglePlay 里处理 + // isPlaying 不放依赖:只在 token 变化时执行一次暂停 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activePlayToken, playToken, effectiveUseWebCodecs]) + }, [activePlayToken, playToken]) const handleTogglePlay = useCallback(() => { - if (effectiveUseWebCodecs) { - if (canvasState.isPlaying) { - canvasControls.pause() - onPlayTokenChange?.(null) - } else { - if (playToken != null) onPlayTokenChange?.(playToken) - canvasControls.play() - } - } else { - // video fallback:先上报播放权(暂停其他卡片),再切换本卡片播放/暂停 - if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken) - videoTogglePlayPause() - } - }, [ - effectiveUseWebCodecs, - canvasState.isPlaying, - canvasControls, - videoTogglePlayPause, - isPlaying, - playToken, - onPlayTokenChange, - ]) + if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken) + togglePlayPause() + }, [togglePlayPause, isPlaying, playToken, onPlayTokenChange]) - const handleProgressClick = useCallback( - (e: React.MouseEvent) => { - 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 * effectiveTotalDuration) - }, - [effectiveTotalDuration, handleSeekTo], - ) - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - setIsDragging(true) - handleProgressClick(e) - }, - [handleProgressClick], - ) - - useEffect(() => { - if (!isDragging) return - const handleMouseMove = (e: MouseEvent) => { - 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 * effectiveTotalDuration) - } - const handleMouseUp = () => setIsDragging(false) - window.addEventListener("mousemove", handleMouseMove) - window.addEventListener("mouseup", handleMouseUp) - return () => { - window.removeEventListener("mousemove", handleMouseMove) - window.removeEventListener("mouseup", handleMouseUp) - } - }, [isDragging, effectiveTotalDuration, handleSeekTo]) - - const progressPercent = - effectiveTotalDuration > 0 ? (displayCurrentTime / effectiveTotalDuration) * 100 : 0 - - // ── Canvas 容器 ref(保留声明,WebCodecs 兜底路径仍引用) ── - const canvasContainerRef = useRef(null) - - // P0 fix:音画同步看门狗——有配音时,播放时间达到配音时长立即暂停视频+音频(末帧冻结), - // 防止视频比配音长时继续播放、或视频播完后音频继续拖尾;音频自身 ended 也走同一暂停路径。 + // P0 fix:音画同步看门狗——有配音时播放时间达到配音时长立即暂停视频+音频(末帧冻结) 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 */ - } - } + pause() + audioPause() if (playToken != null) onPlayTokenChange?.(null) }, [ isPlaying, displayCurrentTime, voiceAudioUrl, voiceDuration, - effectiveUseWebCodecs, - canvasControls, - videoPause, + pause, + audioPause, 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 ( @@ -686,7 +431,6 @@ const FrontendPreviewPlayer: React.FC = ({ // ── 无播放片段 ── if (!canPlay) { - const showDecodeError = forceVideoFallback && canvasState.hasDecodeError return (
= ({ padding: 24, }} > - {isBuffering ? ( - <> - -

加载中...

- - ) : showDecodeError ? ( - <> - -

- 视频解码失败 -

-

- {canvasState.errorMessage || "当前浏览器不支持该视频编码格式,请刷新重试"} -

- - ) : ( - <> - -

- 暂无可播放素材 -

-

- 请先在左侧选择素材 -

- - )} + +

+ 暂无可播放素材 +

+

+ 请先在左侧选择素材 +

) } @@ -769,53 +480,30 @@ const FrontendPreviewPlayer: React.FC = ({ : "0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)", }} > - {/* ── Canvas 渲染层(WebCodecs 路径) ── */} - {effectiveUseWebCodecs && ( -
( +
- )} - - {/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */} - {!effectiveUseWebCodecs && - segments.map((seg, i) => ( -