perf: Web端视频流帧率和画面稳定性优化

- fetch+dataURI替代atob逐字节Base64解码,避免主线程阻塞
- 预解码Blob缓存,渲染循环直接使用createImageBitmap
- canvas尺寸锁定策略增强:取历史最大帧尺寸,防止小帧导致缩小闪烁
- 新帧比锁定尺寸大时动态更新锁定尺寸(适配设备旋转)
This commit is contained in:
wdvipa
2026-02-15 20:51:01 +08:00
parent 03e4cb8dab
commit ce2551cf70

View File

@@ -38,10 +38,13 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
const isRenderingRef = useRef(false) const isRenderingRef = useRef(false)
const imageSizeRef = useRef<{ width: number, height: number } | null>(null) const imageSizeRef = useRef<{ width: number, height: number } | null>(null)
// canvas锁定尺寸首次有效帧确定canvas尺寸后不再变化 // canvas锁定尺寸取历史最大帧尺寸锁定
// 避免不同采集模式(MediaProjection/无障碍截图)帧尺寸不一致导致闪烁 // 避免不同采集模式(MediaProjection/无障碍截图)帧尺寸不一致导致闪烁
const lockedCanvasSizeRef = useRef<{ width: number, height: number } | null>(null) const lockedCanvasSizeRef = useRef<{ width: number, height: number } | null>(null)
// Base64预解码缓存将base64字符串提前转为Blob避免渲染时阻塞主线程
const pendingBlobRef = useRef<Blob | null>(null)
// 添加控制权状态跟踪,避免重复申请 // 添加控制权状态跟踪,避免重复申请
const [isControlRequested, setIsControlRequested] = useState(false) const [isControlRequested, setIsControlRequested] = useState(false)
const [currentWebSocket, setCurrentWebSocket] = useState<any>(null) const [currentWebSocket, setCurrentWebSocket] = useState<any>(null)
@@ -147,17 +150,43 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
console.log(`[屏幕数据] 已收到 ${frameCountRef.current} 帧, 数据大小: ${dataLen}, 渲染中: ${isRenderingRef.current}, 解码中: ${decodingRef.current}`) console.log(`[屏幕数据] 已收到 ${frameCountRef.current} 帧, 数据大小: ${dataLen}, 渲染中: ${isRenderingRef.current}, 解码中: ${decodingRef.current}`)
} }
// 只保存最新帧引用,不立即解码 // 提前将Base64转为Blob避免渲染循环中阻塞主线程
latestFrameRef.current = data // fetch+data URI方式比atob+逐字节复制快得多,且不阻塞主线程
const format = data?.format ?? 'JPEG'
const mimeType = `image/${format.toLowerCase()}`
if (typeof data.data === 'string') {
try {
const dataUri = `data:${mimeType};base64,${data.data}`
fetch(dataUri)
.then(res => res.blob())
.then(blob => {
pendingBlobRef.current = blob
latestFrameRef.current = data
if (!isRenderingRef.current) {
isRenderingRef.current = true
renderLatestFrame()
}
})
.catch(err => {
console.error('Base64 pre-decode failed:', err)
})
} catch (err) {
console.error('Frame pre-decode error:', err)
}
} else {
pendingBlobRef.current = new Blob([data.data], { type: mimeType })
latestFrameRef.current = data
if (!isRenderingRef.current) {
isRenderingRef.current = true
renderLatestFrame()
}
}
// 只在首帧时更新loading状态避免每帧触发重渲染 // 只在首帧时更新loading状态避免每帧触发重渲染
if (isLoading) setIsLoading(false) if (isLoading) setIsLoading(false)
// 如果没有正在进行的渲染循环,启动一个
if (!isRenderingRef.current) {
isRenderingRef.current = true
renderLatestFrame()
}
} }
} }
@@ -293,14 +322,16 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
const renderLatestFrame = useCallback(() => { const renderLatestFrame = useCallback(() => {
const doRender = () => { const doRender = () => {
const frameData = latestFrameRef.current const frameData = latestFrameRef.current
if (!frameData) { const preDecodedBlob = pendingBlobRef.current
if (!frameData || !preDecodedBlob) {
// 没有新帧,停止循环,等 handleScreenData 重新启动 // 没有新帧,停止循环,等 handleScreenData 重新启动
isRenderingRef.current = false isRenderingRef.current = false
return return
} }
// 取走帧数据 // 取走帧数据和预解码Blob
latestFrameRef.current = null latestFrameRef.current = null
pendingBlobRef.current = null
const canvas = canvasRef.current const canvas = canvasRef.current
if (!canvas) { if (!canvas) {
@@ -316,27 +347,15 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
// 上一帧还在解码,把数据放回去,下个 rAF 再试 // 上一帧还在解码,把数据放回去,下个 rAF 再试
if (decodingRef.current) { if (decodingRef.current) {
latestFrameRef.current = frameData latestFrameRef.current = frameData
pendingBlobRef.current = preDecodedBlob
rafIdRef.current = requestAnimationFrame(doRender) rafIdRef.current = requestAnimationFrame(doRender)
return return
} }
decodingRef.current = true decodingRef.current = true
let blobPromise: Promise<Blob> // 直接使用预解码的Blob跳过Base64解码步骤
if (typeof frameData.data === 'string') { createImageBitmap(preDecodedBlob)
const binaryStr = atob(frameData.data)
const len = binaryStr.length
const bytes = new Uint8Array(len)
for (let i = 0; i < len; i++) {
bytes[i] = binaryStr.charCodeAt(i)
}
blobPromise = Promise.resolve(new Blob([bytes], { type: `image/${frameData.format.toLowerCase()}` }))
} else {
blobPromise = Promise.resolve(new Blob([frameData.data], { type: `image/${frameData.format.toLowerCase()}` }))
}
blobPromise
.then(blob => createImageBitmap(blob))
.then(bitmap => { .then(bitmap => {
decodingRef.current = false decodingRef.current = false
@@ -347,23 +366,32 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
return return
} }
// canvas尺寸锁定策略 // canvas尺寸锁定策略(增强版)
// 首次有效帧确定canvas尺寸锁定后续帧直接绘制到固定尺寸canvas上 // 取历史最大帧尺寸锁定canvas避免小帧导致画面缩小闪烁
// 避免不同采集模式帧尺寸不一致导致canvas反复resize闪烁 // 当新帧比锁定尺寸大时,更新锁定尺寸(设备旋转等场景)
const locked = lockedCanvasSizeRef.current const locked = lockedCanvasSizeRef.current
if (!locked) { if (!locked) {
// 首次帧锁定canvas尺寸 // 首次帧锁定canvas尺寸
lockedCanvasSizeRef.current = { width: bitmap.width, height: bitmap.height } lockedCanvasSizeRef.current = { width: bitmap.width, height: bitmap.height }
canvas.width = bitmap.width canvas.width = bitmap.width
canvas.height = bitmap.height canvas.height = bitmap.height
} else if (canvas.width !== locked.width || canvas.height !== locked.height) { } else {
// canvas被外部重置了恢复锁定尺寸 // 后续帧:只在新帧更大时更新锁定尺寸,防止缩小闪烁
canvas.width = locked.width const needUpdate = bitmap.width > locked.width || bitmap.height > locked.height
canvas.height = locked.height if (needUpdate) {
const newW = Math.max(locked.width, bitmap.width)
const newH = Math.max(locked.height, bitmap.height)
lockedCanvasSizeRef.current = { width: newW, height: newH }
canvas.width = newW
canvas.height = newH
} else if (canvas.width !== locked.width || canvas.height !== locked.height) {
// canvas被外部重置了恢复锁定尺寸
canvas.width = locked.width
canvas.height = locked.height
}
} }
// 始终将bitmap绘制到整个canvas区域浏览器自动缩放 // 始终将bitmap绘制到整个canvas区域浏览器自动缩放
// 这样不同尺寸的帧都能正确显示,不会闪烁
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height) ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
// 使用锁定的canvas尺寸上报保持稳定 // 使用锁定的canvas尺寸上报保持稳定
@@ -392,7 +420,7 @@ const DeviceScreen: React.FC<DeviceScreenProps> = ({ deviceId, onScreenSizeChang
}) })
.catch(err => { .catch(err => {
decodingRef.current = false decodingRef.current = false
console.error('图像解码失败:', err) console.error('Image decode failed:', err)
rafIdRef.current = requestAnimationFrame(doRender) rafIdRef.current = requestAnimationFrame(doRender)
}) })
} }