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
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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user