refactor: 默认使用原生video播放,彻底解决主线程解码卡顿
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 56s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m22s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m27s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m29s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m44s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 50s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 34s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m45s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 6m42s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m18s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m25s
AI Code Review / AI Code Review (pull_request) Successful in 8m47s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 53s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 58s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 11m3s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 11m20s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Unit Tests (push) Successful in 15m5s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 6m45s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 14m24s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Integration Tests (pull_request) Successful in 6m53s
CI/CD Pipeline / CI Gate (pull_request) Successful in 9s

FrontendPreviewPlayer.tsx:
- useWebCodecs 默认 false,走浏览器原生硬件解码(独立线程)
- 删除 Canvas ResizeObserver useEffect(默认路径不需要)
- video 元素只预加载当前+下一片段,其余 preload=none+不设src,减少并发连接
- 片段指示器统一显示片段序号,不再显示Canvas/降级提示
- isWebCodecsSupported 不再导入,清理 TS6133

useSegmentScheduler.ts:
- tick 进度更新 200ms 节流(5fps),避免 60fps setCurrentTime 触发整树 re-render
- 重播/seek 时重置节流 ref
- switchToSegment 兜底:若 video 无 src 则动态设置并 load(),兼容 seek 到未预加载片段

useCanvasPlayer.ts 不删,保留作为兜底
This commit is contained in:
xiaoxia
2026-08-22 01:53:43 +08:00
parent 9c02e69ede
commit b8a349d5e0
2 changed files with 51 additions and 59 deletions
@@ -17,7 +17,7 @@ import {
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"
import { useCanvasPlayer } from "../hooks/useCanvasPlayer"
interface FrontendPreviewPlayerProps {
assets: AssetItem[]
@@ -82,7 +82,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
titleSettings,
}) => {
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
const useWebCodecs = isWebCodecsSupported()
// 默认走原生 video 播放(浏览器硬件解码,独立线程,不阻塞 UI)
// WebCodecs 仅在明确需要时启用(保留代码作为兜底)
const useWebCodecs = false
// ── 两条路径共用同一个 canvas ref(fallback 路径不使用) ──
const canvasRef = useRef<HTMLCanvasElement>(null)
@@ -265,32 +267,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
// ── Canvas ResizeObserver ──
// ── Canvas 容器 ref(保留声明,WebCodecs 兜底路径仍引用) ──
const canvasContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!effectiveUseWebCodecs || !canPlay) return
const container = canvasContainerRef.current
const canvas = canvasRef.current
if (!container || !canvas) return
// 立即设置一次 canvas 像素分辨率,避免默认 300×150 导致首帧变形
const initRect = container.getBoundingClientRect()
if (initRect.width > 0 && initRect.height > 0) {
const dpr = window.devicePixelRatio || 1
canvas.width = initRect.width * dpr
canvas.height = initRect.height * dpr
}
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()
}, [effectiveUseWebCodecs, canPlay])
// ── 未就绪 ──
if (!ready || !assets.length) {
@@ -385,31 +363,35 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
</div>
)}
{/* ── Video 渲染层(fallback 路径,或 WebCodecs 解码失败时自动切换 ── */}
{/* ── Video 渲染层(默认路径,浏览器原生硬件解码 ── */}
{!effectiveUseWebCodecs &&
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
/>
))}
segments.map((seg, i) => {
// 只预加载当前片段和下一个片段,其余延迟加载
const shouldPreload = i <= videoCurrentSegIdx + 1
return (
<video
key={seg.assetId}
muted
ref={(el) => {
videoRefs.current[i] = el
}}
preload={shouldPreload ? "auto" : "none"}
src={shouldPreload ? seg.videoUrl : undefined}
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 && (
@@ -453,11 +435,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
zIndex: 10,
}}
>
{effectiveUseWebCodecs
? "Canvas"
: forceVideoFallback
? "Canvas 解码失败,已切换原生播放"
: `片段 ${videoCurrentSegIdx + 1}/${segments.length}`}
{`片段 ${videoCurrentSegIdx + 1}/${segments.length}`}
</div>
{/* 控制条 */}
@@ -97,6 +97,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
const [isEnded, setIsEnded] = useState(false)
const rafRef = useRef<number>(0)
const isSeekingRef = useRef(false)
const lastTimeUpdateRef = useRef(0)
// 计算时间线
const timelineStarts = useMemo(() => buildTimeline(segments), [segments])
@@ -133,6 +134,12 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
const seg = segments[index]
const localTime = seekToLocalTime ?? seg.startTime
// 确保 video 有 src(预加载策略下可能还未设置)
if (!video.src && seg.videoUrl) {
video.src = seg.videoUrl
video.load()
}
// 设置播放位置
video.currentTime = localTime
@@ -167,7 +174,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
[segments, currentSegmentIndex],
)
/** 播放循环 — 检测片段边界并切换 */
/** 播放循环 — 检测片段边界并切换(进度更新 200ms 节流,避免 60fps re-render */
const tick = useCallback(() => {
const video = videoRefs.current[currentSegmentIndex]
if (!video || isSeekingRef.current) {
@@ -185,7 +192,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
const timeToEnd = seg.endTime - video.currentTime
if (timeToEnd <= 3 && nextVideo && nextVideo.readyState < 3) {
const nextSeg = segments[nextIndex]
// 只在还没 seek 过时设置(避免反复 seek)
if (Math.abs(nextVideo.currentTime - nextSeg.startTime) > 1) {
nextVideo.currentTime = nextSeg.startTime
}
@@ -226,6 +232,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
})
const accumulatedTime =
(timelineStarts[currentSegmentIndex] || 0) + (seg.endTime - seg.startTime)
lastTimeUpdateRef.current = 0
setCurrentTime(accumulatedTime)
} else {
setIsPlaying(false)
@@ -236,7 +243,12 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
} else {
const globalTime =
(timelineStarts[currentSegmentIndex] || 0) + (video.currentTime - seg.startTime)
setCurrentTime(Math.max(0, Math.min(globalTime, totalDuration)))
// 节流:200ms 更新一次进度状态(5fps),避免 60fps re-render 拖慢页面
const now = performance.now()
if (now - lastTimeUpdateRef.current >= 200) {
lastTimeUpdateRef.current = now
setCurrentTime(Math.max(0, Math.min(globalTime, totalDuration)))
}
}
rafRef.current = requestAnimationFrame(tick)
@@ -285,6 +297,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
if (isEnded) {
// 播放结束后再次播放,从头开始
setIsEnded(false)
lastTimeUpdateRef.current = 0
switchToSegment(0, segments[0]?.startTime).then(() => {
const video = videoRefs.current[0]
if (video) {
@@ -317,6 +330,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
if (video) video.currentTime = localTime
}
lastTimeUpdateRef.current = 0
setCurrentTime(clampedTime)
setIsEnded(false)