afcfcf45ee
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 19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 13s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 14s
CI/CD Pipeline / Build Staging API Image (push) Successful in 46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m39s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m19s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m50s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 43h10m14s
CI/CD Pipeline / PR Build Web Image (push) Failing after 43h11m24s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 43h10m56s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 43h9m45s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 43h10m55s
CI/CD Pipeline / PR Build API Image (push) Failing after 43h10m55s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 43h9m45s
CI/CD Pipeline / Frontend Lint (push) Failing after 43h10m50s
fix(frontend): #1896 删除侵权华康字体修复PreviewPlayer字体映射 问题: 1. "华康俪金黑"@font-face字体文件缺失(404)且有版权风险 2. PreviewPlayer直接用中文font_preset/font作为fontFamily,浏览器不识别 修改: - 删除"华康俪金黑"字体选项(FONT_OPTIONS+FONT_FAMILY_MAP)和@font-face声明 - 统一 editing-planner/constants/clipProperties.ts 和 subtitleStyle.ts 从 generate/constants 重新导出 FONT_OPTIONS,避免多处维护 - PreviewPlayer.tsx 标题/字幕 fontFamily 通过 getFontFamily() 映射为浏览器可识别字体栈 - FrontendPreviewPlayer.tsx 标题 fontFamily 同样通过 getFontFamily() 映射 - TitleStylePanel/PanelTitleConfig/SubtitleStylePanel 等组件因复用统一常量自动生效 - 最终保留5个字体:思源黑体、思源宋体、苹方、微软雅黑、楷体 - PingFang 别名保留在 FONT_FAMILY_MAP 中以兼容旧数据,但不在 UI 选项中显示 Refs #1896
603 lines
22 KiB
TypeScript
603 lines
22 KiB
TypeScript
/**
|
||
* 前端预览播放器 — 原生 Video 元素方案(浏览器硬件解码,独立线程,不阻塞 UI)
|
||
*
|
||
* 架构:
|
||
* - 默认走原生 video 元素多片段切换播放(useSegmentScheduler 调度),
|
||
* 叠加标题 CSS 浮层、配音音轨(usePreviewAudio)、尾段冻结看门狗、批量播放互斥 token。
|
||
* UI 拆分为 PreviewControls(控制条/按钮) + PreviewProgressBar(进度条)两个子组件。
|
||
* - WebCodecs 路径已废弃(原 useWebCodecs 常量恒为 false,相关死代码已移除),
|
||
* 保留 useCanvasPlayer hook 文件供未来兜底(不影响当前打包体积)。
|
||
*
|
||
* 对外 API 完全不变:assets / videoRatio / ready / voiceAudioUrl / serverClips 等。
|
||
*/
|
||
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
|
||
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 { usePreviewAudio } from "../hooks/usePreviewAudio"
|
||
import { PreviewControls } from "./PreviewControls"
|
||
import { getFontFamily } from "../constants"
|
||
|
||
interface FrontendPreviewPlayerProps {
|
||
assets: AssetItem[]
|
||
videoRatio: string
|
||
ready: boolean
|
||
/** 服务端变体计划真实片段(#1750:必填,无 fallback;批量网格传入各变体自己的 clips) */
|
||
serverClips?: EditPlanClip[]
|
||
voiceAudioUrl?: string
|
||
titleSettings?: {
|
||
title: string
|
||
size: number
|
||
font: string
|
||
color: string
|
||
position: "top" | "center" | "bottom" | "custom"
|
||
bold?: boolean
|
||
italic?: boolean
|
||
stroke?: boolean
|
||
shadow?: boolean
|
||
posX?: number | null
|
||
posY?: number | null
|
||
}
|
||
onTitlePositionChange?: (posX: number, posY: number) => void
|
||
/**
|
||
* 播放互斥 token(#1750):批量网格中为变体序号(0 起),单视频不传。
|
||
* 持有播放权的实例播放,其余自动暂停。
|
||
*/
|
||
playToken?: number
|
||
/** 变体标题文字(批量时每个预览独立标题,叠加在画面上);不传用 titleSettings.title */
|
||
variantTitle?: string
|
||
/** 紧凑模式(批量网格中使用,缩小内边距/标题尺寸) */
|
||
compact?: boolean
|
||
/**
|
||
* 批量网格播放互斥(#1741/#1750):当前持有播放权的实例 token(变体序号)。
|
||
* 持有权变化且不等于自身时,本实例自动暂停(视频+配音)。单视频模式不传。
|
||
*/
|
||
activePlayToken?: number | null
|
||
/** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */
|
||
onPlayTokenChange?: (token: number | null) => void
|
||
/**
|
||
* 后端返回的配音时长(秒)P0 对齐:优先以该值作为音画时长锚点;
|
||
* 未提供则在 audio loadedmetadata 后自测兜底。
|
||
*/
|
||
voiceDurationHint?: number
|
||
}
|
||
|
||
/**
|
||
* 将后端变体计划 clips 映射为播放片段(#1750:唯一数据来源,无本地模拟 fallback)
|
||
*
|
||
* 预览不渲染:浏览器按后端真实计划即时播放素材原片,预览即成片。
|
||
* 模板片段数固定、成片时长=配音时长(后端等比分配),前端不再有任何时长假设。
|
||
*/
|
||
function buildPlaybackSegments(
|
||
assets: AssetItem[],
|
||
serverClips?: EditPlanClip[],
|
||
/** #1754 前端兜底:配音时长≠clips 总时长时,按比例缩放每段播放时长并调速 */
|
||
speedFactor = 1,
|
||
): PlaybackSegment[] {
|
||
if (!assets.length || !serverClips || serverClips.length === 0) return []
|
||
|
||
const assetMap = new Map(assets.map((a) => [a.id, a]))
|
||
const segments: PlaybackSegment[] = []
|
||
// speedFactor > 1 表示 clips 偏短需加速;< 1 表示 clips 偏长需减速
|
||
const invSpeed = speedFactor > 0 && Math.abs(speedFactor - 1) > 0.01 ? 1 / speedFactor : 1
|
||
for (const clip of serverClips) {
|
||
const asset = assetMap.get(clip.asset_id)
|
||
if (!asset) continue
|
||
const assetDuration = asset.duration || asset.metadata?.duration || 0
|
||
const startTime = clip.start_time || 0
|
||
// 片段时长以后端计划为准(配音时长等比分配);素材时长仅用于兜底钳制边界
|
||
const rawClipDuration = clip.duration || 0
|
||
// #1754:按 speedFactor 缩放片段时长,使总时长匹配配音
|
||
const clipDuration = invSpeed !== 1 ? rawClipDuration * invSpeed : rawClipDuration
|
||
const endTime =
|
||
assetDuration > 0
|
||
? Math.min(startTime + clipDuration, assetDuration)
|
||
: startTime + clipDuration
|
||
const videoUrl = asset.file_url || asset.storage_key
|
||
segments.push({
|
||
assetId: asset.id,
|
||
videoUrl,
|
||
startTime,
|
||
endTime,
|
||
order: clip.order,
|
||
playbackRate: invSpeed !== 1 ? speedFactor : undefined,
|
||
})
|
||
}
|
||
return segments.sort((a, b) => a.order - b.order)
|
||
}
|
||
|
||
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||
assets,
|
||
videoRatio,
|
||
ready,
|
||
serverClips,
|
||
voiceAudioUrl,
|
||
voiceDurationHint,
|
||
titleSettings,
|
||
onTitlePositionChange,
|
||
playToken,
|
||
variantTitle,
|
||
compact = false,
|
||
activePlayToken = null,
|
||
onPlayTokenChange,
|
||
}) => {
|
||
// #1754→P0:配音时长作为音画时长锚点。
|
||
// 优先使用后端返回的 voiceDurationHint;音频 loadedmetadata 后再以自测值覆盖(更精确)。
|
||
const [voiceDuration, setVoiceDuration] = useState<number>(() =>
|
||
voiceDurationHint && voiceDurationHint > 0 ? voiceDurationHint : 0,
|
||
)
|
||
|
||
// #1756:clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位)
|
||
const rawClipsDuration = useMemo(() => {
|
||
if (!serverClips?.length) return 0
|
||
return serverClips.reduce((sum, c) => sum + (c.duration || 0) + (c.transition_duration || 0), 0)
|
||
}, [serverClips])
|
||
|
||
// #1754→#1756:配音时长可用且与 clips+转场 总时长偏差 > 5% 时,按比例调速
|
||
const speedFactor = useMemo(() => {
|
||
if (!voiceDuration || voiceDuration <= 0 || rawClipsDuration <= 0) return 1
|
||
const ratio = rawClipsDuration / voiceDuration
|
||
return Math.abs(ratio - 1) > 0.05 ? ratio : 1
|
||
}, [voiceDuration, rawClipsDuration])
|
||
|
||
const segments = useMemo(
|
||
() => buildPlaybackSegments(assets, serverClips, speedFactor),
|
||
[assets, serverClips, speedFactor],
|
||
)
|
||
// 批量变体:标题文字取 variantTitle,样式仍由全局 titleSettings 控制
|
||
const effectiveTitle = variantTitle ?? titleSettings?.title
|
||
|
||
// ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ──
|
||
const TITLE_MARGIN_TOP = 180
|
||
const TITLE_MARGIN_BOTTOM = 100
|
||
const TITLE_MARGIN_SIDE = 40
|
||
const playRes = (() => {
|
||
switch (videoRatio) {
|
||
case "16:9":
|
||
return { width: 1920, height: 1080 }
|
||
case "1:1":
|
||
return { width: 1080, height: 1080 }
|
||
case "9:16":
|
||
default:
|
||
return { width: 1080, height: 1920 }
|
||
}
|
||
})()
|
||
const customTitleXPct =
|
||
titleSettings?.posX != null && playRes.width > 0
|
||
? (titleSettings.posX / playRes.width) * 100
|
||
: null
|
||
const customTitleYPct =
|
||
titleSettings?.posY != null && playRes.height > 0
|
||
? (titleSettings.posY / playRes.height) * 100
|
||
: null
|
||
|
||
// ── 标题拖拽(用 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
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
;(e.target as Element).setPointerCapture(e.pointerId)
|
||
draggingTitleRef.current = true
|
||
;(e.currentTarget as HTMLDivElement).style.cursor = "grabbing"
|
||
},
|
||
[onTitlePositionChange],
|
||
)
|
||
const handleTitlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||
if (!draggingTitleRef.current || !playerContainerRef.current) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
if (titleDragRef.current) {
|
||
const rect = playerContainerRef.current.getBoundingClientRect()
|
||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
|
||
const xpct = (relX / rect.width) * 100
|
||
const ypct = (relY / rect.height) * 100
|
||
titleDragRef.current.style.left = `${xpct}%`
|
||
titleDragRef.current.style.top = `${ypct}%`
|
||
}
|
||
}, [])
|
||
const handleTitlePointerUp = useCallback(
|
||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||
if (!draggingTitleRef.current) return
|
||
draggingTitleRef.current = false
|
||
if (onTitlePositionChange && playerContainerRef.current) {
|
||
const rect = playerContainerRef.current.getBoundingClientRect()
|
||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
|
||
const posX = Math.round((relX / rect.width) * playRes.width)
|
||
const posY = Math.round((relY / rect.height) * playRes.height)
|
||
onTitlePositionChange(posX, posY)
|
||
}
|
||
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
|
||
try {
|
||
if ((e.currentTarget as Element).hasPointerCapture(e.pointerId)) {
|
||
;(e.currentTarget as Element).releasePointerCapture(e.pointerId)
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
},
|
||
[onTitlePositionChange, playRes.width, playRes.height],
|
||
)
|
||
|
||
const [containerHeight, setContainerHeight] = useState(0)
|
||
useEffect(() => {
|
||
const el = playerContainerRef.current
|
||
if (!el) return
|
||
const ro = new ResizeObserver((entries) => {
|
||
for (const entry of entries) {
|
||
const h = entry.contentRect.height
|
||
if (h > 0) setContainerHeight(h)
|
||
}
|
||
})
|
||
ro.observe(el)
|
||
const rect = el.getBoundingClientRect()
|
||
if (rect.height > 0) setContainerHeight(rect.height)
|
||
return () => ro.disconnect()
|
||
}, [])
|
||
|
||
// 标题字号按容器高度与 PlayResY 的比例缩放
|
||
const titleFontSizePx =
|
||
containerHeight > 0
|
||
? ((titleSettings?.size ?? 36) / playRes.height) * containerHeight
|
||
: (titleSettings?.size ?? 36)
|
||
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 播放器(默认路径,浏览器原生硬件解码) ──
|
||
const {
|
||
isPlaying,
|
||
currentTime,
|
||
totalDuration,
|
||
currentSegmentIndex,
|
||
canPlay,
|
||
togglePlayPause,
|
||
seekTo,
|
||
pause,
|
||
videoRefs,
|
||
} = useSegmentScheduler(segments)
|
||
|
||
// P0 fix:以配音时长为音画同步锚点。
|
||
// 有配音时总时长 = 配音时长(短则末帧冻结,长则硬停);无配音时沿用视频总时长(素材原声兜底)。
|
||
const effectiveTotalDuration =
|
||
!!voiceAudioUrl && voiceDuration > 0 ? voiceDuration : totalDuration
|
||
|
||
// 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片
|
||
const [muted, setMuted] = useState(false)
|
||
// 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底
|
||
const hasVoice = !!voiceAudioUrl
|
||
|
||
// 音频 ended:兜底触发暂停与释放播放权
|
||
const handleAudioEnded = useCallback(() => {
|
||
if (!isPlaying) return
|
||
pause()
|
||
if (playToken != null) onPlayTokenChange?.(null)
|
||
}, [isPlaying, pause, playToken, onPlayTokenChange])
|
||
|
||
const {
|
||
seekTo: audioSeekTo,
|
||
ensurePlayingAt: audioEnsurePlayingAt,
|
||
pause: audioPause,
|
||
} = usePreviewAudio({
|
||
voiceAudioUrl,
|
||
voiceDurationHint,
|
||
muted,
|
||
isPlaying,
|
||
currentTime,
|
||
onVoiceDurationChange: setVoiceDuration,
|
||
onEnded: handleAudioEnded,
|
||
})
|
||
|
||
// 片段切换时同步音频时间(video fallback)
|
||
useEffect(() => {
|
||
if (!isPlaying) return
|
||
audioSeekTo(currentTime)
|
||
// 注意:不要把 currentTime 放进依赖数组,否则每200ms会重置音频位置导致卡顿
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [currentSegmentIndex, isPlaying])
|
||
|
||
// P0 fix:视频比配音短时的「末帧冻结+音频续播」模式。
|
||
// 视频调度器播完最后一段自动 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 &&
|
||
typeof currentTime === "number" &&
|
||
currentTime >= totalDuration - 0.1 &&
|
||
currentTime < voiceDuration - 0.1
|
||
if (needTail && tailCurrentTime === null) {
|
||
tailBaseRef.current = currentTime
|
||
tailStartRef.current = performance.now()
|
||
setTailCurrentTime(currentTime)
|
||
tailAudioRef.current.ensurePlayingAt(currentTime)
|
||
return
|
||
}
|
||
if (!needTail && tailCurrentTime !== null) {
|
||
setTailCurrentTime(null)
|
||
}
|
||
}, [isPlaying, currentTime, totalDuration, voiceDuration, voiceAudioUrl, tailCurrentTime])
|
||
|
||
useEffect(() => {
|
||
if (tailCurrentTime === null) return
|
||
let raf = 0
|
||
const tick = () => {
|
||
const elapsed = (performance.now() - tailStartRef.current) / 1000
|
||
const t = Math.min(tailBaseRef.current + elapsed, voiceDuration || tailBaseRef.current)
|
||
setTailCurrentTime(t)
|
||
tailAudioRef.current.ensurePlayingAt(t)
|
||
if (t >= (voiceDuration || 0) - 0.05) {
|
||
tailAudioRef.current.pause()
|
||
if (playToken != null) onPlayTokenChange?.(null)
|
||
setTailCurrentTime(null)
|
||
return
|
||
}
|
||
raf = requestAnimationFrame(tick)
|
||
}
|
||
raf = requestAnimationFrame(tick)
|
||
return () => cancelAnimationFrame(raf)
|
||
}, [tailCurrentTime, voiceDuration, playToken, onPlayTokenChange])
|
||
|
||
// 呈现给 UI/进度条的「当前时间」:尾段用虚拟时间,否则用视频时间
|
||
const displayCurrentTime = tailCurrentTime !== null ? tailCurrentTime : currentTime
|
||
|
||
const handleSeekTo = useCallback(
|
||
(time: number) => {
|
||
setTailCurrentTime(null)
|
||
seekTo(time)
|
||
audioSeekTo(time)
|
||
},
|
||
[seekTo, audioSeekTo],
|
||
)
|
||
|
||
// ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停 ──
|
||
useEffect(() => {
|
||
if (activePlayToken == null || playToken == null || activePlayToken === playToken) return
|
||
if (isPlaying) {
|
||
pause()
|
||
}
|
||
// isPlaying 不放依赖:只在 token 变化时执行一次暂停
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [activePlayToken, playToken])
|
||
|
||
const handleTogglePlay = useCallback(() => {
|
||
if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken)
|
||
togglePlayPause()
|
||
}, [togglePlayPause, isPlaying, playToken, onPlayTokenChange])
|
||
|
||
// P0 fix:音画同步看门狗——有配音时播放时间达到配音时长立即暂停视频+音频(末帧冻结)
|
||
useEffect(() => {
|
||
if (!isPlaying) return
|
||
if (!voiceAudioUrl || voiceDuration <= 0) return
|
||
if (displayCurrentTime < voiceDuration - 0.08) return
|
||
pause()
|
||
audioPause()
|
||
if (playToken != null) onPlayTokenChange?.(null)
|
||
}, [
|
||
isPlaying,
|
||
displayCurrentTime,
|
||
voiceAudioUrl,
|
||
voiceDuration,
|
||
pause,
|
||
audioPause,
|
||
playToken,
|
||
onPlayTokenChange,
|
||
])
|
||
|
||
// ── 未就绪 ──
|
||
if (!ready || !assets.length) {
|
||
return (
|
||
<div
|
||
style={{
|
||
position: "relative",
|
||
width: "100%",
|
||
maxWidth: 280,
|
||
aspectRatio: "9 / 16",
|
||
background: "#0a0a0a",
|
||
borderRadius: 24,
|
||
overflow: "hidden",
|
||
boxShadow:
|
||
"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)",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
}}
|
||
>
|
||
<SoundOutlined 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>
|
||
)
|
||
}
|
||
|
||
// ── 无播放片段 ──
|
||
if (!canPlay) {
|
||
return (
|
||
<div
|
||
style={{
|
||
position: "relative",
|
||
width: "100%",
|
||
maxWidth: 280,
|
||
aspectRatio: "9 / 16",
|
||
background: "#0a0a0a",
|
||
borderRadius: 24,
|
||
overflow: "hidden",
|
||
boxShadow:
|
||
"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)",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
padding: 24,
|
||
}}
|
||
>
|
||
<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>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div
|
||
ref={playerContainerRef}
|
||
style={{
|
||
position: "relative",
|
||
width: "100%",
|
||
maxWidth: compact ? "100%" : 280,
|
||
margin: compact ? 0 : "0 auto",
|
||
aspectRatio: "9 / 16",
|
||
background: compact ? "transparent" : "#0a0a0a",
|
||
borderRadius: compact ? 10 : 24,
|
||
overflow: "hidden",
|
||
boxShadow: compact
|
||
? "inset 0 0 0 1px rgba(255,255,255,0.06)"
|
||
: "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)",
|
||
}}
|
||
>
|
||
{/* ── 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,
|
||
width: "100%",
|
||
height: "100%",
|
||
objectFit: "cover",
|
||
background: "#000",
|
||
zIndex: 1,
|
||
opacity: i === currentSegmentIndex ? 1 : 0,
|
||
pointerEvents: i === currentSegmentIndex ? "auto" : "none",
|
||
}}
|
||
muted={hasVoice || muted}
|
||
playsInline
|
||
/>
|
||
))}
|
||
|
||
{/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */}
|
||
{titleSettings?.title && (
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
inset: 0,
|
||
zIndex: 5,
|
||
pointerEvents: "none",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
width: `${100 - 2 * titleSidePct}%`,
|
||
maxWidth: `${100 - 2 * titleSidePct}%`,
|
||
...(customTitleXPct != null && customTitleYPct != null
|
||
? {
|
||
left: `${customTitleXPct}%`,
|
||
top: `${customTitleYPct}%`,
|
||
transform: "translate(-50%, -50%)",
|
||
textAlign: "center" as const,
|
||
}
|
||
: {
|
||
left: "50%",
|
||
transform: "translateX(-50%)",
|
||
textAlign: "center" as const,
|
||
...(titleSettings.position === "top"
|
||
? { top: `${titleTopPct}%` }
|
||
: titleSettings.position === "center"
|
||
? { top: "50%", transform: "translate(-50%, -50%)" }
|
||
: { bottom: `${titleBottomPct}%` }),
|
||
}),
|
||
pointerEvents: onTitlePositionChange && playToken == null ? "auto" : "none",
|
||
cursor: onTitlePositionChange && playToken == null ? "grab" : "default",
|
||
touchAction: "none",
|
||
userSelect: "none",
|
||
WebkitUserSelect: "none",
|
||
padding: "8px 12px",
|
||
boxShadow: "inset 0 0 0 16px transparent",
|
||
}}
|
||
ref={titleDragRef}
|
||
onPointerDown={handleTitlePointerDown}
|
||
onPointerMove={handleTitlePointerMove}
|
||
onPointerUp={handleTitlePointerUp}
|
||
onPointerCancel={handleTitlePointerUp}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: `${titleFontSizePx}px`,
|
||
fontFamily: getFontFamily(titleSettings.font || "思源黑体"),
|
||
color: titleSettings.color || "#ffffff",
|
||
fontWeight: titleSettings.bold ? 700 : 400,
|
||
fontStyle: titleSettings.italic ? "italic" : "normal",
|
||
lineHeight: 1.05,
|
||
wordBreak: "break-word",
|
||
WebkitTextStroke: titleSettings.stroke
|
||
? `${titleStrokeWidth}px #000000`
|
||
: undefined,
|
||
textShadow: titleSettings.shadow
|
||
? `${titleShadowOffset}px ${titleShadowOffset}px ${titleShadowBlur}px rgba(0,0,0,0.8)`
|
||
: undefined,
|
||
}}
|
||
>
|
||
{(effectiveTitle || "").split(/[//]/).map((part, i) => (
|
||
<span key={i}>
|
||
{i > 0 && <br />}
|
||
{part}
|
||
</span>
|
||
))}
|
||
</span>
|
||
</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>
|
||
)
|
||
}
|
||
|
||
export default FrontendPreviewPlayer
|