Canvas/Video: improve Blobs support

This commit is contained in:
Enrico Ros
2025-06-02 16:40:53 -07:00
parent 10a3669551
commit 803f6bbdea
3 changed files with 53 additions and 12 deletions
@@ -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]);
+28
View File
@@ -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');
+24 -11
View File
@@ -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<File> {
// 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}`;
}