| ... | @@ -143,33 +143,27 @@ async function validateHeicImageDimensions( | ... | @@ -143,33 +143,27 @@ async function validateHeicImageDimensions( |
| 143 | return foundImageDimensions ? null : "unreadable"; | 143 | return foundImageDimensions ? null : "unreadable"; |
| 144 | } | 144 | } |
| 145 | | 145 | |
| 146 | /** Chunked so a large image can't blow the argument limit of `fromCharCode`. */ | 146 | export function readFileAsDataUrl(file: Blob): Promise<string> { |
| 147 | const BASE64_CHUNK_SIZE = 0x8000; | 147 | return new Promise((resolve, reject) => { |
| 148 | | 148 | const reader = new FileReader(); |
| 149 | function bytesToBase64(bytes: Uint8Array): string { | 149 | reader.addEventListener("load", () => { |
| 150 | let binary = ""; | 150 | if (typeof reader.result === "string") { |
| 151 | for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) { | 151 | resolve(reader.result); |
| 152 | binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE)); | 152 | return; |
| 153 | } | 153 | } |
| 154 | return btoa(binary); | 154 | reject(new Error("Could not read image data.")); |
| 155 | } | 155 | }); |
| 156 | | 156 | reader.addEventListener("error", () => { |
| 157 | /** | 157 | reject(reader.error ?? new Error("Failed to read image.")); |
| 158 | * Blob → base64 data URL. Uses `arrayBuffer()` rather than `FileReader` so | 158 | }); |
| 159 | * the module works anywhere `Blob` does (including non-DOM test runners). | 159 | reader.readAsDataURL(file); |
| 160 | */ | 160 | }); |
| 161 | async function blobToDataUrl(blob: File | Blob, mimeTypeOverride?: string): Promise<string> { | | |
| 162 | const buffer = await blob.arrayBuffer(); | | |
| 163 | const mimeType = mimeTypeOverride || blob.type || "application/octet-stream"; | | |
| 164 | return `data:${mimeType};base64,${bytesToBase64(new Uint8Array(buffer))}`; | | |
| 165 | } | 161 | } |
| 166 | | 162 | |
| 167 | /** Approximate decoded byte count for a base64 data URL. */ | 163 | function dataUrlLength(blob: Blob): number { |
| 168 | function dataUrlByteLength(dataUrl: string): number { | 164 | return ( |
| 169 | const commaIndex = dataUrl.indexOf(","); | 165 | `data:${blob.type || "application/octet-stream"};base64,`.length + 4 * Math.ceil(blob.size / 3) |
| 170 | const payload = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1); | 166 | ); |
| 171 | const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0; | | |
| 172 | return Math.max(0, Math.floor((payload.length * 3) / 4) - padding); | | |
| 173 | } | 167 | } |
| 174 | | 168 | |
| 175 | /** Base64 payload of a data URL decoded back into a `File`. */ | 169 | /** Base64 payload of a data URL decoded back into a `File`. */ |
| ... | @@ -232,19 +226,14 @@ async function encodeCanvas( | ... | @@ -232,19 +226,14 @@ async function encodeCanvas( |
| 232 | canvas: OffscreenCanvas | HTMLCanvasElement, | 226 | canvas: OffscreenCanvas | HTMLCanvasElement, |
| 233 | quality: number, | 227 | quality: number, |
| 234 | mimeType: string, | 228 | mimeType: string, |
| 235 | budgetChars: number, | 229 | ): Promise<Blob | null> { |
| 236 | ): Promise<{ dataUrl: string | null; mimeType: string } | null> { | 230 | let blob: Blob | null; |
| 237 | if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) { | 231 | if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) { |
| 238 | const dataUrl = canvas.toDataURL(mimeType, quality); | 232 | blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, mimeType, quality)); |
| 239 | // toDataURL silently returns a PNG when the requested type is unsupported. | 233 | } else { |
| 240 | if (!dataUrl.startsWith(`data:${mimeType}`)) return null; | 234 | blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality }); |
| 241 | return { dataUrl: dataUrl.length <= budgetChars ? dataUrl : null, mimeType }; | | |
| 242 | } | 235 | } |
| 243 | const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality }); | 236 | return blob?.type === mimeType ? blob : null; |
| 244 | if (blob.type && blob.type !== mimeType) return null; | | |
| 245 | const dataUrlLength = `data:${mimeType};base64,`.length + 4 * Math.ceil(blob.size / 3); | | |
| 246 | if (dataUrlLength > budgetChars) return { dataUrl: null, mimeType }; | | |
| 247 | return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType }; | | |
| 248 | } | 237 | } |
| 249 | | 238 | |
| 250 | const composerThumbnails = new WeakMap<File, Promise<string | null>>(); | 239 | const composerThumbnails = new WeakMap<File, Promise<string | null>>(); |
| ... | @@ -274,10 +263,8 @@ export function createComposerImageThumbnail(file: File): Promise<string | null> | ... | @@ -274,10 +263,8 @@ export function createComposerImageThumbnail(file: File): Promise<string | null> |
| 274 | dimension, | 263 | dimension, |
| 275 | dimension, | 264 | dimension, |
| 276 | ); | 265 | ); |
| 277 | return ( | 266 | const blob = await encodeCanvas(surface.canvas, 1, "image/png"); |
| 278 | (await encodeCanvas(surface.canvas, 1, "image/png", Number.POSITIVE_INFINITY))?.dataUrl ?? | 267 | return blob ? await readFileAsDataUrl(blob) : null; |
| 279 | null | | |
| 280 | ); | | |
| 281 | } catch { | 268 | } catch { |
| 282 | return null; | 269 | return null; |
| 283 | } finally { | 270 | } finally { |
| ... | @@ -297,7 +284,7 @@ async function encodeWithinBudget( | ... | @@ -297,7 +284,7 @@ async function encodeWithinBudget( |
| 297 | maxDimension: number, | 284 | maxDimension: number, |
| 298 | budgetChars: number, | 285 | budgetChars: number, |
| 299 | preferredMimeType?: "image/jpeg", | 286 | preferredMimeType?: "image/jpeg", |
| 300 | ): Promise<{ dataUrl: string; mimeType: string; imageSize: ImageSize } | null> { | 287 | ): Promise<{ blob: Blob; mimeType: string; imageSize: ImageSize } | null> { |
| 301 | const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height)); | 288 | const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height)); |
| 302 | const width = Math.max(1, Math.round(bitmap.width * scale)); | 289 | const width = Math.max(1, Math.round(bitmap.width * scale)); |
| 303 | const height = Math.max(1, Math.round(bitmap.height * scale)); | 290 | const height = Math.max(1, Math.round(bitmap.height * scale)); |
| ... | @@ -308,7 +295,7 @@ async function encodeWithinBudget( | ... | @@ -308,7 +295,7 @@ async function encodeWithinBudget( |
| 308 | // happen before drawing and depends on which codec we end up using. | 295 | // happen before drawing and depends on which codec we end up using. |
| 309 | const mimeType = | 296 | const mimeType = |
| 310 | preferredMimeType ?? | 297 | preferredMimeType ?? |
| 311 | ((await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp", 0)) | 298 | ((await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp")) |
| 312 | ? "image/webp" | 299 | ? "image/webp" |
| 313 | : "image/jpeg"); | 300 | : "image/jpeg"); |
| 314 | | 301 | |
| ... | @@ -319,17 +306,17 @@ async function encodeWithinBudget( | ... | @@ -319,17 +306,17 @@ async function encodeWithinBudget( |
| 319 | target.context.drawImage(bitmap, 0, 0, width, height); | 306 | target.context.drawImage(bitmap, 0, 0, width, height); |
| 320 | | 307 | |
| 321 | for (const quality of QUALITY_STEPS) { | 308 | for (const quality of QUALITY_STEPS) { |
| 322 | const encoded = await encodeCanvas(target.canvas, quality, mimeType, budgetChars); | 309 | const encoded = await encodeCanvas(target.canvas, quality, mimeType); |
| 323 | if (!encoded) break; | 310 | if (!encoded) break; |
| 324 | if (encoded.dataUrl !== null) { | 311 | if (dataUrlLength(encoded) <= budgetChars) { |
| 325 | return { dataUrl: encoded.dataUrl, mimeType: encoded.mimeType, imageSize: { width, height } }; | 312 | return { blob: encoded, mimeType, imageSize: { width, height } }; |
| 326 | } | 313 | } |
| 327 | } | 314 | } |
| 328 | return null; | 315 | return null; |
| 329 | } | 316 | } |
| 330 | | 317 | |
| 331 | type ReencodeResult = | 318 | type ReencodeResult = |
| 332 | | { ok: true; dataUrl: string; mimeType: string; imageSize: ImageSize } | 319 | | { ok: true; blob: Blob; mimeType: string; imageSize: ImageSize } |
| 333 | | { ok: false; reason: ImageCompressionFailureReason }; | 320 | | { ok: false; reason: ImageCompressionFailureReason }; |
| 334 | | 321 | |
| 335 | /** | 322 | /** |
| ... | @@ -378,10 +365,10 @@ async function reencodeWithinBudget( | ... | @@ -378,10 +365,10 @@ async function reencodeWithinBudget( |
| 378 | continue; | 365 | continue; |
| 379 | } | 366 | } |
| 380 | encodeFailed = false; | 367 | encodeFailed = false; |
| 381 | if (encoded && encoded.dataUrl.length <= budgetChars) { | 368 | if (encoded) { |
| 382 | return { | 369 | return { |
| 383 | ok: true, | 370 | ok: true, |
| 384 | dataUrl: encoded.dataUrl, | 371 | blob: encoded.blob, |
| 385 | mimeType: encoded.mimeType, | 372 | mimeType: encoded.mimeType, |
| 386 | imageSize: encoded.imageSize, | 373 | imageSize: encoded.imageSize, |
| 387 | }; | 374 | }; |
| ... | @@ -405,37 +392,35 @@ export async function compressImageForStash( | ... | @@ -405,37 +392,35 @@ export async function compressImageForStash( |
| 405 | file: File, | 392 | file: File, |
| 406 | budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS, | 393 | budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS, |
| 407 | ): Promise<CompressStashImageResult> { | 394 | ): Promise<CompressStashImageResult> { |
| 408 | let originalDataUrl: string; | | |
| 409 | try { | 395 | try { |
| 410 | originalDataUrl = await blobToDataUrl(file); | 396 | if (dataUrlLength(file) <= budgetChars) { |
| 411 | } catch { | 397 | return { |
| 412 | return { ok: false, reason: "unreadable" }; | 398 | ok: true, |
| 413 | } | 399 | image: { |
| 414 | if (originalDataUrl.length <= budgetChars) { | 400 | dataUrl: await readFileAsDataUrl(file), |
| | 401 | mimeType: file.type, |
| | 402 | sizeBytes: file.size, |
| | 403 | recompressed: false, |
| | 404 | }, |
| | 405 | }; |
| | 406 | } |
| | 407 | const reencoded = await reencodeWithinBudget(file, budgetChars); |
| | 408 | if (!reencoded.ok) { |
| | 409 | return reencoded; |
| | 410 | } |
| 415 | return { | 411 | return { |
| 416 | ok: true, | 412 | ok: true, |
| 417 | image: { | 413 | image: { |
| 418 | dataUrl: originalDataUrl, | 414 | dataUrl: await readFileAsDataUrl(reencoded.blob), |
| 419 | mimeType: file.type, | 415 | mimeType: reencoded.mimeType, |
| 420 | sizeBytes: file.size, | 416 | sizeBytes: reencoded.blob.size, |
| 421 | recompressed: false, | 417 | recompressed: true, |
| | 418 | imageSize: reencoded.imageSize, |
| 422 | }, | 419 | }, |
| 423 | }; | 420 | }; |
| | 421 | } catch { |
| | 422 | return { ok: false, reason: "unreadable" }; |
| 424 | } | 423 | } |
| 425 | const reencoded = await reencodeWithinBudget(file, budgetChars); | | |
| 426 | if (!reencoded.ok) { | | |
| 427 | return reencoded; | | |
| 428 | } | | |
| 429 | return { | | |
| 430 | ok: true, | | |
| 431 | image: { | | |
| 432 | dataUrl: reencoded.dataUrl, | | |
| 433 | mimeType: reencoded.mimeType, | | |
| 434 | sizeBytes: dataUrlByteLength(reencoded.dataUrl), | | |
| 435 | recompressed: true, | | |
| 436 | imageSize: reencoded.imageSize, | | |
| 437 | }, | | |
| 438 | }; | | |
| 439 | } | 424 | } |
| 440 | | 425 | |
| 441 | /** | 426 | /** |
| ... | @@ -468,10 +453,10 @@ export async function compressImageToByteLimit( | ... | @@ -468,10 +453,10 @@ export async function compressImageToByteLimit( |
| 468 | } | 453 | } |
| 469 | return { | 454 | return { |
| 470 | ok: true, | 455 | ok: true, |
| 471 | file: dataUrlToFile( | 456 | file: new File( |
| 472 | reencoded.dataUrl, | 457 | [reencoded.blob], |
| 473 | fileNameForMimeType(file.name || "image", reencoded.mimeType), | 458 | fileNameForMimeType(file.name || "image", reencoded.mimeType), |
| 474 | reencoded.mimeType, | 459 | { type: reencoded.mimeType }, |
| 475 | ), | 460 | ), |
| 476 | recompressed: true, | 461 | recompressed: true, |
| 477 | imageSize: reencoded.imageSize, | 462 | imageSize: reencoded.imageSize, |