diff --git a/src/lib/exporter/nativeFrameCapture.ts b/src/lib/exporter/nativeFrameCapture.ts new file mode 100644 index 00000000..2f26fc35 --- /dev/null +++ b/src/lib/exporter/nativeFrameCapture.ts @@ -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 { + 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 +} \ No newline at end of file diff --git a/src/lib/exporter/shadowProfile.ts b/src/lib/exporter/shadowProfile.ts new file mode 100644 index 00000000..f676311b --- /dev/null +++ b/src/lib/exporter/shadowProfile.ts @@ -0,0 +1,19 @@ +export interface ShadowLayerProfile { + offsetScale: number; + alphaScale: number; + blurScale: number; +} + +export const VIDEO_SHADOW_LAYER_PROFILES: ReadonlyArray = 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 = 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))); +} \ No newline at end of file