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