f8276ed4c8
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 709h31m53s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 709h31m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 709h31m55s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 709h31m58s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 709h31m58s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 710h5m33s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 710h5m37s
310 lines
8.7 KiB
TypeScript
310 lines
8.7 KiB
TypeScript
/**
|
|
* 前端预览播放器
|
|
* 用原生 <video> 标签按时间线播放素材片段
|
|
* 替代后端 FFmpeg 渲染预览,实现真正的实时预览
|
|
*/
|
|
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
|
|
import {
|
|
PlayCircleOutlined,
|
|
PauseCircleOutlined,
|
|
SoundOutlined,
|
|
} from "@ant-design/icons"
|
|
import type { AssetItem } from "@/api/assets"
|
|
import type { EditingTemplate } from "@/api/editing-planner"
|
|
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
|
|
|
|
interface FrontendPreviewPlayerProps {
|
|
/** 选中的素材列表 */
|
|
assets: AssetItem[]
|
|
/** 当前模板(用于获取片段时长配置) */
|
|
template: EditingTemplate | null
|
|
/** 视频比例 */
|
|
videoRatio: string
|
|
/** 是否准备好播放(素材已加载) */
|
|
ready: boolean
|
|
}
|
|
|
|
/** 格式化时间 mm:ss */
|
|
function formatTime(seconds: number): string {
|
|
const m = Math.floor(seconds / 60)
|
|
const s = Math.floor(seconds % 60)
|
|
return `${m}:${s.toString().padStart(2, "0")}`
|
|
}
|
|
|
|
/**
|
|
* 将素材映射为播放片段
|
|
* 每个素材对应一个模板片段,按顺序分配
|
|
*/
|
|
function buildPlaybackSegments(
|
|
assets: AssetItem[],
|
|
template: EditingTemplate | null,
|
|
): PlaybackSegment[] {
|
|
if (!assets.length) return []
|
|
|
|
const templateSegments = template?.segments || []
|
|
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))
|
|
: Math.min(assetDuration, 10)
|
|
|
|
const startTime = 0
|
|
const endTime = Math.min(startTime + segDuration, assetDuration)
|
|
|
|
segments.push({
|
|
assetId: asset.id,
|
|
videoUrl: asset.file_url || asset.storage_key,
|
|
startTime,
|
|
endTime,
|
|
order: i,
|
|
})
|
|
})
|
|
|
|
return segments
|
|
}
|
|
|
|
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
|
assets,
|
|
template,
|
|
videoRatio,
|
|
ready,
|
|
}) => {
|
|
// 构建播放片段
|
|
const segments = useMemo(
|
|
() => buildPlaybackSegments(assets, template),
|
|
[assets, template],
|
|
)
|
|
|
|
const {
|
|
isPlaying,
|
|
currentTime,
|
|
totalDuration,
|
|
currentSegmentIndex,
|
|
isEnded,
|
|
canPlay,
|
|
togglePlayPause,
|
|
seekTo,
|
|
videoRef,
|
|
} = useSegmentScheduler(segments)
|
|
|
|
// 进度条拖拽
|
|
const [isDragging, setIsDragging] = useState(false)
|
|
const progressRef = useRef<HTMLDivElement>(null)
|
|
|
|
const handleProgressClick = useCallback(
|
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!progressRef.current || totalDuration <= 0) return
|
|
const rect = progressRef.current.getBoundingClientRect()
|
|
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
|
seekTo(ratio * totalDuration)
|
|
},
|
|
[totalDuration, seekTo],
|
|
)
|
|
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
setIsDragging(true)
|
|
handleProgressClick(e)
|
|
},
|
|
[handleProgressClick],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!isDragging) return
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (!progressRef.current || totalDuration <= 0) return
|
|
const rect = progressRef.current.getBoundingClientRect()
|
|
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
|
seekTo(ratio * totalDuration)
|
|
}
|
|
const handleMouseUp = () => setIsDragging(false)
|
|
window.addEventListener("mousemove", handleMouseMove)
|
|
window.addEventListener("mouseup", handleMouseUp)
|
|
return () => {
|
|
window.removeEventListener("mousemove", handleMouseMove)
|
|
window.removeEventListener("mouseup", handleMouseUp)
|
|
}
|
|
}, [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">
|
|
<SoundOutlined style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }} />
|
|
<p className="xx-preview-empty-title">准备预览素材...</p>
|
|
<p className="xx-preview-empty-desc">加载素材后即可预览播放</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 无播放片段
|
|
if (!canPlay) {
|
|
return (
|
|
<div className="xx-preview-empty">
|
|
<PlayCircleOutlined style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }} />
|
|
<p className="xx-preview-empty-title">暂无可播放素材</p>
|
|
<p className="xx-preview-empty-desc">请先在左侧选择素材</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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
|
|
/>
|
|
|
|
{/* 播放/暂停按钮覆盖 */}
|
|
{!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
|
|
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>
|
|
|
|
{/* 控制条 */}
|
|
<div
|
|
className="xx-preview-controls"
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
padding: "8px 0",
|
|
}}
|
|
>
|
|
{/* 播放/暂停 */}
|
|
<button
|
|
onClick={togglePlayPause}
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
color: "var(--text-primary, #fff)",
|
|
fontSize: 18,
|
|
cursor: "pointer",
|
|
padding: 4,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
|
</button>
|
|
|
|
{/* 时间 */}
|
|
<span
|
|
style={{
|
|
fontSize: 12,
|
|
color: "var(--text-secondary, #999)",
|
|
minWidth: 80,
|
|
fontVariantNumeric: "tabular-nums",
|
|
}}
|
|
>
|
|
{formatTime(currentTime)} / {formatTime(totalDuration)}
|
|
</span>
|
|
|
|
{/* 进度条 */}
|
|
<div
|
|
ref={progressRef}
|
|
onMouseDown={handleMouseDown}
|
|
style={{
|
|
flex: 1,
|
|
height: 4,
|
|
background: "rgba(255,255,255,0.15)",
|
|
borderRadius: 2,
|
|
cursor: "pointer",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
height: "100%",
|
|
width: `${progressPercent}%`,
|
|
background: "#3b82f6",
|
|
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: "#3b82f6",
|
|
border: "2px solid #fff",
|
|
opacity: isDragging ? 1 : 0,
|
|
transition: "opacity 0.15s",
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default FrontendPreviewPlayer
|