From 803f6bbdea5ebc492d36a1e2c0db07e7b9dc6885 Mon Sep 17 00:00:00 2001 From: Enrico Ros Date: Mon, 2 Jun 2025 16:40:53 -0700 Subject: [PATCH] Canvas/Video: improve Blobs support --- .../composer/CameraCaptureModal.tsx | 2 +- src/common/util/canvasUtils.ts | 28 +++++++++++++++ src/common/util/videoUtils.ts | 35 +++++++++++++------ 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/apps/chat/components/composer/CameraCaptureModal.tsx b/src/apps/chat/components/composer/CameraCaptureModal.tsx index 05266470c..ca643d02f 100644 --- a/src/apps/chat/components/composer/CameraCaptureModal.tsx +++ b/src/apps/chat/components/composer/CameraCaptureModal.tsx @@ -127,7 +127,7 @@ export function CameraCaptureModal(props: { const handleVideoDownloadClicked = React.useCallback(async () => { if (!videoRef.current) return; - await downloadVideoFrame(videoRef.current, 'camera', 'image/jpeg', 0.98); + await downloadVideoFrame(videoRef.current, 'camera', 'image/jpeg', 0.98).catch(alert); }, [videoRef]); diff --git a/src/common/util/canvasUtils.ts b/src/common/util/canvasUtils.ts index 7f3258dde..4d0f10112 100644 --- a/src/common/util/canvasUtils.ts +++ b/src/common/util/canvasUtils.ts @@ -47,6 +47,34 @@ export async function asyncCanvasToBlob( return new Promise((resolve) => canvas.toBlob(resolve, imageFormat, imageQuality)); } +/** + * Creates a Blob object representing the image contained in the canvas, with format validation and fallback + * @param canvas The canvas element to convert + * @param requestedMimeType Desired MIME type - browsers are required to support image/png; many will support additional formats including image/jpeg and some may support image/webp. + * @param imageQuality Quality for lossy formats (0-1) (image/jpeg or image/webp) + * @param debugLabel Label for debugging + */ +export async function asyncCanvasToBlobWithValidation( + canvas: HTMLCanvasElement, + requestedMimeType: string, + imageQuality: undefined | number, + debugLabel?: string, +): Promise<{ blob: Blob; actualMimeType: string }> { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (!blob) + return reject(new Error(`Failed to convert canvas to blob with format '${requestedMimeType}'`)); + + // Warn if the actual MIME type differs from the requested one + if (debugLabel && blob.type !== requestedMimeType) + console.warn(`[DEV] ${debugLabel}: requested MIME type "${requestedMimeType}" was not used. Actual MIME type is "${blob.type}".`); + + resolve({ blob, actualMimeType: blob.type }); + }, requestedMimeType, imageQuality); + }); +} + + export function renderVideoFrameToNewCanvas(videoElement: HTMLVideoElement): HTMLCanvasElement { // paint the video on a canvas, to save it const canvas = document.createElement('canvas'); diff --git a/src/common/util/videoUtils.ts b/src/common/util/videoUtils.ts index cbd6d3d9c..18bcbcf23 100644 --- a/src/common/util/videoUtils.ts +++ b/src/common/util/videoUtils.ts @@ -5,7 +5,7 @@ * Also see imageUtils.ts for more image-related functions. */ -import { asyncCanvasToBlob, renderVideoFrameToNewCanvas } from './canvasUtils'; +import { asyncCanvasToBlobWithValidation, renderVideoFrameToNewCanvas } from './canvasUtils'; import { downloadBlob } from './downloadUtils'; import { prettyTimestampForFilenames } from './timeUtils'; @@ -20,10 +20,13 @@ type AllowedFormats = 'image/png' | 'image/jpeg'; export async function downloadVideoFrame(videoElement: HTMLVideoElement, prefixName: string, imageFormat: AllowedFormats, imageQuality?: number) { // Video -> Canvas -> Blob const renderedFrame: HTMLCanvasElement = renderVideoFrameToNewCanvas(videoElement); - const blob: Blob | null = await asyncCanvasToBlob(renderedFrame, imageFormat, imageQuality); - if (!blob) throw new Error('Failed to render video frame to Blob.'); - // Blob -> download - downloadBlob(blob, _videoPrettyFileName(prefixName, renderedFrame, imageFormat)); + try { + const { blob } = await asyncCanvasToBlobWithValidation(renderedFrame, imageFormat, imageQuality, 'downloadVideoFrame'); + // Blob -> download + downloadBlob(blob, _videoPrettyFileName(prefixName, renderedFrame, imageFormat)); + } catch (error) { + throw new Error(`Failed to download video frame: ${error instanceof Error ? error.message : String(error)}`); + } } /** @@ -32,14 +35,24 @@ export async function downloadVideoFrame(videoElement: HTMLVideoElement, prefixN export async function renderVideoFrameAsFile(videoElement: HTMLVideoElement, prefixName: string, imageFormat: AllowedFormats, imageQuality?: number): Promise { // Video -> Canvas -> Blob const renderedFrame: HTMLCanvasElement = renderVideoFrameToNewCanvas(videoElement); - const blob: Blob | null = await asyncCanvasToBlob(renderedFrame, imageFormat, imageQuality); - if (!blob) throw new Error('Failed to render video frame to Blob.'); - // Blob -> File - return new File([blob], _videoPrettyFileName(prefixName, renderedFrame, imageFormat), { type: blob.type }); + try { + const { blob, actualMimeType } = await asyncCanvasToBlobWithValidation(renderedFrame, imageFormat, imageQuality, 'renderVideoFrameAsFile'); + // Blob -> File + return new File([blob], _videoPrettyFileName(prefixName, renderedFrame, actualMimeType), { type: actualMimeType }); + } catch (error) { + throw new Error(`Failed to render video frame: ${error instanceof Error ? error.message : String(error)}`); + } } -function _videoPrettyFileName(prefixName: string, renderedFrame: HTMLCanvasElement, imageFormat: AllowedFormats): string { +function _videoPrettyFileName(prefixName: string, renderedFrame: HTMLCanvasElement, imageFormat: AllowedFormats | string /* allowing for the actual mime type to be different */): string { const prettyResolution = `${renderedFrame.width}x${renderedFrame.height}`; - return `${prefixName}_${prettyTimestampForFilenames()}_${prettyResolution}.${imageFormat === 'image/png' ? 'png' : 'jpg'}`; + const extensions: { [mime: string]: string } = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'image/gif': 'gif', + }; + const extension = extensions[imageFormat] || 'jpg'; // Fallback to jpg if format is not recognized + return `${prefixName}_${prettyTimestampForFilenames()}_${prettyResolution}.${extension}`; }