Files
xiaoxia-saas/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx
T
xiaoxia 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
fix: handleMouseDown 事件类型修正为 HTMLDivElement
2026-08-18 01:54:31 +08:00

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