add native frame capture utilities

This commit is contained in:
webadderall
2026-04-04 13:07:34 +11:00
parent 3109b18460
commit cc63fa1b24
2 changed files with 139 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
const RGBA_BYTES_PER_PIXEL = 4
let nativeFrameCaptureMode: 'video-frame-rgba' | 'canvas-readback' | null = null
let nativeFrameCaptureFallbackWarned = false
let fallbackCanvas:
| HTMLCanvasElement
| OffscreenCanvas
| null = null
let fallbackContext:
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null = null
function getRgbaLayout(width: number): PlaneLayout[] {
return [{ offset: 0, stride: width * RGBA_BYTES_PER_PIXEL }]
}
function getRgbaByteSize(width: number, height: number): number {
return width * height * RGBA_BYTES_PER_PIXEL
}
function getFallbackReadbackContext(
width: number,
height: number,
): CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D {
if (!fallbackCanvas || fallbackCanvas.width !== width || fallbackCanvas.height !== height) {
if (typeof OffscreenCanvas !== 'undefined') {
fallbackCanvas = new OffscreenCanvas(width, height)
fallbackContext = fallbackCanvas.getContext('2d', {
willReadFrequently: true,
})
} else if (typeof document !== 'undefined') {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
fallbackCanvas = canvas
fallbackContext = canvas.getContext('2d', {
willReadFrequently: true,
})
} else {
throw new Error('No canvas readback path is available for native export')
}
}
if (!fallbackContext) {
throw new Error('Failed to initialize native export frame readback context')
}
return fallbackContext
}
function captureCanvasFrameWithReadback(canvas: HTMLCanvasElement, targetWidth?: number, targetHeight?: number): Uint8Array {
const outWidth = targetWidth ?? canvas.width
const outHeight = targetHeight ?? canvas.height
const context = getFallbackReadbackContext(outWidth, outHeight)
context.clearRect(0, 0, outWidth, outHeight)
context.drawImage(canvas, 0, 0, outWidth, outHeight)
const imageData = context.getImageData(0, 0, outWidth, outHeight)
return new Uint8Array(imageData.data)
}
function flipRgbaRowsInPlace(buffer: Uint8Array, width: number, height: number): void {
const rowByteLength = width * RGBA_BYTES_PER_PIXEL
const scratchRow = new Uint8Array(rowByteLength)
const halfRows = Math.floor(height / 2)
for (let rowIndex = 0; rowIndex < halfRows; rowIndex += 1) {
const topOffset = rowIndex * rowByteLength
const bottomOffset = (height - rowIndex - 1) * rowByteLength
scratchRow.set(buffer.subarray(topOffset, topOffset + rowByteLength))
buffer.copyWithin(topOffset, bottomOffset, bottomOffset + rowByteLength)
buffer.set(scratchRow, bottomOffset)
}
}
export async function captureCanvasFrameForNativeExport(
canvas: HTMLCanvasElement,
timestamp: number,
flipVertical = false,
targetWidth?: number,
targetHeight?: number,
): Promise<Uint8Array> {
const outWidth = targetWidth ?? canvas.width
const outHeight = targetHeight ?? canvas.height
if (nativeFrameCaptureMode !== 'canvas-readback') {
const buffer = new Uint8Array(getRgbaByteSize(outWidth, outHeight))
const frame = new VideoFrame(canvas, { timestamp })
try {
await frame.copyTo(buffer, {
format: 'RGBA',
layout: getRgbaLayout(outWidth),
})
if (flipVertical) {
flipRgbaRowsInPlace(buffer, outWidth, outHeight)
}
nativeFrameCaptureMode = 'video-frame-rgba'
return buffer
} catch (error) {
nativeFrameCaptureMode = 'canvas-readback'
if (!nativeFrameCaptureFallbackWarned) {
nativeFrameCaptureFallbackWarned = true
console.warn(
'[native-export] VideoFrame RGBA copyTo failed, falling back to canvas readback',
error,
)
}
} finally {
frame.close()
}
}
const buffer = captureCanvasFrameWithReadback(canvas, outWidth, outHeight)
if (flipVertical) {
flipRgbaRowsInPlace(buffer, outWidth, outHeight)
}
return buffer
}
+19
View File
@@ -0,0 +1,19 @@
export interface ShadowLayerProfile {
offsetScale: number;
alphaScale: number;
blurScale: number;
}
export const VIDEO_SHADOW_LAYER_PROFILES: ReadonlyArray<ShadowLayerProfile> = Object.freeze([
{ offsetScale: 12, alphaScale: 0.7, blurScale: 48 },
{ offsetScale: 4, alphaScale: 0.5, blurScale: 16 },
{ offsetScale: 2, alphaScale: 0.3, blurScale: 8 },
]);
export const WEBCAM_SHADOW_LAYER_PROFILES: ReadonlyArray<ShadowLayerProfile> = Object.freeze([
{ offsetScale: 0.06, alphaScale: 1, blurScale: 0.22 },
]);
export function getShadowFilterPadding(blur: number, offsetY: number): number {
return Math.ceil(Math.max(0, blur * 2 + Math.abs(offsetY)));
}