refactor(web): 智能剪辑 & AI数字人 前端代码审查与重构 #1883
@@ -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 与图标切换
|
||||
|
||||
Reference in New Issue
Block a user