| @@ -107,8 +107,9 @@ | ||
| 107 | 107 | getHeicConversionAdvice: () => getHeicConversionAdvice, |
| 108 | 108 | getHeicUnsupportedMessage: () => getHeicUnsupportedMessage, |
| 109 | 109 | isClientSideMediaSupported: () => isClientSideMediaSupported, |
| 110 | 110 | isHeicCanvasSupported: () => isHeicCanvasSupported, |
| 111 | + isHeicFile: () => isHeicFile, | |
| 111 | 112 | store: () => store |
| 112 | 113 | }); |
| 113 | 114 | |
| 114 | 115 | // packages/upload-media/build-module/store/index.mjs |
| @@ -193,9 +194,10 @@ | ||
| 193 | 194 | mediaUpload: noop, |
| 194 | 195 | maxConcurrentUploads: DEFAULT_MAX_CONCURRENT_UPLOADS, |
| 195 | 196 | maxConcurrentImageProcessing: DEFAULT_MAX_CONCURRENT_IMAGE_PROCESSING, |
| 196 | 197 | retry: { ...DEFAULT_RETRY_SETTINGS } |
| 197 | - } | |
| 198 | + }, | |
| 199 | + failureCount: 0 | |
| 198 | 200 | }; |
| 199 | 201 | function reducer(state = DEFAULT_STATE, action = { type: Type.Unknown }) { |
| 200 | 202 | switch (action.type) { |
| 201 | 203 | case Type.PauseQueue: { |
| @@ -234,11 +236,15 @@ | ||
| 234 | 236 | return { |
| 235 | 237 | ...state, |
| 236 | 238 | queue: [...state.queue, action.item] |
| 237 | 239 | }; |
| 238 | - case Type.Cancel: | |
| 240 | + case Type.Cancel: { | |
| 241 | + const cancelled = state.queue.find( | |
| 242 | + (item) => item.id === action.id | |
| 243 | + ); | |
| 239 | 244 | return { |
| 240 | 245 | ...state, |
| 246 | + failureCount: cancelled && !cancelled.parentId ? state.failureCount + 1 : state.failureCount, | |
| 241 | 247 | queue: state.queue.map( |
| 242 | 248 | (item) => item.id === action.id ? { |
| 243 | 249 | ...item, |
| 244 | 250 | error: action.error |
| @@ -244,8 +250,9 @@ | ||
| 244 | 250 | error: action.error |
| 245 | 251 | } : item |
| 246 | 252 | ) |
| 247 | 253 | }; |
| 254 | + } | |
| 248 | 255 | case Type.RetryItem: |
| 249 | 256 | return { |
| 250 | 257 | ...state, |
| 251 | 258 | queue: state.queue.map( |
| @@ -253,9 +260,20 @@ | ||
| 253 | 260 | ...item, |
| 254 | 261 | status: ItemStatus.Processing, |
| 255 | 262 | error: void 0, |
| 256 | 263 | retryCount: (item.retryCount ?? 0) + 1, |
| 257 | - abortController: new AbortController() | |
| 264 | + abortController: new AbortController(), | |
| 265 | + /* | |
| 266 | + * The failed operation is still recorded on | |
| 267 | + * the item: nothing finishes it when it | |
| 268 | + * fails, and while the item waits out the | |
| 269 | + * backoff that is what keeps it out of the | |
| 270 | + * concurrency pools. Clear it now that the | |
| 271 | + * item is about to run again, so processItem | |
| 272 | + * does not mistake it for an operation still | |
| 273 | + * in flight and skip the retry. | |
| 274 | + */ | |
| 275 | + currentOperation: void 0 | |
| 258 | 276 | } : item |
| 259 | 277 | ) |
| 260 | 278 | }; |
| 261 | 279 | case Type.ScheduleRetry: |
| @@ -420,8 +438,9 @@ | ||
| 420 | 438 | getActiveVideoProcessingCount: () => getActiveVideoProcessingCount, |
| 421 | 439 | getAllItems: () => getAllItems, |
| 422 | 440 | getBlobUrls: () => getBlobUrls, |
| 423 | 441 | getFailedItems: () => getFailedItems, |
| 442 | + getFailureCount: () => getFailureCount, | |
| 424 | 443 | getItem: () => getItem, |
| 425 | 444 | getItemProgress: () => getItemProgress, |
| 426 | 445 | getPendingImageProcessing: () => getPendingImageProcessing, |
| 427 | 446 | getPendingUploads: () => getPendingUploads, |
| @@ -432,8 +451,11 @@ | ||
| 432 | 451 | }); |
| 433 | 452 | function getAllItems(state) { |
| 434 | 453 | return state.queue; |
| 435 | 454 | } |
| 455 | + function getFailureCount(state) { | |
| 456 | + return state.failureCount; | |
| 457 | + } | |
| 436 | 458 | function getItem(state, id) { |
| 437 | 459 | return state.queue.find((item) => item.id === id); |
| 438 | 460 | } |
| 439 | 461 | function isBatchUploaded(state, batchId) { |
| @@ -655,8 +677,56 @@ | ||
| 655 | 677 | } |
| 656 | 678 | } |
| 657 | 679 | return false; |
| 658 | 680 | } |
| 681 | + var FILE_TYPE_BOX_BYTES = 64; | |
| 682 | + var HEIC_BRANDS = [ | |
| 683 | + "heic", | |
| 684 | + "heix", | |
| 685 | + "heim", | |
| 686 | + "heis", | |
| 687 | + "hevc", | |
| 688 | + "hevx", | |
| 689 | + "hevm", | |
| 690 | + "hevs", | |
| 691 | + "mif1", | |
| 692 | + "msf1" | |
| 693 | + ]; | |
| 694 | + var AVIF_BRANDS = ["avif", "avis"]; | |
| 695 | + function isHeicBuffer(buffer) { | |
| 696 | + const view = new Uint8Array(buffer); | |
| 697 | + const readBrand = (offset) => String.fromCharCode(...view.subarray(offset, offset + 4)); | |
| 698 | + if (view.length < 12 || readBrand(4) !== "ftyp") { | |
| 699 | + return false; | |
| 700 | + } | |
| 701 | + const boxEnd = Math.min( | |
| 702 | + view.length, | |
| 703 | + new DataView(buffer).getUint32(0) | |
| 704 | + ); | |
| 705 | + const brands = [readBrand(8)]; | |
| 706 | + for (let offset = 16; offset + 4 <= boxEnd; offset += 4) { | |
| 707 | + brands.push(readBrand(offset)); | |
| 708 | + } | |
| 709 | + if (brands.some((brand) => AVIF_BRANDS.includes(brand))) { | |
| 710 | + return false; | |
| 711 | + } | |
| 712 | + return brands.some((brand) => HEIC_BRANDS.includes(brand)); | |
| 713 | + } | |
| 714 | + async function isHeicFile(file) { | |
| 715 | + if (HEIC_MIME_TYPES.includes(file.type)) { | |
| 716 | + return true; | |
| 717 | + } | |
| 718 | + if (file.type && !file.type.startsWith("image/")) { | |
| 719 | + return false; | |
| 720 | + } | |
| 721 | + try { | |
| 722 | + return isHeicBuffer( | |
| 723 | + await file.slice(0, FILE_TYPE_BOX_BYTES).arrayBuffer() | |
| 724 | + ); | |
| 725 | + } catch { | |
| 726 | + return false; | |
| 727 | + } | |
| 728 | + } | |
| 659 | 729 | |
| 660 | 730 | // packages/upload-media/build-module/store/utils/index.mjs |
| 661 | 731 | var vipsModulePromise; |
| 662 | 732 | var vipsModule; |
| @@ -1568,8 +1638,15 @@ | ||
| 1568 | 1638 | return `hvc1.${spacePrefix}${profileIdc}.${compatHex}.${tierChar}${levelIdc}${constraintStr}`; |
| 1569 | 1639 | } |
| 1570 | 1640 | function readItemData(buffer, loc, idatOffset) { |
| 1571 | 1641 | const baseOffset = loc.constructionMethod === 1 ? idatOffset : 0; |
| 1642 | + for (const ext of loc.extents) { | |
| 1643 | + if (baseOffset + ext.offset + ext.length > buffer.byteLength) { | |
| 1644 | + throw new Error( | |
| 1645 | + "HEIC item data extends past the end of the file" | |
| 1646 | + ); | |
| 1647 | + } | |
| 1648 | + } | |
| 1572 | 1649 | if (loc.extents.length === 1) { |
| 1573 | 1650 | const ext = loc.extents[0]; |
| 1574 | 1651 | const start = baseOffset + ext.offset; |
| 1575 | 1652 | return new Uint8Array(buffer.slice(start, start + ext.length)); |
| @@ -2056,8 +2133,11 @@ | ||
| 2056 | 2133 | return `${cause} ${getHeicConversionAdvice()}`; |
| 2057 | 2134 | } |
| 2058 | 2135 | |
| 2059 | 2136 | // packages/upload-media/build-module/canvas-utils.mjs |
| 2137 | + var IMAGE_DECODER_TIMEOUT = 15e3; | |
| 2138 | + var HeicUnsupportedError = class extends Error { | |
| 2139 | + }; | |
| 2060 | 2140 | async function canvasConvertToJpeg(file, quality = 0.82) { |
| 2061 | 2141 | const baseName = getFileBasename(file.name); |
| 2062 | 2142 | try { |
| 2063 | 2143 | const bitmap = await createImageBitmap(file); |
| @@ -2086,10 +2166,36 @@ | ||
| 2086 | 2166 | const decoder = new ImageDecoder({ |
| 2087 | 2167 | type: file.type, |
| 2088 | 2168 | data: file.stream() |
| 2089 | 2169 | }); |
| 2170 | + let videoFrame; | |
| 2171 | + let timeoutId; | |
| 2172 | + let timedOut = false; | |
| 2090 | 2173 | try { |
| 2091 | - const { image: videoFrame } = await decoder.decode(); | |
| 2174 | + const decoded = await Promise.race([ | |
| 2175 | + decoder.decode(), | |
| 2176 | + new Promise((_resolve, reject) => { | |
| 2177 | + timeoutId = setTimeout(() => { | |
| 2178 | + timedOut = true; | |
| 2179 | + decoder.close(); | |
| 2180 | + reject( | |
| 2181 | + new Error("ImageDecoder decode timed out") | |
| 2182 | + ); | |
| 2183 | + }, IMAGE_DECODER_TIMEOUT); | |
| 2184 | + }) | |
| 2185 | + ]); | |
| 2186 | + videoFrame = decoded.image; | |
| 2187 | + } catch (error) { | |
| 2188 | + if (!timedOut) { | |
| 2189 | + throw error; | |
| 2190 | + } | |
| 2191 | + } finally { | |
| 2192 | + clearTimeout(timeoutId); | |
| 2193 | + if (!timedOut) { | |
| 2194 | + decoder.close(); | |
| 2195 | + } | |
| 2196 | + } | |
| 2197 | + if (videoFrame) { | |
| 2092 | 2198 | try { |
| 2093 | 2199 | const canvas = new OffscreenCanvas( |
| 2094 | 2200 | videoFrame.displayWidth, |
| 2095 | 2201 | videoFrame.displayHeight |
| @@ -2108,58 +2214,62 @@ | ||
| 2108 | 2214 | }); |
| 2109 | 2215 | } finally { |
| 2110 | 2216 | videoFrame.close(); |
| 2111 | 2217 | } |
| 2112 | - } finally { | |
| 2113 | - decoder.close(); | |
| 2114 | 2218 | } |
| 2115 | 2219 | } |
| 2116 | 2220 | } |
| 2221 | + let heicData; | |
| 2222 | + try { | |
| 2223 | + heicData = parseHeic(await file.arrayBuffer()); | |
| 2224 | + } catch (error) { | |
| 2225 | + throw new Error("The HEIC file could not be parsed", { | |
| 2226 | + cause: error | |
| 2227 | + }); | |
| 2228 | + } | |
| 2117 | 2229 | if (typeof VideoDecoder !== "undefined") { |
| 2230 | + let supported = false; | |
| 2118 | 2231 | try { |
| 2119 | - const heicData = parseHeic(await file.arrayBuffer()); | |
| 2120 | 2232 | const support = await VideoDecoder.isConfigSupported({ |
| 2121 | 2233 | codec: heicData.codecString |
| 2122 | 2234 | }); |
| 2123 | - if (support.supported) { | |
| 2124 | - const canvas = new OffscreenCanvas( | |
| 2125 | - heicData.outputWidth, | |
| 2126 | - heicData.outputHeight | |
| 2235 | + supported = support.supported === true; | |
| 2236 | + } catch { | |
| 2237 | + } | |
| 2238 | + if (supported) { | |
| 2239 | + const canvas = new OffscreenCanvas( | |
| 2240 | + heicData.outputWidth, | |
| 2241 | + heicData.outputHeight | |
| 2242 | + ); | |
| 2243 | + const ctx = canvas.getContext("2d"); | |
| 2244 | + if (!ctx) { | |
| 2245 | + throw new Error("Could not get canvas 2d context"); | |
| 2246 | + } | |
| 2247 | + for (const tile of heicData.tiles) { | |
| 2248 | + const frame = await decodeHevcFrame( | |
| 2249 | + heicData.codecString, | |
| 2250 | + heicData.description, | |
| 2251 | + heicData.tileWidth, | |
| 2252 | + heicData.tileHeight, | |
| 2253 | + tile.data | |
| 2127 | 2254 | ); |
| 2128 | - const ctx = canvas.getContext("2d"); | |
| 2129 | - if (!ctx) { | |
| 2130 | - throw new Error("Could not get canvas 2d context"); | |
| 2255 | + try { | |
| 2256 | + ctx.drawImage(frame, tile.x, tile.y); | |
| 2257 | + } finally { | |
| 2258 | + frame.close(); | |
| 2131 | 2259 | } |
| 2132 | - for (const tile of heicData.tiles) { | |
| 2133 | - const frame = await decodeHevcFrame( | |
| 2134 | - heicData.codecString, | |
| 2135 | - heicData.description, | |
| 2136 | - heicData.tileWidth, | |
| 2137 | - heicData.tileHeight, | |
| 2138 | - tile.data | |
| 2139 | - ); | |
| 2140 | - try { | |
| 2141 | - ctx.drawImage(frame, tile.x, tile.y); | |
| 2142 | - } finally { | |
| 2143 | - frame.close(); | |
| 2144 | - } | |
| 2145 | - } | |
| 2146 | - const outputCanvas = heicData.rotation !== 0 ? applyRotation(canvas, heicData.rotation) : applyExifOrientation( | |
| 2147 | - canvas, | |
| 2148 | - heicData.exifOrientation | |
| 2149 | - ); | |
| 2150 | - const jpegBlob = await outputCanvas.convertToBlob({ | |
| 2151 | - type: "image/jpeg", | |
| 2152 | - quality | |
| 2153 | - }); | |
| 2154 | - return new File([jpegBlob], `${baseName}.jpg`, { | |
| 2155 | - type: "image/jpeg" | |
| 2156 | - }); | |
| 2157 | 2260 | } |
| 2158 | - } catch { | |
| 2261 | + const outputCanvas = heicData.rotation !== 0 ? applyRotation(canvas, heicData.rotation) : applyExifOrientation(canvas, heicData.exifOrientation); | |
| 2262 | + const jpegBlob = await outputCanvas.convertToBlob({ | |
| 2263 | + type: "image/jpeg", | |
| 2264 | + quality | |
| 2265 | + }); | |
| 2266 | + return new File([jpegBlob], `${baseName}.jpg`, { | |
| 2267 | + type: "image/jpeg" | |
| 2268 | + }); | |
| 2159 | 2269 | } |
| 2160 | 2270 | } |
| 2161 | - throw new Error(getHeicUnsupportedMessage()); | |
| 2271 | + throw new HeicUnsupportedError(getHeicUnsupportedMessage()); | |
| 2162 | 2272 | } |
| 2163 | 2273 | function applyRotation(source, rotation) { |
| 2164 | 2274 | if (rotation === 0) { |
| 2165 | 2275 | return source; |
| @@ -2529,8 +2639,11 @@ | ||
| 2529 | 2639 | const item = select2.getItem(id); |
| 2530 | 2640 | if (!item) { |
| 2531 | 2641 | return; |
| 2532 | 2642 | } |
| 2643 | + if (item.currentOperation) { | |
| 2644 | + return; | |
| 2645 | + } | |
| 2533 | 2646 | const { |
| 2534 | 2647 | attachment, |
| 2535 | 2648 | onChange, |
| 2536 | 2649 | onSuccess, |
| @@ -2808,13 +2921,16 @@ | ||
| 2808 | 2921 | } |
| 2809 | 2922 | } |
| 2810 | 2923 | } |
| 2811 | 2924 | let heicJpeg = null; |
| 2812 | - const isImage = file.type.startsWith("image/"); | |
| 2813 | - const isVipsSupported = CLIENT_SIDE_SUPPORTED_MIME_TYPES.includes( | |
| 2814 | - file.type | |
| 2815 | - ); | |
| 2816 | - const isHeic = HEIC_MIME_TYPES.includes(file.type); | |
| 2925 | + const isHeic = await isHeicFile(file); | |
| 2926 | + const isMisnamedHeic = isHeic && !HEIC_MIME_TYPES.includes(file.type); | |
| 2927 | + const isImage = file.type.startsWith("image/") || isHeic; | |
| 2928 | + const isVipsSupported = !isHeic && CLIENT_SIDE_SUPPORTED_MIME_TYPES.includes(file.type); | |
| 2929 | + const heicFile = isMisnamedHeic ? new File([file], `${getFileBasename(file.name)}.heic`, { | |
| 2930 | + type: HEIC_MIME_TYPES[0], | |
| 2931 | + lastModified: file.lastModified | |
| 2932 | + }) : file; | |
| 2817 | 2933 | let tooLargeForClient = false; |
| 2818 | 2934 | if (isImage && isVipsSupported) { |
| 2819 | 2935 | const dimensions = await getImageDimensions(file); |
| 2820 | 2936 | if (dimensions && exceedsClientProcessingMemory(dimensions)) { |
| @@ -2820,9 +2936,9 @@ | ||
| 2820 | 2936 | if (dimensions && exceedsClientProcessingMemory(dimensions)) { |
| 2821 | 2937 | tooLargeForClient = true; |
| 2822 | 2938 | } |
| 2823 | 2939 | } |
| 2824 | - if (file.type === "image/jpeg" && !tooLargeForClient) { | |
| 2940 | + if (file.type === "image/jpeg" && !isHeic && !tooLargeForClient) { | |
| 2825 | 2941 | operations.push(OperationType.DetectUltraHdr); |
| 2826 | 2942 | } |
| 2827 | 2943 | if (isImage && isVipsSupported && !tooLargeForClient) { |
| 2828 | 2944 | operations.push( |
| @@ -2832,18 +2948,22 @@ | ||
| 2832 | 2948 | ); |
| 2833 | 2949 | } else if (isImage && isHeic) { |
| 2834 | 2950 | try { |
| 2835 | 2951 | heicJpeg = await canvasConvertToJpeg( |
| 2836 | - file, | |
| 2952 | + heicFile, | |
| 2837 | 2953 | settings.imageQuality ?? DEFAULT_OUTPUT_QUALITY |
| 2838 | 2954 | ); |
| 2839 | - } catch { | |
| 2955 | + } catch (error) { | |
| 2956 | + const unsupported = error instanceof HeicUnsupportedError; | |
| 2840 | 2957 | dispatch.cancelItem( |
| 2841 | 2958 | id, |
| 2842 | 2959 | new UploadError({ |
| 2843 | - code: ErrorCode.HEIC_DECODE_ERROR, | |
| 2844 | - message: getHeicUnsupportedMessage(), | |
| 2845 | - file | |
| 2960 | + code: unsupported ? ErrorCode.HEIC_DECODE_ERROR : ErrorCode.IMAGE_TRANSCODING_ERROR, | |
| 2961 | + message: unsupported ? getHeicUnsupportedMessage() : (0, import_i18n7.__)( | |
| 2962 | + "This HEIC image could not be converted. Try converting it to JPEG before uploading." | |
| 2963 | + ), | |
| 2964 | + file, | |
| 2965 | + cause: error instanceof Error ? error : void 0 | |
| 2846 | 2966 | }) |
| 2847 | 2967 | ); |
| 2848 | 2968 | return; |
| 2849 | 2969 | } |
| @@ -2865,9 +2985,9 @@ | ||
| 2865 | 2985 | const vipsAvailable = isClientSideMediaSupported(); |
| 2866 | 2986 | updates = { |
| 2867 | 2987 | file: heicJpeg, |
| 2868 | 2988 | sourceFile: heicJpeg, |
| 2869 | - originalHeicFile: item.file, | |
| 2989 | + originalHeicFile: heicFile, | |
| 2870 | 2990 | additionalData: { |
| 2871 | 2991 | ...item.additionalData, |
| 2872 | 2992 | generate_sub_sizes: !vipsAvailable, |
| 2873 | 2993 | convert_format: true |