resizeImage() always produces lossless WebP: convertToBlob defaults to quality 1.0, while the toBlob fallback defaults to 0.8
First, thank you for SingleFile — and for the quick fix in #1982. That fix is what surfaced this: now that unique images are actually resized, the re-encoding step itself turns out to be the remaining size problem.
Summary
resizeImage() calls canvas.convertToBlob({ type: contentType }) with no quality. In current Chromium, convertToBlob defaults to quality: 1.0, and quality 1.0 activates lossless WebP (VP8L). So every image resized via the primary path is re-encoded losslessly, typically several times larger than necessary.
The toBlob fallback in the same function omits quality too — but toBlob's default is 0.8, which is lossy. So the two paths in the same function behave completely differently, and the bug only appears where OffscreenCanvas is available.
Net effect: imageReductionFactor can only ever save by pixel count, never by compression mode.
Measured browser behaviour
Brave 151.1.93.138 (Chromium 151), macOS. 400×600 canvas, inner RIFF chunk read past the VP8X header (VP8L = lossless, VP8 = lossy):
| API | quality |
bytes | codec |
|---|---|---|---|
convertToBlob |
omitted | 50,278 | VP8L — lossless |
convertToBlob |
1.0 | 50,278 | VP8L — lossless |
convertToBlob |
0.92 | 37,706 | VP8 — lossy |
convertToBlob |
0.8 | 24,304 | VP8 — lossy |
toBlob |
omitted | 24,304 | VP8 — lossy |
toBlob |
1.0 | 50,278 | VP8L — lossless |
toBlob |
0.92 | 37,706 | VP8 — lossy |
toBlob |
0.8 | 24,304 | VP8 — lossy |
The byte-identical pairs are the proof: convertToBlob omitted == explicit 1.0, and toBlob omitted == explicit 0.8. Same results with and without an alpha channel.
To confirm those defaults exactly rather than by a single coincidence, I swept toBlob quality across 0.60–1.00 in 0.01 steps on three different canvas contents and looked for byte-identical matches to the omitted-quality result. 0.80 was the unique match every time, and convertToBlob's default matched 1.00 every time (different canvas content from the table above, hence different absolute sizes):
| canvas content | toBlob default |
unique match, 0.60–1.00 sweep | convertToBlob default |
match |
|---|---|---|---|---|
| gradient + noise | 23,728 B | 0.80 | 52,566 B | 1.00 |
| flat cover-like art | 2,686 B | 0.80 | 16,838 B | 1.00 |
| photo-like | 87,666 B | 0.80 | 49,806 B | 1.00 |
The middle row is the case that matters most here: for flat artwork with text — i.e. a book cover — the primary path emits 6.3× more bytes than the fallback for the same pixels.
(Note for anyone who finds it: https://issues.chromium.org/issues/359245758, "convertToBlob quality 1 doesn't activate lossless mode", is stale — quality 1.0 does activate lossless in current Chromium.)
Self-contained repro (paste in DevTools console)function codec(buf){
const u=new Uint8Array(buf), s=(o,n)=>String.fromCharCode(...u.slice(o,o+n));
if(s(0,4)!=='RIFF'||s(8,4)!=='WEBP') return '??';
let f=s(12,4); if(f!=='VP8X') return f.trim();
let p=12+8+10, g=0;
while(p+8<=u.length && g++<20){
const id=s(p,4), sz=u[p+4]|(u[p+5]<<8)|(u[p+6]<<16)|(u[p+7]<<24);
if(id==='VP8 '||id==='VP8L') return id.trim();
p+=8+sz+(sz&1);
}
return 'VP8X(?)';
}
(async()=>{
const c=document.createElement('canvas'); c.width=400; c.height=600;
const x=c.getContext('2d');
const gr=x.createLinearGradient(0,0,400,600);
gr.addColorStop(0,'#8a2be2'); gr.addColorStop(1,'#20b2aa');
x.fillStyle=gr; x.fillRect(0,0,400,600);
const off=new OffscreenCanvas(400,600); off.getContext('2d').drawImage(c,0,0);
for(const q of [undefined,1.0,0.8]){
const o={type:'image/webp'}; if(q!==undefined)o.quality=q;
const b1=await off.convertToBlob(o);
const b2=await new Promise(r=>q===undefined?c.toBlob(r,'image/webp'):c.toBlob(r,'image/webp',q));
console.log('q='+q, 'convertToBlob', b1.size, codec(await b1.arrayBuffer()),
'| toBlob', b2.size, codec(await b2.arrayBuffer()));
}
})();Source
core/lib/processor-helper-common.js, resizeImage():
try {
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext("2d");
context.drawImage(image, 0, 0, width, height);
blob = await canvas.convertToBlob({ type: contentType }); // ← no quality → 1.0 → lossless
} catch {
const canvas = doc.createElement("canvas");
// …
blob = await new Promise(resolve => canvas.toBlob(blob => {
// …
}, contentType)); // ← no quality → ~0.8 → lossy
}contentType is also carried over from the source, so a lossless WebP input is guaranteed to come back out lossless.
Real-world impact
Page: https://www.nytimes.com/interactive/2024/books/best-books-21st-century.html (~100 book covers). The covers are PNG originals, and the CDN serves them under Vary: Accept as lossless WebP (~703 KB each), so the input is already worst-case.
| Setting | Archive size | Inline images |
|---|---|---|
imageReductionFactor: 1 |
142.2 MB | 125 images, 140.7 MB of base64; 120 × VP8L |
imageReductionFactor: 1.5 |
73.1 MB | resized correctly (600→400, 2048→1365) but still VP8L, ~300 KB each |
73.1 / 142.2 ≈ 1 / 2.25 — exactly the pixel-area ratio, i.e. the entire saving came from downscaling and none from compression. webpinfo on the re-encoded images confirms Format: Lossless (2).
Re-encoding every one of the 116 inline images in that 73.1 MB archive with libwebp at quality 80 (all 116 succeeded) gives a measured 9.6 MB archive — the base64 payload drops from 71.5 MB to 8.1 MB, 8.9× smaller, for the same dimensions. Chromium encodes WebP with libwebp as well, so its quality-0.8 output should land close, though not necessarily byte-identical.
This matters downstream for self-hosted archivers: my linkding instance sits behind nginx with client_max_body_size in the 64–128 MB range, and the 142 MB capture was rejected with a 413. #1982 mentions Karakeep's SingleFile ingest hitting OOM/timeouts on 50–70 MB archives for what looks like the same underlying reason.
Suggested fix
Pass an explicit quality to the primary path so it matches the fallback's effective behaviour:
blob = await canvas.convertToBlob({ type: contentType, quality: 0.8 });Optionally expose it as an option (e.g. imageQuality, default 0.8) alongside imageReductionFactor, so lossless can still be requested deliberately with 1.0. Either way it would be worth passing the same value to the toBlob fallback rather than relying on its default, so the two paths can't diverge again.
Since single-file-cli shares this core (and just gained --image-reduction-factor in gildas-lormeau/single-file-cli@f24d518), the fix should benefit it too.
Happy to test a build.
Environment
- SingleFile 1.26.5 (MV3), Brave 151.1.93.138 (Chromium 151), macOS 15
imageReductionFactor: 1.5, destination = REST form API- Reproduced with factors 1 and 1.5; codec verified with
webpinfoand by reading the inner RIFF chunk past theVP8Xheader
Diagnosed with the assistance of Claude (Anthropic). The measurements, source reading, and browser-behaviour matrix above were produced and verified in the course of that session; I've reviewed them before filing.
Source: gildas-lormeau/SingleFile