| ... | @@ -140,27 +140,28 @@ function createCanvas(width: number, height: number): Canvas2D | null { | ... | @@ -140,27 +140,28 @@ function createCanvas(width: number, height: number): Canvas2D | null { |
| 140 | * and it keeps alpha, so screenshots with transparency survive intact. | 140 | * and it keeps alpha, so screenshots with transparency survive intact. |
| 141 | * Browsers that can't encode it silently fall back to JPEG. | 141 | * Browsers that can't encode it silently fall back to JPEG. |
| 142 | */ | 142 | */ |
| 143 | async function encodeToDataUrl( | 143 | async function encodeCanvas( |
| 144 | canvas: OffscreenCanvas | HTMLCanvasElement, | 144 | canvas: OffscreenCanvas | HTMLCanvasElement, |
| 145 | quality: number, | 145 | quality: number, |
| 146 | mimeType: string, | 146 | mimeType: string, |
| 147 | ): Promise<{ dataUrl: string; mimeType: string } | null> { | 147 | budgetChars: number, |
| | 148 | ): Promise<{ dataUrl: string | null; mimeType: string } | null> { |
| 148 | if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) { | 149 | if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) { |
| 149 | const dataUrl = canvas.toDataURL(mimeType, quality); | 150 | const dataUrl = canvas.toDataURL(mimeType, quality); |
| 150 | // toDataURL silently returns a PNG when the requested type is unsupported. | 151 | // toDataURL silently returns a PNG when the requested type is unsupported. |
| 151 | if (!dataUrl.startsWith(`data:${mimeType}`)) return null; | 152 | if (!dataUrl.startsWith(`data:${mimeType}`)) return null; |
| 152 | return { dataUrl, mimeType }; | 153 | return { dataUrl: dataUrl.length <= budgetChars ? dataUrl : null, mimeType }; |
| 153 | } | 154 | } |
| 154 | const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality }); | 155 | const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality }); |
| 155 | if (blob.type && blob.type !== mimeType) return null; | 156 | if (blob.type && blob.type !== mimeType) return null; |
| | 157 | const dataUrlLength = `data:${mimeType};base64,`.length + 4 * Math.ceil(blob.size / 3); |
| | 158 | if (dataUrlLength > budgetChars) return { dataUrl: null, mimeType }; |
| 156 | return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType }; | 159 | return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType }; |
| 157 | } | 160 | } |
| 158 | | 161 | |
| 159 | /** | 162 | /** |
| 160 | * Draws `bitmap` scaled to fit `maxDimension` and encodes it, stepping | 163 | * Draws `bitmap` scaled to fit `maxDimension` and encodes it, stepping |
| 161 | * quality down until the data URL fits `budgetChars`. Returns the smallest | 164 | * quality down until the data URL fits `budgetChars`. |
| 162 | * encoding produced, even if it still exceeds the budget, so the caller can | | |
| 163 | * decide whether to keep or drop it. | | |
| 164 | */ | 165 | */ |
| 165 | async function encodeWithinBudget( | 166 | async function encodeWithinBudget( |
| 166 | bitmap: ImageBitmap, | 167 | bitmap: ImageBitmap, |
| ... | @@ -175,7 +176,7 @@ async function encodeWithinBudget( | ... | @@ -175,7 +176,7 @@ async function encodeWithinBudget( |
| 175 | | 176 | |
| 176 | // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to | 177 | // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to |
| 177 | // happen before drawing and depends on which codec we end up using. | 178 | // happen before drawing and depends on which codec we end up using. |
| 178 | const probe = await encodeToDataUrl(target.canvas, QUALITY_STEPS[0], "image/webp"); | 179 | const probe = await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp", 0); |
| 179 | const mimeType = probe ? "image/webp" : "image/jpeg"; | 180 | const mimeType = probe ? "image/webp" : "image/jpeg"; |
| 180 | | 181 | |
| 181 | if (mimeType === "image/jpeg") { | 182 | if (mimeType === "image/jpeg") { |
| ... | @@ -184,18 +185,14 @@ async function encodeWithinBudget( | ... | @@ -184,18 +185,14 @@ async function encodeWithinBudget( |
| 184 | } | 185 | } |
| 185 | target.context.drawImage(bitmap, 0, 0, width, height); | 186 | target.context.drawImage(bitmap, 0, 0, width, height); |
| 186 | | 187 | |
| 187 | let smallest: { dataUrl: string; mimeType: string } | null = null; | | |
| 188 | for (const quality of QUALITY_STEPS) { | 188 | for (const quality of QUALITY_STEPS) { |
| 189 | const encoded = await encodeToDataUrl(target.canvas, quality, mimeType); | 189 | const encoded = await encodeCanvas(target.canvas, quality, mimeType, budgetChars); |
| 190 | if (!encoded) break; | 190 | if (!encoded) break; |
| 191 | if (smallest === null || encoded.dataUrl.length < smallest.dataUrl.length) { | 191 | if (encoded.dataUrl !== null) { |
| 192 | smallest = encoded; | 192 | return { dataUrl: encoded.dataUrl, mimeType: encoded.mimeType }; |
| 193 | } | | |
| 194 | if (encoded.dataUrl.length <= budgetChars) { | | |
| 195 | return encoded; | | |
| 196 | } | 193 | } |
| 197 | } | 194 | } |
| 198 | return smallest; | 195 | return null; |
| 199 | } | 196 | } |
| 200 | | 197 | |
| 201 | type ReencodeResult = | 198 | type ReencodeResult = |