Compare commits

...

1 Commits

Author SHA1 Message Date
张宏杰 e78c812bfd fix: 多video元素无缝切换+标题字号上限调大
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 36s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 55s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m35s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m53s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m55s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m13s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m25s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m19s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m33s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 6s
AI Code Review / AI Code Review (pull_request) Successful in 6m50s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m1s
1. 片段切换改为多video元素方案:每个片段独立<video>元素+preload='auto'全预加载
   用display:none/block切换,不再重新设置src+load,消除切换延迟
2. 标题fontSize上限从36调到96,与滑块最大值对齐,用户可设置更大字号
3. useSegmentScheduler重写:videoRefs数组管理多video,switchToSegment仅seek不切src
2026-08-18 23:27:43 +08:00
3 changed files with 104 additions and 188 deletions
@@ -1,7 +1,7 @@
/**
* 前端预览播放器
* 用原生 <video> 标签按时间线播放素材片段
* 替代后端 FFmpeg 渲染预览,实现真正的实时预览
* 前端预览播放器(多 video 元素方案)
* 为每个片段创建独立 <video> 元素,全部预加载
* 用 display: none/block 切换当前播放的视频,实现无缝片段切换
*
* 注意:本组件不创建 .xx-preview-video 容器(由父组件 PreviewVideoPanel 提供)
* 避免嵌套 .xx-preview-video 导致 CSS 冲突
@@ -90,7 +90,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
canPlay,
togglePlayPause,
seekTo,
videoRef,
videoRefs,
} = useSegmentScheduler(segments)
// ── 配音音频同步 ──
@@ -172,7 +172,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
}
}, [])
// 进度条拖拽
// ── 进度条拖拽 ──
const [isDragging, setIsDragging] = useState(false)
const progressRef = useRef<HTMLDivElement>(null)
@@ -213,7 +213,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
// 未就绪状态
// ── 未就绪状态 ──
if (!ready || !assets.length) {
return (
<div
@@ -235,7 +235,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
)
}
// 无播放片段
// ── 无播放片段 ──
if (!canPlay) {
return (
<div
@@ -261,23 +261,30 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
return (
<>
{/* ✅ 不再创建 .xx-preview-video 容器,直接渲染 video + 覆盖层 */}
{/* video 元素 — 由父组件 .xx-preview-video 容器提供定位和尺寸 */}
<video
muted
ref={videoRef}
preload="auto"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "contain",
background: "#000",
zIndex: 1,
}}
playsInline
/>
{/* ✅ 多 video 元素方案:每个片段一个独立 video,全部预加载
通过 display 切换当前播放的视频,无需重新 load,实现无缝切换 */}
{segments.map((seg, i) => (
<video
key={seg.assetId}
muted
ref={(el) => {
videoRefs.current[i] = el
}}
preload="auto"
src={seg.videoUrl}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "contain",
background: "#000",
zIndex: 1,
display: i === currentSegmentIndex ? "block" : "none",
}}
playsInline
/>
))}
{/* 播放按钮覆盖层 */}
{!isPlaying && (
@@ -83,8 +83,8 @@ function buildTitleStyle(settings: TitleSettings, containerHeight: number): Reac
// 用 px 计算 fontSize,不再依赖父元素 font-size 的百分比
const fontSizePx =
containerHeight > 0
? (Math.min(settings.size, 36) / ASS_VIDEO_HEIGHT) * containerHeight
: (Math.min(settings.size, 36) / ASS_VIDEO_HEIGHT) * 400 // fallback
? (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * containerHeight
: (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * 400 // fallback
const base: React.CSSProperties = {
fontFamily: settings.font || "思源黑体",
@@ -1,9 +1,9 @@
/**
* 素材片段调度器 Hook
* 控制原生 <video> 元素按时间线依次播放素材片段(入点→出点)
* 实现前端预览播放,替代后端 FFmpeg 渲染
* 素材片段调度器 Hook(多 video 元素方案 v2
* 每个片段对应一个独立 <video> 元素,全部预加载,通过 display 切换实现无缝播放
* 替代单 video + 切 src 方案,消除片段切换延迟
*/
import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
/** 单个播放片段 */
export interface PlaybackSegment {
@@ -27,7 +27,7 @@ export interface SegmentSchedulerState {
currentTime: number
/** 总时长(秒) */
totalDuration: number
/** 当前片段索引(在 segments 数组中的位置) */
/** 当前片段索引 */
currentSegmentIndex: number
/** 当前片段的本地播放时间 */
segmentLocalTime: number
@@ -43,8 +43,8 @@ export interface SegmentSchedulerState {
togglePlayPause: () => void
/** 跳转到全局时间 */
seekTo: (time: number) => void
/** 绑定到 <video> 元素 */
videoRef: React.RefObject<HTMLVideoElement>
/** 每个片段对应的 video 元素 ref 数组 */
videoRefs: React.MutableRefObject<(HTMLVideoElement | null)[]>
}
/**
@@ -80,10 +80,17 @@ function buildTimeline(segments: PlaybackSegment[]): number[] {
}
/**
* useSegmentScheduler — 素材片段调度器
* useSegmentScheduler — 多 video 元素版素材片段调度器
*
* 核心改变:
* - 每个片段对应一个独立 <video> 元素(由组件渲染,ref 传入)
* - 所有 video 在挂载时即设置 src + preload="auto",浏览器自动预加载
* - 切换片段仅改 currentSegmentIndex + display,无需重新 load
* - 实现无缝切换,无加载延迟
*/
export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedulerState {
const videoRef = useRef<HTMLVideoElement | null>(null)
/** 每个片段对应的 video 元素 ref(由组件 JSX 渲染并绑定) */
const videoRefs = useRef<(HTMLVideoElement | null)[]>([])
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0)
@@ -91,13 +98,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
const rafRef = useRef<number>(0)
const isSeekingRef = useRef(false)
// ✅ 关键修复:用 ref 标记是否已加载过片段(替代检查 video.src)
// video.src = "" 在浏览器中会被解析为页面 URL,导致 "video.src === ''" 永远为 false
const srcLoadedRef = useRef(false)
// 预加载用的隐藏 video 元素
const preloadVideoRef = useRef<HTMLVideoElement | null>(null)
// 计算时间线
const timelineStarts = useMemo(() => buildTimeline(segments), [segments])
const totalDuration = useMemo(
@@ -113,122 +113,63 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
? currentTime - (timelineStarts[currentSegmentIndex] || 0) + currentSegment.startTime
: 0
/** 切换到指定片段,返回 Promise 在视频可播放后 resolve */
/**
* 切换到指定片段
* 不改变 src(video 已在 JSX 中设置),仅 seek + 等待可播
*/
const switchToSegment = useCallback(
(index: number, seekToLocalTime?: number): Promise<void> => {
return new Promise((resolve) => {
const video = videoRef.current
// 暂停当前视频
const prevVideo = videoRefs.current[currentSegmentIndex]
if (prevVideo) prevVideo.pause()
const video = videoRefs.current[index]
if (!video || index >= segments.length) {
resolve()
return
}
const seg = segments[index]
const videoUrl = seg.videoUrl
const localTime = seekToLocalTime ?? seg.startTime
if (!videoUrl || videoUrl === "") {
console.error(
`[useSegmentScheduler] 片段 ${index} 的 videoUrl 为空!assetId: ${seg.assetId}`,
)
// 设置播放位置
video.currentTime = localTime
// 如果已有足够帧数据,直接 resolve
if (video.readyState >= 2) {
setCurrentSegmentIndex(index)
resolve()
return
}
// 检查是否是完整的 HTTP URL
if (!videoUrl.startsWith("http://") && !videoUrl.startsWith("https://")) {
console.warn(`[useSegmentScheduler] 片段 ${index} 的 videoUrl 不是完整 URL: ${videoUrl}`)
}
const localTime = seekToLocalTime ?? seg.startTime
// ✅ 关键修复:清除旧事件监听,防止重复绑定
const cleanup = () => {
video.removeEventListener("loadedmetadata", onLoaded)
video.removeEventListener("error", onError)
// 等待 canplay 事件
const onCanPlay = () => {
video.removeEventListener("canplay", onCanPlay)
clearTimeout(timeoutId)
}
const onLoaded = () => {
video.currentTime = localTime
srcLoadedRef.current = true
cleanup()
console.log(
`[useSegmentScheduler] 片段 ${index} loadedmetadata, src=${videoUrl.substring(0, 80)}`,
)
setCurrentSegmentIndex(index)
resolve()
}
const onCanPlay = () => {
video.currentTime = localTime
srcLoadedRef.current = true
cleanup()
console.log(
`[useSegmentScheduler] 片段 ${index} canplay, src=${videoUrl.substring(0, 80)}`,
)
resolve()
}
const onError = () => {
console.error(
`[useSegmentScheduler] 视频加载失败: ${videoUrl}`,
video.error?.message || `code=${video.error?.code}`,
)
srcLoadedRef.current = true // 标记为已尝试,避免死循环
cleanup()
resolve()
}
// ✅ 关键修复:10秒超时防止 Promise 永远不 resolve
// 10 秒超时保护
const timeoutId = setTimeout(() => {
video.removeEventListener("canplay", onCanPlay)
console.warn(
`[useSegmentScheduler] 片段 ${index} 加载超时 (10s), readyState=${video.readyState}`,
`[useSegmentScheduler] 片段 ${index} 加载超时 (10s), readyState=${video.readyState}`,
)
cleanup()
setCurrentSegmentIndex(index)
resolve()
}, 10000)
// 先绑定事件,再设置 src(避免错过已触发的loadedmetadata
video.addEventListener("loadedmetadata", onLoaded)
video.addEventListener("canplay", onCanPlay)
video.addEventListener("error", onError)
// 设置 src 并显式调用 load()
video.src = videoUrl
video.load()
// ✅ 关键修复:如果 readyState >= 2HAVE_CURRENT_DATA),说明已有帧数据
// 直接设置 currentTime 并 resolve,不等待事件
if (video.readyState >= 2) {
video.currentTime = localTime
srcLoadedRef.current = true
cleanup()
console.log(
`[useSegmentScheduler] 片段 ${index} 已缓存(readyState=${video.readyState}), 直接播放`,
)
resolve()
return
}
setCurrentSegmentIndex(index)
// 预加载下一段
if (index + 1 < segments.length) {
const nextSeg = segments[index + 1]
if (!preloadVideoRef.current) {
preloadVideoRef.current = document.createElement("video")
preloadVideoRef.current.preload = "auto"
}
preloadVideoRef.current.src = nextSeg.videoUrl
}
})
},
[segments],
[segments, currentSegmentIndex],
)
/** 播放循环 — 检测片段边界并切换 */
const tick = useCallback(() => {
const video = videoRef.current
const video = videoRefs.current[currentSegmentIndex]
if (!video || isSeekingRef.current) {
rafRef.current = requestAnimationFrame(tick)
return
@@ -237,36 +178,23 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
const seg = segments[currentSegmentIndex]
if (!seg) return
// 提前 2 秒预加载下一段
if (currentSegmentIndex + 1 < segments.length) {
const nextSeg = segments[currentSegmentIndex + 1]
if (!preloadVideoRef.current) {
preloadVideoRef.current = document.createElement("video")
preloadVideoRef.current.preload = "auto"
}
if (preloadVideoRef.current.src !== nextSeg.videoUrl) {
preloadVideoRef.current.src = nextSeg.videoUrl
preloadVideoRef.current.load()
}
}
// 检查是否到达出点(容差 0.15s)
if (video.currentTime >= seg.endTime - 0.15) {
video.pause()
const nextIndex = currentSegmentIndex + 1
if (nextIndex < segments.length) {
switchToSegment(nextIndex).then(() => {
const v = videoRef.current
if (v) {
v.play().catch((e) =>
console.warn("[useSegmentScheduler] auto-play next segment failed:", e),
)
const nextVideo = videoRefs.current[nextIndex]
if (nextVideo) {
nextVideo
.play()
.catch((e) => console.warn("[useSegmentScheduler] auto-play next segment failed:", e))
}
})
const accumulatedTime =
(timelineStarts[currentSegmentIndex] || 0) + (seg.endTime - seg.startTime)
setCurrentTime(accumulatedTime)
} else {
video.pause()
setIsPlaying(false)
setIsEnded(true)
setCurrentTime(totalDuration)
@@ -283,52 +211,38 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
/** 播放 */
const play = useCallback(async () => {
const video = videoRef.current
if (!video || !canPlay) {
console.log("[useSegmentScheduler] play: video=", !!video, "canPlay=", canPlay)
return
}
if (!canPlay) return
setIsEnded(false)
// ✅ 关键修复:用 ref 标记代替 video.src 检查
if (!srcLoadedRef.current) {
console.log("[useSegmentScheduler] 首次播放,加载片段 0...")
// 确保第一段可播放
const firstVideo = videoRefs.current[0]
if (firstVideo && currentSegmentIndex === 0 && firstVideo.readyState < 2) {
await switchToSegment(0)
}
const video = videoRefs.current[currentSegmentIndex]
if (!video) return
try {
const playPromise = video.play()
if (playPromise !== undefined) {
await playPromise
}
console.log("[useSegmentScheduler] 播放开始, src=", video.src?.substring(0, 80))
setIsPlaying(true)
rafRef.current = requestAnimationFrame(tick)
} catch (err) {
console.warn("[useSegmentScheduler] 播放失败:", err, "src=", video.src?.substring(0, 80))
// 如果播放失败,尝试重新加载片段
if (!srcLoadedRef.current) {
srcLoadedRef.current = false
await switchToSegment(0)
try {
await video.play()
setIsPlaying(true)
rafRef.current = requestAnimationFrame(tick)
} catch (retryErr) {
console.error("[useSegmentScheduler] 重试播放仍然失败:", retryErr)
}
}
console.warn("[useSegmentScheduler] 播放失败:", err)
}
}, [canPlay, switchToSegment, tick])
}, [canPlay, switchToSegment, tick, currentSegmentIndex])
/** 暂停 */
const pause = useCallback(() => {
const video = videoRef.current
const video = videoRefs.current[currentSegmentIndex]
if (video) video.pause()
setIsPlaying(false)
cancelAnimationFrame(rafRef.current)
}, [])
}, [currentSegmentIndex])
/** 切换播放/暂停 */
const togglePlayPause = useCallback(() => {
@@ -338,9 +252,8 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
if (isEnded) {
// 播放结束后再次播放,从头开始
setIsEnded(false)
srcLoadedRef.current = false // ✅ 重置标记,强制重新加载
switchToSegment(0, segments[0]?.startTime).then(() => {
const video = videoRef.current
const video = videoRefs.current[0]
if (video) {
video.play().catch((e) => console.warn("[useSegmentScheduler] restart play failed:", e))
setIsPlaying(true)
@@ -357,8 +270,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
/** 跳转到指定全局时间 */
const seekTo = useCallback(
async (time: number) => {
const video = videoRef.current
if (!video || !canPlay) return
if (!canPlay) return
const clampedTime = Math.max(0, Math.min(time, totalDuration))
const { index, localTime } = findSegmentAtTime(segments, clampedTime)
@@ -368,11 +280,11 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
if (index !== currentSegmentIndex) {
await switchToSegment(index, localTime)
} else {
video.currentTime = localTime
const video = videoRefs.current[index]
if (video) video.currentTime = localTime
}
setCurrentTime(clampedTime)
setCurrentSegmentIndex(index)
setIsEnded(false)
setTimeout(() => {
@@ -382,14 +294,18 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
[canPlay, totalDuration, segments, currentSegmentIndex, switchToSegment],
)
// 确保 videoRefs 数组长度与 segments 一致
useEffect(() => {
videoRefs.current = videoRefs.current.slice(0, segments.length)
while (videoRefs.current.length < segments.length) {
videoRefs.current.push(null)
}
}, [segments])
// 组件卸载时清理
useEffect(() => {
return () => {
cancelAnimationFrame(rafRef.current)
if (preloadVideoRef.current) {
preloadVideoRef.current.src = ""
preloadVideoRef.current = null
}
}
}, [])
@@ -400,13 +316,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
setCurrentTime(0)
setCurrentSegmentIndex(0)
setIsEnded(false)
srcLoadedRef.current = false // ✅ 重置标记
const video = videoRef.current
if (video) {
video.pause()
video.removeAttribute("src")
video.load()
}
}, [segments])
return {
@@ -421,7 +330,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
pause,
togglePlayPause,
seekTo,
videoRef,
videoRefs,
}
}