refactor(web): 智能剪辑 & AI数字人 前端代码审查与重构 (#1883)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m39s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m9s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m10s
CI/CD Pipeline / Validate - Style (push) Successful in 4m33s
CI/CD Pipeline / Integration Tests (push) Successful in 5m1s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m47s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Validate - Security (push) Successful in 10m36s
CI/CD Pipeline / Unit Tests (push) Successful in 10m43s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Failing after 60h28m16s
CI/CD Pipeline / Frontend Lint (push) Failing after 60h39m0s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 60h35m47s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 60h38m38s
CI/CD Pipeline / PR Build API Image (push) Failing after 60h38m38s
CI/CD Pipeline / Build Production Web Image (push) Failing after 60h27m50s
CI/CD Pipeline / CI Gate (push) Failing after 60h27m50s
CI/CD Pipeline / Canary Release to Production (push) Failing after 60h27m46s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 60h35m46s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 60h35m46s
CI/CD Pipeline / PR Build Web Image (push) Failing after 60h38m38s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 60h38m40s
CI/CD Pipeline / Deploy Production (push) Failing after 60h27m46s
CI/CD Pipeline / Build Production API Image (push) Failing after 60h27m50s

Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
This commit was merged in pull request #1883.
This commit is contained in:
2026-09-14 02:44:41 +08:00
committed by auto-approve-bot
parent da1d55ef4e
commit 6ff8d48c15
8 changed files with 547 additions and 1668 deletions
@@ -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")
@@ -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("合成试听失败:未返回音频")
@@ -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<FrontendPreviewPlayerProps> = ({
const [voiceDuration, setVoiceDuration] = useState<number>(() =>
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<FrontendPreviewPlayerProps> = ({
? (titleSettings.posY / playRes.height) * 100
: null
// ── 拖拽状态(用 ref 避免在每帧渲染中触发重渲染)──
// ── 标题拖拽(用 ref 避免每帧触发 React 重渲染)──
const draggingTitleRef = useRef(false)
const titleDragRef = useRef<HTMLDivElement>(null)
const playerContainerRef = useRef<HTMLDivElement>(null)
const handleTitlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!onTitlePositionChange || !playerContainerRef.current) return
@@ -222,7 +189,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
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<FrontendPreviewPlayerProps> = ({
(e: React.PointerEvent<HTMLDivElement>) => {
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<FrontendPreviewPlayerProps> = ({
[onTitlePositionChange, playRes.width, playRes.height],
)
const playerContainerRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(0)
useEffect(() => {
const el = playerContainerRef.current
@@ -283,162 +247,76 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
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<HTMLCanvasElement>(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<HTMLAudioElement | null>(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<HTMLDivElement>(null)
}, [currentSegmentIndex, isPlaying])
// P0 fix:视频比配音短时的「末帧冻结+音频续播」模式。
// 视频调度器播完最后一段会自动 pause + setIsPlaying(false),此时若配音仍在播,
// 用虚拟时钟继续推进 currentTime 直到配音时长,期间保持音频播放、视频停在末帧。
// 视频调度器播完最后一段自动 pause,此时若配音仍在播,用 rAF 虚拟时钟推进 currentTime 直到配音结束。
const [tailCurrentTime, setTailCurrentTime] = useState<number | null>(null)
const tailStartRef = useRef<number>(0)
const tailBaseRef = useRef<number>(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<FrontendPreviewPlayerProps> = ({
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<FrontendPreviewPlayerProps> = ({
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<FrontendPreviewPlayerProps> = ({
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>(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<FrontendPreviewPlayerProps> = ({
// ── 无播放片段 ──
if (!canPlay) {
const showDecodeError = forceVideoFallback && canvasState.hasDecodeError
return (
<div
style={{
@@ -706,48 +450,15 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
padding: 24,
}}
>
{isBuffering ? (
<>
<LoadingOutlined style={{ fontSize: 40, color: "#fff", marginBottom: 12 }} spin />
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14, margin: 0 }}>加载中...</p>
</>
) : showDecodeError ? (
<>
<PlayCircleOutlined style={{ fontSize: 40, color: "#ef4444", marginBottom: 12 }} />
<p
style={{
color: "rgba(255,255,255,0.9)",
fontSize: 14,
margin: "0 0 4px",
fontWeight: 500,
}}
>
视频解码失败
</p>
<p
style={{
color: "rgba(255,255,255,0.5)",
fontSize: 12,
margin: 0,
textAlign: "center",
}}
>
{canvasState.errorMessage || "当前浏览器不支持该视频编码格式,请刷新重试"}
</p>
</>
) : (
<>
<PlayCircleOutlined
style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }}
/>
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
暂无可播放素材
</p>
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
请先在左侧选择素材
</p>
</>
)}
<PlayCircleOutlined
style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }}
/>
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
暂无可播放素材
</p>
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
请先在左侧选择素材
</p>
</div>
)
}
@@ -769,53 +480,30 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
: "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 && (
<div
ref={canvasContainerRef}
{/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */}
{segments.map((seg, i) => (
<video
key={seg.assetId}
ref={(el) => {
videoRefs.current[i] = el
}}
preload="auto"
src={seg.videoUrl}
style={{
position: "absolute",
inset: 0,
zIndex: 1,
width: "100%",
height: "100%",
objectFit: "cover",
background: "#000",
zIndex: 1,
opacity: i === currentSegmentIndex ? 1 : 0,
pointerEvents: i === currentSegmentIndex ? "auto" : "none",
}}
>
<canvas
ref={canvasRef}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
</div>
)}
{/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */}
{!effectiveUseWebCodecs &&
segments.map((seg, i) => (
<video
key={seg.assetId}
muted={hasVoice || muted}
ref={(el) => {
videoRefs.current[i] = el
}}
preload="auto"
src={seg.videoUrl}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
background: "#000",
zIndex: 1,
opacity: i === videoCurrentSegIdx ? 1 : 0,
pointerEvents: i === videoCurrentSegIdx ? "auto" : "none",
}}
playsInline
/>
))}
muted={hasVoice || muted}
playsInline
/>
))}
{/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */}
{titleSettings?.title && (
@@ -892,200 +580,19 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
</div>
)}
{/* 中央播放按钮 */}
{!isPlaying && (
<button
onClick={handleTogglePlay}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "50%",
width: 52,
height: 52,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 26,
zIndex: 10,
transition: "transform 0.2s ease, background 0.2s ease",
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1.08)"
e.currentTarget.style.background = "rgba(0,0,0,0.6)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1)"
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
<PlayCircleOutlined />
</button>
)}
{/* 静音/有声切换(#1741):左上角,默认有声;批量与单视频均可单独静音 */}
{segments.length > 0 && (
<button
type="button"
aria-label={muted ? "取消静音" : "静音"}
title={muted ? "取消静音" : "静音"}
onClick={() => setMuted((m) => !m)}
style={{
position: "absolute",
top: 8,
left: 8,
width: compact ? 26 : 30,
height: compact ? 26 : 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "50%",
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
fontSize: compact ? 13 : 15,
cursor: "pointer",
zIndex: 10,
padding: 0,
transition: "background 0.15s, color 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
</button>
)}
{/* 片段指示器 — 右上角胶囊 */}
<div
style={{
position: "absolute",
top: 8,
right: 8,
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
color: "rgba(255,255,255,0.9)",
fontSize: compact ? 9 : 10,
fontWeight: 500,
padding: compact ? "1px 6px" : "2px 8px",
borderRadius: 999,
zIndex: 10,
border: "1px solid rgba(255,255,255,0.1)",
letterSpacing: 0.3,
}}
>
{`${videoCurrentSegIdx + 1} / ${segments.length}`}
</div>
{/* 控制条 — 手机风格毛玻璃 */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
display: "flex",
alignItems: "center",
gap: compact ? 6 : 10,
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
zIndex: 10,
}}
>
<button
onClick={handleTogglePlay}
style={{
background: "rgba(255,255,255,0.15)",
border: "none",
color: "#fff",
fontSize: compact ? 14 : 16,
cursor: "pointer",
width: compact ? 26 : 32,
height: compact ? 26 : 32,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.25)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.15)"
}}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<span
style={{
fontSize: compact ? 10 : 11,
color: "rgba(255,255,255,0.85)",
minWidth: compact ? 58 : 72,
fontVariantNumeric: "tabular-nums",
letterSpacing: 0.2,
}}
>
{formatTime(displayCurrentTime)} / {formatTime(effectiveTotalDuration)}
</span>
<div
ref={progressRef}
onMouseDown={handleMouseDown}
style={{
flex: 1,
height: 3,
background: "rgba(255,255,255,0.2)",
borderRadius: 2,
cursor: "pointer",
position: "relative",
}}
>
<div
style={{
height: "100%",
width: `${progressPercent}%`,
background: "#fff",
borderRadius: 2,
transition: isDragging ? "none" : "width 0.1s linear",
}}
/>
<div
style={{
position: "absolute",
top: "50%",
left: `${progressPercent}%`,
transform: "translate(-50%, -50%)",
width: 10,
height: 10,
borderRadius: "50%",
background: "#fff",
boxShadow: "0 0 6px rgba(255,255,255,0.5)",
opacity: isDragging ? 1 : 0,
transition: "opacity 0.15s",
}}
/>
</div>
</div>
<PreviewControls
isPlaying={isPlaying}
onTogglePlay={handleTogglePlay}
muted={muted}
onToggleMute={() => setMuted((m) => !m)}
hasSegments={segments.length > 0}
segmentIndex={currentSegmentIndex}
segmentCount={segments.length}
currentTime={displayCurrentTime}
totalDuration={effectiveTotalDuration}
onSeek={handleSeekTo}
compact={compact}
/>
</div>
)
}
@@ -0,0 +1,195 @@
import React from "react"
import {
PlayCircleOutlined,
PauseCircleOutlined,
AudioOutlined,
AudioMutedOutlined,
} from "@ant-design/icons"
import { PreviewProgressBar } from "./PreviewProgressBar"
interface PreviewControlsProps {
isPlaying: boolean
onTogglePlay: () => void
muted: boolean
onToggleMute: () => void
hasSegments: boolean
segmentIndex: number
segmentCount: number
currentTime: number
totalDuration: number
onSeek: (time: number) => void
compact?: boolean
}
/**
* 播放控制 UI 组件(静音按钮 / 片段指示器 / 中央播放按钮 / 底部毛玻璃控制条)
*/
export const PreviewControls: React.FC<PreviewControlsProps> = ({
isPlaying,
onTogglePlay,
muted,
onToggleMute,
hasSegments,
segmentIndex,
segmentCount,
currentTime,
totalDuration,
onSeek,
compact = false,
}) => {
return (
<>
{/* 静音/有声切换(#1741):左上角 */}
{hasSegments && (
<button
type="button"
aria-label={muted ? "取消静音" : "静音"}
title={muted ? "取消静音" : "静音"}
onClick={onToggleMute}
style={{
position: "absolute",
top: 8,
left: 8,
width: compact ? 26 : 30,
height: compact ? 26 : 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "50%",
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
fontSize: compact ? 13 : 15,
cursor: "pointer",
zIndex: 10,
padding: 0,
transition: "background 0.15s, color 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
</button>
)}
{/* 片段指示器 — 右上角胶囊 */}
<div
style={{
position: "absolute",
top: 8,
right: 8,
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
color: "rgba(255,255,255,0.9)",
fontSize: compact ? 9 : 10,
fontWeight: 500,
padding: compact ? "1px 6px" : "2px 8px",
borderRadius: 999,
zIndex: 10,
border: "1px solid rgba(255,255,255,0.1)",
letterSpacing: 0.3,
}}
>
{`${segmentIndex + 1} / ${segmentCount}`}
</div>
{/* 中央播放按钮 */}
{!isPlaying && (
<button
onClick={onTogglePlay}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "50%",
width: 52,
height: 52,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 26,
zIndex: 10,
transition: "transform 0.2s ease, background 0.2s ease",
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1.08)"
e.currentTarget.style.background = "rgba(0,0,0,0.6)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1)"
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
<PlayCircleOutlined />
</button>
)}
{/* 控制条 — 手机风格毛玻璃 */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
display: "flex",
alignItems: "center",
gap: compact ? 6 : 10,
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
zIndex: 10,
}}
>
<button
onClick={onTogglePlay}
style={{
background: "rgba(255,255,255,0.15)",
border: "none",
color: "#fff",
fontSize: compact ? 14 : 16,
cursor: "pointer",
width: compact ? 26 : 32,
height: compact ? 26 : 32,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.25)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.15)"
}}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<PreviewProgressBar
currentTime={currentTime}
totalDuration={totalDuration}
onSeek={onSeek}
compact={compact}
/>
</div>
</>
)
}
@@ -0,0 +1,108 @@
import React, { useCallback, useEffect, useRef, useState } from "react"
import { formatDuration } from "../utils/formatDuration"
interface PreviewProgressBarProps {
currentTime: number
totalDuration: number
onSeek: (time: number) => void
compact?: boolean
}
/**
* 进度条组件:点击/拖拽 seek
*/
export const PreviewProgressBar: React.FC<PreviewProgressBarProps> = ({
currentTime,
totalDuration,
onSeek,
compact = false,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const [isDragging, setIsDragging] = useState(false)
const seekByClientX = useCallback(
(clientX: number) => {
if (!progressRef.current || totalDuration <= 0) return
const rect = progressRef.current.getBoundingClientRect()
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
onSeek(ratio * totalDuration)
},
[totalDuration, onSeek],
)
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
setIsDragging(true)
seekByClientX(e.clientX)
},
[seekByClientX],
)
useEffect(() => {
if (!isDragging) return
const handleMouseMove = (e: MouseEvent) => seekByClientX(e.clientX)
const handleMouseUp = () => setIsDragging(false)
window.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mouseup", handleMouseUp)
return () => {
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
}
}, [isDragging, seekByClientX])
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
return (
<>
<span
style={{
fontSize: compact ? 10 : 11,
color: "rgba(255,255,255,0.85)",
minWidth: compact ? 58 : 72,
fontVariantNumeric: "tabular-nums",
letterSpacing: 0.2,
}}
>
{formatDuration(currentTime)} / {formatDuration(totalDuration)}
</span>
<div
ref={progressRef}
onMouseDown={handleMouseDown}
style={{
flex: 1,
height: 3,
background: "rgba(255,255,255,0.2)",
borderRadius: 2,
cursor: "pointer",
position: "relative",
}}
>
<div
style={{
height: "100%",
width: `${progressPercent}%`,
background: "#fff",
borderRadius: 2,
transition: isDragging ? "none" : "width 0.1s linear",
}}
/>
<div
style={{
position: "absolute",
top: "50%",
left: `${progressPercent}%`,
transform: "translate(-50%, -50%)",
width: 10,
height: 10,
borderRadius: "50%",
background: "#fff",
boxShadow: "0 0 6px rgba(255,255,255,0.5)",
opacity: isDragging ? 1 : 0,
transition: "opacity 0.15s",
}}
/>
</div>
</>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useRef } from "react"
interface UsePreviewAudioOptions {
voiceAudioUrl: string | undefined
voiceDurationHint: number | undefined
muted: boolean
isPlaying: boolean
currentTime: number
onVoiceDurationChange: (d: number) => void
onEnded: () => void
}
interface UsePreviewAudioReturn {
seekTo: (time: number) => void
ensurePlayingAt: (time: number) => void
pause: () => void
}
/**
* 配音音频管理 hook:加载配音、loadedmetadata 自测时长、play/pause 同步、
* ended 事件回调、seek 同步、末帧冻结期间续播。
*/
export function usePreviewAudio({
voiceAudioUrl,
voiceDurationHint,
muted,
isPlaying,
currentTime,
onVoiceDurationChange,
onEnded,
}: UsePreviewAudioOptions): UsePreviewAudioReturn {
const audioRef = useRef<HTMLAudioElement | null>(null)
const prevIsPlayingRef = useRef(false)
// 外部 hint 初始化(自测值前的兜底)
useEffect(() => {
if (voiceDurationHint && voiceDurationHint > 0) {
onVoiceDurationChange(voiceDurationHint)
}
}, [voiceDurationHint, onVoiceDurationChange])
// 创建/替换 audio 元素,加载 metadata 时自测时长并监听 ended
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
const audio = audioRef.current
const onLoaded = () => {
if (audio.duration && isFinite(audio.duration) && audio.duration > 0) {
onVoiceDurationChange(audio.duration)
}
}
const onEndedHandler = () => onEnded()
audio.addEventListener("loadedmetadata", onLoaded)
audio.addEventListener("ended", onEndedHandler)
return () => {
audio.removeEventListener("loadedmetadata", onLoaded)
audio.removeEventListener("ended", onEndedHandler)
}
}, [voiceAudioUrl, muted, onVoiceDurationChange, onEnded])
// mute 变化即时同步
useEffect(() => {
if (audioRef.current) audioRef.current.muted = muted
}, [muted])
// 播放/暂停同步(跟随视频 isPlaying)
useEffect(() => {
const audio = audioRef.current
if (!audio || !audio.src) return
if (isPlaying && !prevIsPlayingRef.current) {
if (Math.abs(audio.currentTime - currentTime) > 0.3) {
try {
audio.currentTime = currentTime
} catch {
/* ignore */
}
}
audio.play().catch(() => {})
} else if (!isPlaying && prevIsPlayingRef.current) {
audio.pause()
}
prevIsPlayingRef.current = isPlaying
}, [isPlaying, currentTime])
const seekTo = useCallback((time: number) => {
const audio = audioRef.current
if (audio && audio.src) {
try {
audio.currentTime = time
} catch {
/* ignore */
}
}
}, [])
const ensurePlayingAt = useCallback((time: number) => {
const audio = audioRef.current
if (!audio || !audio.src) return
try {
if (Math.abs(audio.currentTime - time) > 0.5) audio.currentTime = time
if (audio.paused) audio.play().catch(() => {})
} catch {
/* ignore */
}
}, [])
const pause = useCallback(() => {
try {
audioRef.current?.pause()
} catch {
/* ignore */
}
}, [])
return { seekTo, ensurePlayingAt, pause }
}
@@ -1,7 +1,7 @@
/**
* FrontendPreviewPlayer 音频行为单测(Issue #1741 / #1750)
*
* useSegmentScheduler/useCanvasPlayer 用 mock 控制播放态,专注验证本组件的音频逻辑:
* useSegmentScheduler 用 mock 控制播放态,专注验证本组件的音频逻辑:
* - 有配音时 video 保持 muted(素材原声不与配音混音)
* - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声)
* - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换