UNPKG

@wordpress/upload-media

Version:
321 lines (286 loc) 8.64 kB
/** * Internal dependencies */ import { getFileBasename } from './utils'; import { parseHeic } from './heic-parser'; /** * Converts an image file to JPEG using the browser's native decoder and canvas. * * Tries three decoding strategies: * 1. createImageBitmap() + OffscreenCanvas (works in Safari, future Chrome). * 2. WebCodecs ImageDecoder API (uses platform codecs; may work in future * Chrome if HEIC is added to its image decoder pipeline). * 3. HEIC container parsing + WebCodecs VideoDecoder (Chrome 107+ on macOS). * Parses the HEIC/ISOBMFF container to extract the HEVC bitstream, then * decodes it using Chrome's platform HEVC video decoder (hardware- * accelerated via macOS VideoToolbox). * * This avoids shipping our own HEVC decoder, sidestepping patent/licensing concerns. * * @param file Source image file (e.g., HEIC/HEIF). * @param quality JPEG quality (0-1). Default 0.82. * @return JPEG File object. */ export async function canvasConvertToJpeg( file: File, quality = 0.82 ): Promise< File > { const baseName = getFileBasename( file.name ); // Strategy 1: createImageBitmap + OffscreenCanvas. try { const bitmap = await createImageBitmap( file ); try { const canvas = new OffscreenCanvas( bitmap.width, bitmap.height ); const ctx = canvas.getContext( '2d' ); if ( ! ctx ) { throw new Error( 'Could not get canvas 2d context' ); } ctx.drawImage( bitmap, 0, 0 ); const jpegBlob = await canvas.convertToBlob( { type: 'image/jpeg', quality, } ); return new File( [ jpegBlob ], `${ baseName }.jpg`, { type: 'image/jpeg', } ); } finally { bitmap.close(); } } catch { // createImageBitmap doesn't support HEIC in this browser. // Fall through to strategy 2. } // Strategy 2: WebCodecs ImageDecoder API. // Uses platform codecs (e.g., macOS HEIC support) that may not be // exposed through createImageBitmap or <img> elements. if ( typeof ImageDecoder !== 'undefined' ) { const supported = await ImageDecoder.isTypeSupported( file.type ); if ( supported ) { const decoder = new ImageDecoder( { type: file.type, data: file.stream(), } ); try { const { image: videoFrame } = await decoder.decode(); try { const canvas = new OffscreenCanvas( videoFrame.displayWidth, videoFrame.displayHeight ); const ctx = canvas.getContext( '2d' ); if ( ! ctx ) { throw new Error( 'Could not get canvas 2d context' ); } ctx.drawImage( videoFrame, 0, 0 ); const jpegBlob = await canvas.convertToBlob( { type: 'image/jpeg', quality, } ); return new File( [ jpegBlob ], `${ baseName }.jpg`, { type: 'image/jpeg', } ); } finally { videoFrame.close(); } } finally { decoder.close(); } } } // Strategy 3: HEIC container parsing + WebCodecs VideoDecoder. // Chrome 107+ on macOS supports HEVC *video* decoding via platform codecs // (macOS VideoToolbox), even though it doesn't support HEIC through image // APIs. A HEIC file is an ISOBMFF container with HEVC-encoded tiles — // we parse the container and decode each tile via VideoDecoder. if ( typeof VideoDecoder !== 'undefined' ) { try { const heicData = parseHeic( await file.arrayBuffer() ); const support = await VideoDecoder.isConfigSupported( { codec: heicData.codecString, } ); if ( support.supported ) { const canvas = new OffscreenCanvas( heicData.outputWidth, heicData.outputHeight ); const ctx = canvas.getContext( '2d' ); if ( ! ctx ) { throw new Error( 'Could not get canvas 2d context' ); } // Decode each tile and draw it at its grid position. for ( const tile of heicData.tiles ) { const frame = await decodeHevcFrame( heicData.codecString, heicData.description, heicData.tileWidth, heicData.tileHeight, tile.data ); try { ctx.drawImage( frame, tile.x, tile.y ); } finally { frame.close(); } } // Apply orientation: the native ISOBMFF `irot` transform when // present, otherwise the EXIF orientation tag (libheif applies // the former but ignores the latter for HEIF-family inputs). const outputCanvas = heicData.rotation !== 0 ? applyRotation( canvas, heicData.rotation ) : applyExifOrientation( canvas, heicData.exifOrientation ); const jpegBlob = await outputCanvas.convertToBlob( { type: 'image/jpeg', quality, } ); return new File( [ jpegBlob ], `${ baseName }.jpg`, { type: 'image/jpeg', } ); } } catch { // VideoDecoder HEVC not available or HEIC parsing failed. // Fall through to error. } } throw new Error( 'This browser cannot decode HEIC images. Please use Safari or convert to JPEG before uploading.' ); } /** * Apply ISOBMFF irot rotation to a canvas. * * Returns the original canvas if no rotation is needed, or a new * OffscreenCanvas with the rotation applied. * * @param source Source canvas with the decoded image. * @param rotation Rotation angle in degrees counter-clockwise (0, 90, 180, 270). * @return Canvas with rotation applied. */ function applyRotation( source: OffscreenCanvas, rotation: number ): OffscreenCanvas { if ( rotation === 0 ) { return source; } const swap = rotation === 90 || rotation === 270; const w = swap ? source.height : source.width; const h = swap ? source.width : source.height; const rotated = new OffscreenCanvas( w, h ); const ctx = rotated.getContext( '2d' ); if ( ! ctx ) { return source; } ctx.translate( w / 2, h / 2 ); // irot angle is CCW; canvas rotate() is CW, so negate. ctx.rotate( ( -rotation * Math.PI ) / 180 ); ctx.drawImage( source, -source.width / 2, -source.height / 2 ); return rotated; } /** * Apply an EXIF orientation (1-8) to a canvas. * * Returns the original canvas for orientation 1, or a new OffscreenCanvas with * the rotation/flip applied. Orientations 5-8 swap width and height. * * @param source Source canvas with the decoded image. * @param orientation EXIF orientation value (1-8). * @return Canvas with the orientation applied. */ function applyExifOrientation( source: OffscreenCanvas, orientation: number ): OffscreenCanvas { if ( orientation <= 1 || orientation > 8 ) { return source; } const { width: sw, height: sh } = source; const swap = orientation >= 5; const out = new OffscreenCanvas( swap ? sh : sw, swap ? sw : sh ); const ctx = out.getContext( '2d' ); if ( ! ctx ) { return source; } // Affine transforms map EXIF orientation to the upright image. The e/f // translation terms use the source width/height. See the EXIF spec tag // 0x0112 and the standard orientation matrix. switch ( orientation ) { case 2: // Flip horizontal. ctx.transform( -1, 0, 0, 1, sw, 0 ); break; case 3: // Rotate 180°. ctx.transform( -1, 0, 0, -1, sw, sh ); break; case 4: // Flip vertical. ctx.transform( 1, 0, 0, -1, 0, sh ); break; case 5: // Transpose. ctx.transform( 0, 1, 1, 0, 0, 0 ); break; case 6: // Rotate 90° CW. ctx.transform( 0, 1, -1, 0, sh, 0 ); break; case 7: // Transverse. ctx.transform( 0, -1, -1, 0, sh, sw ); break; case 8: // Rotate 90° CCW. ctx.transform( 0, -1, 1, 0, 0, sw ); break; } ctx.drawImage( source, 0, 0 ); return out; } /** * Decode a single HEVC key frame using the WebCodecs VideoDecoder API. * * @param codec HEVC codec string (e.g. 'hvc1.1.6.L93.B0'). * @param description HEVCDecoderConfigurationRecord bytes. * @param width Coded width of the frame. * @param height Coded height of the frame. * @param data Raw HEVC bitstream (IDR frame). * @return Decoded VideoFrame. Caller must call frame.close(). */ function decodeHevcFrame( codec: string, description: Uint8Array, width: number, height: number, data: Uint8Array ): Promise< VideoFrame > { return new Promise< VideoFrame >( ( resolve, reject ) => { const decoder = new VideoDecoder( { output: ( frame ) => { decoder.close(); resolve( frame ); }, error: ( e ) => { if ( decoder.state !== 'closed' ) { decoder.close(); } reject( e ); }, } ); decoder.configure( { codec, codedWidth: width, codedHeight: height, description, } ); decoder.decode( new EncodedVideoChunk( { type: 'key', timestamp: 0, data, } ) ); decoder.flush().catch( ( e ) => { if ( decoder.state !== 'closed' ) { decoder.close(); } reject( e ); } ); } ); }