Compare commits

...

1 Commits

Author SHA1 Message Date
张宏杰 3ddde4c650 fix: 修复预览播放器视频无法播放和标题不显示
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 52s
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
AI Code Review / AI Code Review (pull_request) Failing after 1m3s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m6s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m54s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m24s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 1m33s
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m23s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m15s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 33s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m46s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 51s
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
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 33s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 5m35s
CI/CD Pipeline / CI Gate (pull_request) Failing after 10s
修复3个根因:
1. useSegmentScheduler: 用 srcLoadedRef 替代 video.src 检查
   - video.src='' 在浏览器中被解析为页面URL,导致条件永远为false
   - switchToSegment 先绑事件再设src,加 readyState 检查和10s超时
   - 显式调用 video.load() 确保加载启动
2. FrontendPreviewPlayer: 移除嵌套的 .xx-preview-video 容器
   - 改为 Fragment + absolute 定位,避免双层 overflow:hidden 冲突
   - 视频/控制条/播放按钮直接填充父容器
3. PreviewVideoPanel: 简化为单一 .xx-preview-video 容器
   - TitleOverlay z-index:20 确保在视频(z:1)和控制条(z:10)之上
2026-08-18 17:43:40 +08:00
3 changed files with 207 additions and 111 deletions
@@ -2,6 +2,9 @@
* 前端预览播放器
* 用原生 <video> 标签按时间线播放素材片段
* 替代后端 FFmpeg 渲染预览,实现真正的实时预览
*
* 注意:本组件不创建 .xx-preview-video 容器(由父组件 PreviewVideoPanel 提供)
* 避免嵌套 .xx-preview-video 导致 CSS 冲突
*/
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
import { PlayCircleOutlined, PauseCircleOutlined, SoundOutlined } from "@ant-design/icons"
@@ -41,9 +44,7 @@ function buildPlaybackSegments(
const segments: PlaybackSegment[] = []
assets.forEach((asset, i) => {
// 获取素材时长(从 metadata 或顶层字段)
const assetDuration = asset.duration || asset.metadata?.duration || 30
// 模板片段的时长约束
const tplSeg = templateSegments[i] || templateSegments[templateSegments.length - 1]
const segDuration = tplSeg
? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration))
@@ -54,9 +55,8 @@ function buildPlaybackSegments(
const videoUrl = asset.file_url || asset.storage_key
// 调试日志:打印每个片段的 videoUrl
console.log(
`[buildPlaybackSegments] 片段 ${i}: assetId=${asset.id}, videoUrl=${videoUrl}, file_url=${asset.file_url}, storage_key=${asset.storage_key}`,
`[buildPlaybackSegments] 片段 ${i}: assetId=${asset.id}, videoUrl=${videoUrl?.substring(0, 80)}, file_url=${!!asset.file_url}`,
)
segments.push({
@@ -77,7 +77,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
videoRatio,
ready,
}) => {
// 构建播放片段
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
const {
@@ -131,13 +130,23 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
}
}, [isDragging, totalDuration, seekTo])
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
// 未就绪状态
if (!ready || !assets.length) {
return (
<div className="xx-preview-empty">
<div
className="xx-preview-empty"
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
zIndex: 1,
}}
>
<SoundOutlined style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }} />
<p className="xx-preview-empty-title">...</p>
<p className="xx-preview-empty-desc"></p>
@@ -148,7 +157,18 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
// 无播放片段
if (!canPlay) {
return (
<div className="xx-preview-empty">
<div
className="xx-preview-empty"
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
zIndex: 1,
}}
>
<PlayCircleOutlined
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
/>
@@ -159,76 +179,84 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
}
return (
<div className="xx-frontend-preview-player">
{/* 视频区域 */}
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
<video
ref={videoRef}
preload="auto"
style={{
width: "100%",
height: "100%",
objectFit: "contain",
background: "#000",
}}
playsInline
/>
<>
{/* ✅ 不再创建 .xx-preview-video 容器,直接渲染 video + 覆盖层 */}
{/* video 元素 — 由父组件 .xx-preview-video 容器提供定位和尺寸 */}
<video
ref={videoRef}
preload="auto"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "contain",
background: "#000",
zIndex: 1,
}}
playsInline
/>
{/* 播放/暂停按钮覆盖 */}
{!isPlaying && (
<button
className="xx-preview-play-btn"
onClick={togglePlayPause}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0,0,0,0.5)",
border: "none",
borderRadius: "50%",
width: 56,
height: 56,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 28,
zIndex: 10,
transition: "opacity 0.2s",
}}
>
{isEnded ? <PlayCircleOutlined /> : <PlayCircleOutlined />}
</button>
)}
{/* 当前片段指示器 */}
<div
{/* 播放按钮覆盖 */}
{!isPlaying && (
<button
className="xx-preview-play-btn"
onClick={togglePlayPause}
style={{
position: "absolute",
top: 8,
left: 8,
background: "rgba(0,0,0,0.6)",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0,0,0,0.5)",
border: "none",
borderRadius: "50%",
width: 56,
height: 56,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 11,
padding: "2px 8px",
borderRadius: 4,
fontSize: 28,
zIndex: 10,
transition: "opacity 0.2s",
}}
>
{currentSegmentIndex + 1}/{segments.length}
</div>
<PlayCircleOutlined />
</button>
)}
{/* 片段指示器 */}
<div
style={{
position: "absolute",
top: 8,
left: 8,
background: "rgba(0,0,0,0.6)",
color: "#fff",
fontSize: 11,
padding: "2px 8px",
borderRadius: 4,
zIndex: 10,
}}
>
{currentSegmentIndex + 1}/{segments.length}
</div>
{/* 控制条 */}
{/* 控制条 — 绝对定位在底部 */}
<div
className="xx-preview-controls"
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 0",
padding: "8px 12px",
background: "linear-gradient(transparent, rgba(0,0,0,0.6))",
zIndex: 10,
}}
>
{/* 播放/暂停 */}
@@ -237,7 +265,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
style={{
background: "none",
border: "none",
color: "var(--text-primary, #fff)",
color: "#fff",
fontSize: 18,
cursor: "pointer",
padding: 4,
@@ -252,7 +280,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
<span
style={{
fontSize: 12,
color: "var(--text-secondary, #999)",
color: "rgba(255,255,255,0.8)",
minWidth: 80,
fontVariantNumeric: "tabular-nums",
}}
@@ -282,7 +310,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
transition: isDragging ? "none" : "width 0.1s linear",
}}
/>
{/* 进度指示点 */}
<div
style={{
position: "absolute",
@@ -300,7 +327,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
/>
</div>
</div>
</div>
</>
)
}
@@ -6,6 +6,10 @@
* 架构改造:完全去除后端 FFmpeg 预览依赖
* - 使用 FrontendPreviewPlayer 直接播放素材片段
* - TitleOverlay CSS 层实时响应标题样式变化
*
* 布局:本组件提供 .xx-preview-video 容器(position: relative + overflow: hidden
* FrontendPreviewPlayer 的内容通过 absolute 定位填充容器
* TitleOverlay 通过 absolute 定位 + z-index: 30 覆盖在最上层
*/
import React, { useMemo } from "react"
import { LoadingOutlined } from "@ant-design/icons"
@@ -104,6 +108,7 @@ function buildTitleStyle(settings: TitleSettings): React.CSSProperties {
/**
* CSS 标题预览覆盖层
* 始终渲染:有标题显示标题,无标题显示占位文本"标题预览"
* z-index: 20(在视频 z-index:1 和控制条 z-index:10 之上)
*/
const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSettings }) => {
const positionStyle = useMemo(
@@ -130,12 +135,12 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
return (
<div
className="xx-preview-title-overlay"
style={{
position: "absolute",
inset: 0,
zIndex: 30,
zIndex: 20,
pointerEvents: "none",
overflow: "hidden",
}}
>
<div
@@ -170,10 +175,11 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
{assetsReady && assets.length > 0 && <span className="xx-preview-badge"></span>}
</div>
{/* 预览容器 — 标题叠加层始终渲染在容器顶层,不受加载状态影响 */}
{/* 预览容器 — 唯一的 .xx-preview-video 容器
内部所有内容(视频、控制条、标题叠加层)通过 absolute 定位填充 */}
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
{/* 内容层:加载中 or 前端播放器 */}
{assetsLoading ? (
{/* 加载中状态 */}
{assetsLoading && (
<div
className="xx-preview-loading-center"
style={{
@@ -183,7 +189,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
zIndex: 1,
zIndex: 5,
}}
>
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
@@ -191,18 +197,17 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
...
</p>
</div>
) : (
<FrontendPreviewPlayer
assets={assets}
template={template}
videoRatio={videoRatio}
ready={assetsReady}
/>
)}
{/* CSS 标题实时预览层 — 与 FFmpeg ASS 渲染坐标对齐
始终渲染在内容层之上(z-index: 30
有标题显示标题,无标题显示"标题预览"占位文本 */}
{/* 前端播放器(视频 + 控制条 + 播放按钮)*/}
<FrontendPreviewPlayer
assets={assets}
template={template}
videoRatio={videoRatio}
ready={assetsReady}
/>
{/* CSS 标题实时预览层 — z-index: 20,始终渲染在内容层之上 */}
{titleSettings && <TitleOverlay titleSettings={titleSettings} />}
</div>
@@ -91,6 +91,10 @@ 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)
@@ -122,7 +126,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
const seg = segments[index]
const videoUrl = seg.videoUrl
// 调试日志:检查 videoUrl 是否有效
if (!videoUrl || videoUrl === "") {
console.error(
`[useSegmentScheduler] 片段 ${index} 的 videoUrl 为空!assetId: ${seg.assetId}`,
@@ -133,33 +136,80 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
// 检查是否是完整的 HTTP URL
if (!videoUrl.startsWith("http://") && !videoUrl.startsWith("https://")) {
console.warn(
`[useSegmentScheduler] 片段 ${index} 的 videoUrl 不是完整 URL: ${videoUrl},可能导致加载失败`,
)
console.warn(`[useSegmentScheduler] 片段 ${index} 的 videoUrl 不是完整 URL: ${videoUrl}`)
}
video.src = videoUrl
const localTime = seekToLocalTime ?? seg.startTime
// 等待 src 设置后再设置 currentTime 并 resolve
// ✅ 关键修复:清除旧事件监听,防止重复绑定
const cleanup = () => {
video.removeEventListener("loadedmetadata", onLoaded)
video.removeEventListener("error", onError)
video.removeEventListener("canplay", onCanPlay)
clearTimeout(timeoutId)
}
const onLoaded = () => {
video.currentTime = localTime
video.removeEventListener("loadedmetadata", onLoaded)
srcLoadedRef.current = true
cleanup()
console.log(
`[useSegmentScheduler] 片段 ${index} loadedmetadata, src=${videoUrl.substring(0, 80)}`,
)
resolve()
}
const onCanPlay = () => {
video.currentTime = localTime
srcLoadedRef.current = true
cleanup()
console.log(
`[useSegmentScheduler] 片段 ${index} canplay, src=${videoUrl.substring(0, 80)}`,
)
resolve()
}
video.addEventListener("loadedmetadata", onLoaded)
// 监听加载错误
const onError = () => {
console.error(
`[useSegmentScheduler] 视频加载失败: ${videoUrl}`,
video.error?.message || "unknown error",
video.error?.message || `code=${video.error?.code}`,
)
video.removeEventListener("error", onError)
resolve() // 即使失败也 resolve,让调用方继续处理
srcLoadedRef.current = true // 标记为已尝试,避免死循环
cleanup()
resolve()
}
// ✅ 关键修复:10秒超时防止 Promise 永远不 resolve
const timeoutId = setTimeout(() => {
console.warn(
`[useSegmentScheduler] 片段 ${index} 加载超时 (10s), readyState=${video.readyState}`,
)
cleanup()
resolve()
}, 10000)
// 先绑定事件,再设置 src(避免错过已触发的loadedmetadata
video.addEventListener("loadedmetadata", onLoaded)
video.addEventListener("canplay", onCanPlay)
video.addEventListener("error", onError)
// 设置 src 并显式调用 load()
video.src = videoUrl
video.load()
// ✅ 关键修复:如果 readyState >= 1(已有 metadata),说明加载已完成
// 直接设置 currentTime 并 resolve,不等待事件
if (video.readyState >= 1) {
video.currentTime = localTime
srcLoadedRef.current = true
cleanup()
console.log(
`[useSegmentScheduler] 片段 ${index} 已缓存(readyState=${video.readyState}), 直接播放`,
)
resolve()
return
}
setCurrentSegmentIndex(index)
// 预加载下一段
@@ -191,13 +241,11 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
if (video.currentTime >= seg.endTime - 0.15) {
const nextIndex = currentSegmentIndex + 1
if (nextIndex < segments.length) {
// 切到下一段(不等待,直接继续 tick)
switchToSegment(nextIndex)
const accumulatedTime =
(timelineStarts[currentSegmentIndex] || 0) + (seg.endTime - seg.startTime)
setCurrentTime(accumulatedTime)
} else {
// 播放结束
video.pause()
setIsPlaying(false)
setIsEnded(true)
@@ -205,7 +253,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
return
}
} else {
// 更新全局时间
const globalTime =
(timelineStarts[currentSegmentIndex] || 0) + (video.currentTime - seg.startTime)
setCurrentTime(Math.max(0, Math.min(globalTime, totalDuration)))
@@ -217,25 +264,41 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
/** 播放 */
const play = useCallback(async () => {
const video = videoRef.current
if (!video || !canPlay) return
if (!video || !canPlay) {
console.log("[useSegmentScheduler] play: video=", !!video, "canPlay=", canPlay)
return
}
setIsEnded(false)
// 如果还没设置 src(首次播放),先加载第一段并等待
if (!video.src || video.src === "") {
// ✅ 关键修复:用 ref 标记代替 video.src 检查
if (!srcLoadedRef.current) {
console.log("[useSegmentScheduler] 首次播放,加载片段 0...")
await switchToSegment(0)
// switchToSegment 会等待 loadedmetadata
}
try {
await video.play()
console.log("[useSegmentScheduler] 播放开始")
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)
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)
}
}
}
}, [canPlay, switchToSegment, tick])
@@ -255,10 +318,11 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
if (isEnded) {
// 播放结束后再次播放,从头开始
setIsEnded(false)
srcLoadedRef.current = false // ✅ 重置标记,强制重新加载
switchToSegment(0, segments[0]?.startTime).then(() => {
const video = videoRef.current
if (video) {
video.play().catch(() => {})
video.play().catch((e) => console.warn("[useSegmentScheduler] restart play failed:", e))
setIsPlaying(true)
setCurrentTime(0)
rafRef.current = requestAnimationFrame(tick)
@@ -281,7 +345,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
isSeekingRef.current = true
// 如果片段变了,需要切换 src
if (index !== currentSegmentIndex) {
await switchToSegment(index, localTime)
} else {
@@ -292,7 +355,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
setCurrentSegmentIndex(index)
setIsEnded(false)
// 延迟恢复 tick 检测
setTimeout(() => {
isSeekingRef.current = false
}, 200)
@@ -318,10 +380,12 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
setCurrentTime(0)
setCurrentSegmentIndex(0)
setIsEnded(false)
srcLoadedRef.current = false // ✅ 重置标记
const video = videoRef.current
if (video) {
video.pause()
video.src = ""
video.removeAttribute("src")
video.load()
}
}, [segments])