#2001·SingleFile

resizeImage() always produces lossless WebP: convertToBlob defaults to quality 1.0, while the toBlob fallback defaults to 0.8

Author: kennethb22Created Sep 25, 2026Updated Sep 25, 2026

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)
javascript
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():

javascript
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:

javascript
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 webpinfo and by reading the inner RIFF chunk past the VP8X header

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