feat: Canvas + WebCodecs 预览播放器核心实现 #1436
Generated
+10
@@ -12,6 +12,7 @@
|
||||
"@tanstack/react-query": "^5.45.0",
|
||||
"antd": "^5.18.0",
|
||||
"axios": "^1.7.2",
|
||||
"mp4box": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.24.0",
|
||||
@@ -4623,6 +4624,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mp4box": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/mp4box/-/mp4box-2.4.1.tgz",
|
||||
"integrity": "sha512-0HGX7nXoDIX6FKLVl4a3wtYjBlwqsN3xuQC3GXzNtKp98FXUOhDSq623azsz8DG5ptd9ZXcXodDkgbdMZOjWvw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@tanstack/react-query": "^5.45.0",
|
||||
"antd": "^5.18.0",
|
||||
"axios": "^1.7.2",
|
||||
"mp4box": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.24.0",
|
||||
|
||||
@@ -1,31 +1,43 @@
|
||||
/**
|
||||
* 前端预览播放器(多 video 元素方案)
|
||||
* 为每个片段创建独立 <video> 元素,全部预加载
|
||||
* 用 display: none/block 切换当前播放的视频,实现无缝片段切换
|
||||
* 前端预览播放器 — Canvas + WebCodecs 方案
|
||||
*
|
||||
* 注意:本组件不创建 .xx-preview-video 容器(由父组件 PreviewVideoPanel 提供)
|
||||
* 避免嵌套 .xx-preview-video 导致 CSS 冲突
|
||||
* 架构:
|
||||
* - 浏览器支持 WebCodecs → Canvas 渲染(帧级精确控制 + 标题合成)
|
||||
* - 浏览器不支持 → fallback 到多 video 元素方案
|
||||
*
|
||||
* 对外 API 不变:assets, template, videoRatio, ready, voiceAudioUrl
|
||||
*/
|
||||
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
|
||||
import { PlayCircleOutlined, PauseCircleOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
SoundOutlined,
|
||||
LoadingOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
|
||||
import { useCanvasPlayer, isWebCodecsSupported } from "../hooks/useCanvasPlayer"
|
||||
|
||||
interface FrontendPreviewPlayerProps {
|
||||
/** 选中的素材列表 */
|
||||
assets: AssetItem[]
|
||||
/** 当前模板(用于获取片段时长配置) */
|
||||
template: EditingTemplate | null
|
||||
/** 视频比例 */
|
||||
videoRatio: string
|
||||
/** 是否准备好播放(素材已加载) */
|
||||
ready: boolean
|
||||
/** 配音音频 URL */
|
||||
voiceAudioUrl?: string
|
||||
titleSettings?: {
|
||||
title: string
|
||||
size: number
|
||||
font: string
|
||||
color: string
|
||||
position: "top" | "center" | "bottom"
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化时间 mm:ss */
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
@@ -33,8 +45,7 @@ function formatTime(seconds: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将素材映射为播放片段
|
||||
* 每个素材对应一个模板片段,按顺序分配
|
||||
* 将素材映射为播放片段(复用原逻辑)
|
||||
*/
|
||||
function buildPlaybackSegments(
|
||||
assets: AssetItem[],
|
||||
@@ -54,20 +65,9 @@ function buildPlaybackSegments(
|
||||
|
||||
const startTime = 0
|
||||
const endTime = Math.min(startTime + segDuration, assetDuration)
|
||||
|
||||
const videoUrl = asset.file_url || asset.storage_key
|
||||
|
||||
console.log(
|
||||
`[buildPlaybackSegments] 片段 ${i}: assetId=${asset.id}, videoUrl=${videoUrl?.substring(0, 80)}, file_url=${!!asset.file_url}`,
|
||||
)
|
||||
|
||||
segments.push({
|
||||
assetId: asset.id,
|
||||
videoUrl,
|
||||
startTime,
|
||||
endTime,
|
||||
order: i,
|
||||
})
|
||||
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: i })
|
||||
})
|
||||
|
||||
return segments
|
||||
@@ -79,28 +79,70 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
videoRatio: _videoRatio,
|
||||
ready,
|
||||
voiceAudioUrl,
|
||||
titleSettings,
|
||||
}) => {
|
||||
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
|
||||
const useWebCodecs = isWebCodecsSupported()
|
||||
|
||||
// ── 两条路径共用同一个 canvas ref(fallback 路径不使用) ──
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
// ── Canvas 播放器(WebCodecs 路径) ──
|
||||
const canvasTitle = titleSettings
|
||||
? {
|
||||
text: titleSettings.title || "标题预览",
|
||||
fontSize: titleSettings.size,
|
||||
fontFamily: titleSettings.font || "思源黑体",
|
||||
color: titleSettings.color || "#ffffff",
|
||||
position: titleSettings.position || "bottom",
|
||||
bold: titleSettings.bold,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const canvasSegments = useMemo(
|
||||
() =>
|
||||
segments.map((s) => ({
|
||||
assetId: s.assetId,
|
||||
videoUrl: s.videoUrl,
|
||||
startTime: s.startTime,
|
||||
endTime: s.endTime,
|
||||
})),
|
||||
[segments],
|
||||
)
|
||||
|
||||
const { state: canvasState, controls: canvasControls } = useCanvasPlayer(
|
||||
canvasRef,
|
||||
canvasSegments,
|
||||
useWebCodecs ? canvasTitle : undefined,
|
||||
)
|
||||
|
||||
// ── Video 播放器(fallback 路径) ──
|
||||
const {
|
||||
isPlaying,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
currentSegmentIndex,
|
||||
canPlay,
|
||||
togglePlayPause,
|
||||
seekTo,
|
||||
isPlaying: videoIsPlaying,
|
||||
currentTime: videoCurrentTime,
|
||||
totalDuration: videoTotalDuration,
|
||||
currentSegmentIndex: videoCurrentSegIdx,
|
||||
canPlay: videoCanPlay,
|
||||
togglePlayPause: videoTogglePlayPause,
|
||||
seekTo: videoSeekTo,
|
||||
videoRefs,
|
||||
} = useSegmentScheduler(segments)
|
||||
|
||||
// 选择哪条路径的状态
|
||||
const isPlaying = useWebCodecs ? canvasState.isPlaying : videoIsPlaying
|
||||
const currentTime = useWebCodecs ? canvasState.currentTime : videoCurrentTime
|
||||
const totalDuration = useWebCodecs ? canvasState.duration : videoTotalDuration
|
||||
const canPlay = useWebCodecs ? canvasState.isReady : videoCanPlay
|
||||
const isBuffering = useWebCodecs ? canvasState.isBuffering : false
|
||||
|
||||
// ── 配音音频同步 ──
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const prevIsPlayingRef = useRef(false)
|
||||
|
||||
// 创建/更新 Audio 元素
|
||||
useEffect(() => {
|
||||
if (!voiceAudioUrl) {
|
||||
// 没有配音,清理已有 audio
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.src = ""
|
||||
@@ -108,7 +150,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio()
|
||||
audioRef.current.preload = "auto"
|
||||
@@ -118,59 +159,52 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
}
|
||||
}, [voiceAudioUrl])
|
||||
|
||||
// 同步播放状态
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audio.src) return
|
||||
|
||||
if (isPlaying && !prevIsPlayingRef.current) {
|
||||
// 刚进入播放
|
||||
audio.currentTime = currentTime
|
||||
audio.play().catch(() => {})
|
||||
} else if (!isPlaying && prevIsPlayingRef.current) {
|
||||
// 刚暂停
|
||||
audio.pause()
|
||||
}
|
||||
prevIsPlayingRef.current = isPlaying
|
||||
}, [isPlaying, currentTime])
|
||||
|
||||
// 片段切换时同步音频 — 将 audio.currentTime 对齐到视频全局时间
|
||||
// 片段切换时同步音频(仅 fallback 路径需要)
|
||||
const segmentSyncKey = useWebCodecs ? -1 : videoCurrentSegIdx
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audio.src || !isPlaying) return
|
||||
// 用视频当前的全局时间对齐音频
|
||||
audio.currentTime = currentTime
|
||||
}, [currentSegmentIndex, currentTime, isPlaying])
|
||||
}, [segmentSyncKey, isPlaying, currentTime])
|
||||
|
||||
// seek 时同步音频
|
||||
const handleSeekTo = useCallback(
|
||||
(time: number) => {
|
||||
seekTo(time)
|
||||
if (useWebCodecs) {
|
||||
canvasControls.seek(time)
|
||||
} else {
|
||||
videoSeekTo(time)
|
||||
}
|
||||
const audio = audioRef.current
|
||||
if (audio && audio.src) {
|
||||
audio.currentTime = time
|
||||
}
|
||||
},
|
||||
[seekTo],
|
||||
[useWebCodecs, canvasControls, videoSeekTo],
|
||||
)
|
||||
|
||||
// 播放结束时暂停音频
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
const audio = audioRef.current
|
||||
if (audio) audio.pause()
|
||||
}
|
||||
}, [isPlaying])
|
||||
|
||||
// 清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.src = ""
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
if (useWebCodecs) {
|
||||
if (canvasState.isPlaying) {
|
||||
canvasControls.pause()
|
||||
} else {
|
||||
canvasControls.play()
|
||||
}
|
||||
} else {
|
||||
videoTogglePlayPause()
|
||||
}
|
||||
}, [])
|
||||
}, [useWebCodecs, canvasState.isPlaying, canvasControls, videoTogglePlayPause])
|
||||
|
||||
// ── 进度条拖拽 ──
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
@@ -213,7 +247,26 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
|
||||
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
|
||||
|
||||
// ── 未就绪状态 ──
|
||||
// ── Canvas ResizeObserver ──
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const container = canvasContainerRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!container || !canvas) return
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect
|
||||
if (width > 0 && height > 0) {
|
||||
canvas.width = width * window.devicePixelRatio
|
||||
canvas.height = height * window.devicePixelRatio
|
||||
}
|
||||
}
|
||||
})
|
||||
ro.observe(container)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// ── 未就绪 ──
|
||||
if (!ready || !assets.length) {
|
||||
return (
|
||||
<div
|
||||
@@ -250,48 +303,79 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
|
||||
/>
|
||||
<p className="xx-preview-empty-title">暂无可播放素材</p>
|
||||
<p className="xx-preview-empty-desc">请先在左侧选择素材</p>
|
||||
{isBuffering ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ fontSize: 48, color: "#fff", marginBottom: 12 }} spin />
|
||||
<p style={{ color: "rgba(255,255,255,0.8)" }}>加载中...</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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 (
|
||||
<>
|
||||
{/* ✅ 多 video 元素方案:每个片段一个独立 video,全部预加载
|
||||
通过 display 切换当前播放的视频,无需重新 load,实现无缝切换 */}
|
||||
{segments.map((seg, i) => (
|
||||
<video
|
||||
key={seg.assetId}
|
||||
muted
|
||||
ref={(el) => {
|
||||
videoRefs.current[i] = el
|
||||
}}
|
||||
preload="auto"
|
||||
src={seg.videoUrl}
|
||||
{/* ── Canvas 渲染层(WebCodecs 路径) ── */}
|
||||
{useWebCodecs && (
|
||||
<div
|
||||
ref={canvasContainerRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
background: "#000",
|
||||
zIndex: 1,
|
||||
opacity: i === currentSegmentIndex ? 1 : 0,
|
||||
pointerEvents: i === currentSegmentIndex ? "auto" : "none",
|
||||
background: "#000",
|
||||
}}
|
||||
playsInline
|
||||
/>
|
||||
))}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放按钮覆盖层 */}
|
||||
{/* ── Video 渲染层(fallback 路径) ── */}
|
||||
{!useWebCodecs &&
|
||||
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,
|
||||
opacity: i === videoCurrentSegIdx ? 1 : 0,
|
||||
pointerEvents: i === videoCurrentSegIdx ? "auto" : "none",
|
||||
}}
|
||||
playsInline
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 播放按钮 */}
|
||||
{!isPlaying && (
|
||||
<button
|
||||
className="xx-preview-play-btn"
|
||||
onClick={togglePlayPause}
|
||||
onClick={handleTogglePlay}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
@@ -309,7 +393,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
color: "#fff",
|
||||
fontSize: 28,
|
||||
zIndex: 10,
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
@@ -330,10 +413,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
片段 {currentSegmentIndex + 1}/{segments.length}
|
||||
{useWebCodecs ? "Canvas" : `片段 ${videoCurrentSegIdx + 1}/${segments.length}`}
|
||||
</div>
|
||||
|
||||
{/* 控制条 — 绝对定位在底部 */}
|
||||
{/* 控制条 */}
|
||||
<div
|
||||
className="xx-preview-controls"
|
||||
style={{
|
||||
@@ -349,9 +432,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{/* 播放/暂停 */}
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
onClick={handleTogglePlay}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
@@ -366,7 +448,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
{/* 时间 */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
@@ -378,7 +459,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
{formatTime(currentTime)} / {formatTime(totalDuration)}
|
||||
</span>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div
|
||||
ref={progressRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* Canvas + WebCodecs 播放器核心 Hook
|
||||
* MP4 → mp4box.js 解封装 → VideoDecoder 解码帧 → Canvas 绘制
|
||||
* 音频走 Web Audio API 独立同步播放
|
||||
*
|
||||
* 浏览器不支持 WebCodecs 时返回 hasSupport=false,由调用方 fallback
|
||||
*/
|
||||
import { useRef, useCallback, useEffect, useState } from "react"
|
||||
import { createFile } from "mp4box"
|
||||
import type { Movie, Sample } from "mp4box"
|
||||
|
||||
// ── 帧队列(环形缓冲区) ──
|
||||
interface FrameEntry {
|
||||
frame: VideoFrame
|
||||
pts: number // 显示时间戳(秒)
|
||||
duration: number // 帧持续时长(秒)
|
||||
}
|
||||
|
||||
class FrameQueue {
|
||||
private frames: FrameEntry[] = []
|
||||
private maxSize: number
|
||||
|
||||
constructor(maxSize = 5) {
|
||||
this.maxSize = maxSize
|
||||
}
|
||||
|
||||
push(entry: FrameEntry) {
|
||||
// 如果队列已满,丢弃最旧的帧
|
||||
while (this.frames.length >= this.maxSize) {
|
||||
const old = this.frames.shift()
|
||||
old?.frame.close()
|
||||
}
|
||||
this.frames.push(entry)
|
||||
}
|
||||
|
||||
/** 获取当前时间戳应显示的帧 */
|
||||
getCurrentFrame(timestamp: number): VideoFrame | null {
|
||||
// 找到 pts <= timestamp 的最新帧
|
||||
let best: FrameEntry | null = null
|
||||
let bestIdx = -1
|
||||
for (let i = 0; i < this.frames.length; i++) {
|
||||
const f = this.frames[i]
|
||||
if (f.pts <= timestamp + 0.01) {
|
||||
// 10ms 容差
|
||||
best = f
|
||||
bestIdx = i
|
||||
}
|
||||
}
|
||||
// 释放已消费帧之前的所有帧
|
||||
for (let i = 0; i < bestIdx; i++) {
|
||||
this.frames[i].frame.close()
|
||||
}
|
||||
if (bestIdx >= 0) {
|
||||
this.frames = this.frames.slice(bestIdx)
|
||||
}
|
||||
return best?.frame ?? null
|
||||
}
|
||||
|
||||
clear() {
|
||||
for (const f of this.frames) {
|
||||
f.frame.close()
|
||||
}
|
||||
this.frames = []
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.frames.length
|
||||
}
|
||||
}
|
||||
|
||||
// ── 播放器状态 ──
|
||||
export interface CanvasPlayerState {
|
||||
/** 是否支持 WebCodecs */
|
||||
hasSupport: boolean
|
||||
/** 是否正在播放 */
|
||||
isPlaying: boolean
|
||||
/** 当前播放时间(秒) */
|
||||
currentTime: number
|
||||
/** 总时长(秒) */
|
||||
duration: number
|
||||
/** 是否已加载(可以播放) */
|
||||
isReady: boolean
|
||||
/** 是否正在缓冲 */
|
||||
isBuffering: boolean
|
||||
}
|
||||
|
||||
export interface CanvasPlayerControls {
|
||||
play: () => void
|
||||
pause: () => void
|
||||
seek: (time: number) => void
|
||||
destroy: () => void
|
||||
}
|
||||
|
||||
interface SegmentSource {
|
||||
assetId: string
|
||||
videoUrl: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
}
|
||||
|
||||
/** 检测浏览器是否支持 WebCodecs VideoDecoder */
|
||||
export function isWebCodecsSupported(): boolean {
|
||||
return typeof window !== "undefined" && "VideoDecoder" in window && "VideoFrame" in window
|
||||
}
|
||||
|
||||
/**
|
||||
* useCanvasPlayer — Canvas + WebCodecs 播放器核心
|
||||
*/
|
||||
export function useCanvasPlayer(
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>,
|
||||
segments: SegmentSource[],
|
||||
titleSettings?: {
|
||||
text: string
|
||||
fontSize: number
|
||||
fontFamily: string
|
||||
color: string
|
||||
position: "top" | "center" | "bottom"
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
},
|
||||
) {
|
||||
const [state, setState] = useState<CanvasPlayerState>({
|
||||
hasSupport: isWebCodecsSupported(),
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
isReady: false,
|
||||
isBuffering: false,
|
||||
})
|
||||
|
||||
// ── 内部引用 ──
|
||||
const decoderRef = useRef<VideoDecoder | null>(null)
|
||||
const frameQueueRef = useRef(new FrameQueue(5))
|
||||
const audioCtxRef = useRef<AudioContext | null>(null)
|
||||
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null)
|
||||
const rafRef = useRef<number>(0)
|
||||
const playStartRef = useRef<number>(0)
|
||||
const playStartOffsetRef = useRef<number>(0)
|
||||
const segmentDataRef = useRef<Map<string, ArrayBuffer>>(new Map())
|
||||
const videoTrackRef = useRef<{ id: number; timescale: number; codec: string } | null>(null)
|
||||
const isDestroyedRef = useRef(false)
|
||||
|
||||
// 计算总时长
|
||||
const totalDuration = segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0)
|
||||
|
||||
// ── 加载 MP4 文件数据 ──
|
||||
const loadSegment = useCallback(async (segment: SegmentSource): Promise<void> => {
|
||||
if (isDestroyedRef.current) return
|
||||
if (segmentDataRef.current.has(segment.assetId)) return
|
||||
|
||||
setState((s) => ({ ...s, isBuffering: true }))
|
||||
|
||||
try {
|
||||
const resp = await fetch(segment.videoUrl)
|
||||
const buffer = await resp.arrayBuffer()
|
||||
segmentDataRef.current.set(segment.assetId, buffer)
|
||||
} catch (err) {
|
||||
console.error("[useCanvasPlayer] Failed to fetch segment:", err)
|
||||
} finally {
|
||||
setState((s) => ({ ...s, isBuffering: false }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── 初始化 VideoDecoder ──
|
||||
const initDecoder = useCallback(async (codec: string, width: number, height: number) => {
|
||||
if (!isWebCodecsSupported()) return false
|
||||
|
||||
const decoder = new VideoDecoder({
|
||||
output: (frame: VideoFrame) => {
|
||||
frameQueueRef.current.push({
|
||||
frame,
|
||||
pts: frame.timestamp / 1_000_000,
|
||||
duration: (frame.duration ?? 0) / 1_000_000,
|
||||
})
|
||||
},
|
||||
error: (e: DOMException) => {
|
||||
console.error("[useCanvasPlayer] Decoder error:", e)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await decoder.configure({
|
||||
codec,
|
||||
codedWidth: width,
|
||||
codedHeight: height,
|
||||
})
|
||||
decoderRef.current = decoder
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error("[useCanvasPlayer] Decoder configure failed:", err)
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── 解封装 MP4 并送入解码器 ──
|
||||
const demuxAndDecode = useCallback(
|
||||
async (buffer: ArrayBuffer) => {
|
||||
const mp4File = createFile()
|
||||
|
||||
mp4File.onReady = (info: Movie) => {
|
||||
const videoTrack = info.videoTracks[0]
|
||||
if (!videoTrack) {
|
||||
console.error("[useCanvasPlayer] No video track found")
|
||||
return
|
||||
}
|
||||
|
||||
videoTrackRef.current = {
|
||||
id: videoTrack.id,
|
||||
timescale: videoTrack.timescale,
|
||||
codec: videoTrack.codec,
|
||||
}
|
||||
|
||||
const width = videoTrack.track_width || 1280
|
||||
const height = videoTrack.track_height || 720
|
||||
initDecoder(videoTrack.codec, width, height)
|
||||
|
||||
mp4File.setExtractionOptions(videoTrack.id, null, {
|
||||
nbSamples: videoTrack.nb_samples,
|
||||
})
|
||||
mp4File.start()
|
||||
}
|
||||
|
||||
mp4File.onSamples = (trackId: number, _user: unknown, samples: Sample[]) => {
|
||||
if (isDestroyedRef.current) return
|
||||
|
||||
const videoTrack = videoTrackRef.current
|
||||
if (!videoTrack || trackId !== videoTrack.id) return
|
||||
|
||||
const decoder = decoderRef.current
|
||||
if (!decoder || decoder.state === "closed") return
|
||||
|
||||
for (const sample of samples) {
|
||||
if (!sample.data) continue
|
||||
|
||||
const chunk = new EncodedVideoChunk({
|
||||
type: sample.is_sync ? "key" : "delta",
|
||||
timestamp: (sample.cts / videoTrack.timescale) * 1_000_000,
|
||||
duration: (sample.duration / videoTrack.timescale) * 1_000_000,
|
||||
data: sample.data.buffer as ArrayBuffer,
|
||||
})
|
||||
|
||||
try {
|
||||
decoder.decode(chunk)
|
||||
} catch (e) {
|
||||
console.warn("[useCanvasPlayer] Decode chunk error:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mp4File.onError = (_module: string, message: string) => {
|
||||
console.error(`[useCanvasPlayer] MP4Box error: ${message}`)
|
||||
}
|
||||
|
||||
// mp4box 需要在 buffer 上设置 fileStart 属性
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(buffer as any).fileStart = 0
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mp4File.appendBuffer(buffer as any)
|
||||
},
|
||||
[initDecoder],
|
||||
)
|
||||
|
||||
// ── 标题绘制 ──
|
||||
const drawTitle = useCallback(
|
||||
(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
title: NonNullable<typeof titleSettings>,
|
||||
) => {
|
||||
const fontSize = (title.fontSize / 720) * canvas.height
|
||||
ctx.font = `${title.bold ? "bold" : "normal"} ${fontSize}px ${title.fontFamily}`
|
||||
ctx.fillStyle = title.color
|
||||
ctx.textAlign = "center"
|
||||
|
||||
let y: number
|
||||
switch (title.position) {
|
||||
case "top":
|
||||
y = fontSize + canvas.height * 0.08
|
||||
break
|
||||
case "bottom":
|
||||
y = canvas.height - canvas.height * 0.08
|
||||
break
|
||||
case "center":
|
||||
default:
|
||||
y = canvas.height / 2
|
||||
break
|
||||
}
|
||||
|
||||
if (title.shadow) {
|
||||
ctx.shadowColor = "rgba(0,0,0,0.8)"
|
||||
ctx.shadowBlur = 4
|
||||
ctx.shadowOffsetX = 2
|
||||
ctx.shadowOffsetY = 2
|
||||
}
|
||||
|
||||
if (title.stroke) {
|
||||
ctx.strokeStyle = "#000000"
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeText(title.text, canvas.width / 2, y)
|
||||
}
|
||||
|
||||
ctx.fillText(title.text, canvas.width / 2, y)
|
||||
|
||||
// 重置阴影
|
||||
ctx.shadowColor = "transparent"
|
||||
ctx.shadowBlur = 0
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = 0
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// ── Canvas 渲染循环 ──
|
||||
const renderFrame = useCallback(() => {
|
||||
if (isDestroyedRef.current) return
|
||||
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const elapsed = (performance.now() - playStartRef.current) / 1000
|
||||
const currentTime = Math.min(playStartOffsetRef.current + elapsed, totalDuration)
|
||||
|
||||
const frame = frameQueueRef.current.getCurrentFrame(currentTime)
|
||||
|
||||
if (frame) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
if (titleSettings?.text) {
|
||||
drawTitle(ctx, canvas, titleSettings)
|
||||
}
|
||||
|
||||
setState((s) => {
|
||||
if (Math.abs(s.currentTime - currentTime) > 0.1) {
|
||||
return { ...s, currentTime }
|
||||
}
|
||||
return s
|
||||
})
|
||||
|
||||
if (currentTime >= totalDuration) {
|
||||
setState((s) => ({ ...s, isPlaying: false }))
|
||||
return
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(renderFrame)
|
||||
}, [canvasRef, totalDuration, titleSettings, drawTitle])
|
||||
|
||||
// ── 播放控制 ──
|
||||
const play = useCallback(async () => {
|
||||
if (!state.hasSupport || isDestroyedRef.current) return
|
||||
|
||||
if (!audioCtxRef.current) {
|
||||
audioCtxRef.current = new AudioContext()
|
||||
}
|
||||
|
||||
setState((s) => ({ ...s, isPlaying: true }))
|
||||
playStartRef.current = performance.now()
|
||||
playStartOffsetRef.current = state.currentTime
|
||||
rafRef.current = requestAnimationFrame(renderFrame)
|
||||
}, [state.hasSupport, state.currentTime, renderFrame])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
setState((s) => ({ ...s, isPlaying: false }))
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
|
||||
if (audioSourceRef.current && audioCtxRef.current) {
|
||||
audioSourceRef.current.stop()
|
||||
audioSourceRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const seek = useCallback(
|
||||
(time: number) => {
|
||||
const clampedTime = Math.max(0, Math.min(time, totalDuration))
|
||||
setState((s) => ({ ...s, currentTime: clampedTime }))
|
||||
playStartOffsetRef.current = clampedTime
|
||||
playStartRef.current = performance.now()
|
||||
},
|
||||
[totalDuration],
|
||||
)
|
||||
|
||||
const destroy = useCallback(() => {
|
||||
isDestroyedRef.current = true
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
|
||||
if (decoderRef.current && decoderRef.current.state !== "closed") {
|
||||
decoderRef.current.close()
|
||||
}
|
||||
|
||||
frameQueueRef.current.clear()
|
||||
|
||||
if (audioSourceRef.current) {
|
||||
audioSourceRef.current.stop()
|
||||
audioSourceRef.current = null
|
||||
}
|
||||
|
||||
if (audioCtxRef.current) {
|
||||
audioCtxRef.current.close()
|
||||
audioCtxRef.current = null
|
||||
}
|
||||
|
||||
segmentDataRef.current.clear()
|
||||
}, [])
|
||||
|
||||
// ── 预加载下一个片段 ──
|
||||
const preloadNext = useCallback(
|
||||
async (currentIndex: number) => {
|
||||
const nextIdx = currentIndex + 1
|
||||
if (nextIdx >= segments.length) return
|
||||
const next = segments[nextIdx]
|
||||
if (segmentDataRef.current.has(next.assetId)) return
|
||||
await loadSegment(next)
|
||||
},
|
||||
[segments, loadSegment],
|
||||
)
|
||||
|
||||
// ── 初始化:加载第一个片段并解封装 ──
|
||||
useEffect(() => {
|
||||
if (!state.hasSupport || segments.length === 0) return
|
||||
|
||||
const init = async () => {
|
||||
await loadSegment(segments[0])
|
||||
const buffer = segmentDataRef.current.get(segments[0].assetId)
|
||||
if (buffer) {
|
||||
await demuxAndDecode(buffer)
|
||||
setState((s) => ({ ...s, duration: totalDuration, isReady: true }))
|
||||
preloadNext(0)
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
return () => {
|
||||
destroy()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [segments, state.hasSupport])
|
||||
|
||||
return {
|
||||
state: { ...state, duration: totalDuration },
|
||||
controls: { play, pause, seek, destroy } satisfies CanvasPlayerControls,
|
||||
preloadNext,
|
||||
}
|
||||
}
|
||||
|
||||
export default useCanvasPlayer
|
||||
Reference in New Issue
Block a user