canvas最大尺寸是多少:三个浏览器能画、能导出的上限

摘要

有人往页面里传了一张 20000×20000 的图。预览缩略图好好的。点「导出原图」拿到的却是一张空白图;控制台干干净净,getContext('2d') 也好好地返回了对象。我一开始以为是自己哪行代码写错了,查了一圈才发现问题不在代码。问题在「canvas 最大能多大」这件事本身没有统一答案。它要按内核分,还要按阶段分。

我找了三个内核构建挨个测了一遍。Chromium 149 和 WebKit 26.5 构建能画的面积上限都是 268,435,456 像素(16384×16384)。多一行变成 16384×16385 就画不上。Firefox 151 构建的方形上限是 23168×23168。这大约是 5.37 亿像素,是另两家的 2 倍。能画还不等于能导出:WebKit 构建单边能画到 4,194,303,导出 PNG 却只到 1,000,000。能导出也不等于拿到的就是原图。Chromium 导出 WebP 时边长超过 16383 会被直接裁掉,一个字都不提示。

这篇把三个阶段拆开,给一张能按「内核 × 阶段」查的表。表后面附四段代码。每段都在我这台机器上真跑过,输出原样贴出来。内存和大图解码也各占一章。它们只是陪衬,主角是那张表。

版本声明

主体数据跑于 2026-09-24。浏览器是三个内核构建,版本号是 Chromium 149.0.7827.55、WebKit 26.5 和 Firefox 151.0。Chromium 那个是开源构建而不是平时下载安装的 Chrome。机器是一台 Apple M4、16 GB 内存的 Mac(macOS 26.5.2)。文中四段代码是写这篇时在同一台机器、同样三个构建上重跑的;输出和主体数据对得上的我直接用。对不上的地方单独说。

WebKit 这一列要多说一句。它的 UA 写着 Version/26.5 Safari/605.1.15。可它只是一个 WebKit 26.5 构建,不等于 Safari 26.5 正式版。Firefox 那列同理,是 Firefox 151 的构建而非正式版。真 Safari、iOS、安卓 Chrome、微信内置浏览器和 Chrome 正式版我一个都没测。网上流传的那些「iOS 上限是多少」的数字,这篇不引用也不背书。

适用边界

这篇管什么 。只管一件事:一张图在浏览器里走 <canvas> 这条路时每个阶段最大能多大。阶段分三个。「能画」指整幅填色之后读回已知像素是对的;「能导出」指 toBlob 给了非 null 的文件,文件解开后宽高和颜色也对。「导出格式」指换成 WebP 或 JPEG 时边长又会被卡在哪。三内核各给一套数。

这篇不管什么。GPU 加速画布没查。这几个构建里画布是不是走 GPU 我不知道。WebGL 和 WebGPU 的纹理上限没测;Worker 里的内存没单独量。多标签页和低内存设备也没测。HEIC、AVIF、WebP 格式的大图输入没测,只测了 JPEG 和 PNG。分块处理和渐进降采样我会讲原理。手上没有实测数,就不给数字。

样本全是程序生成的。大图是 RGB 渐变叠 40 个半透明矩形和圆,再加一层高斯噪声。没有一张是照片。画布探针更简单:直接在画布上填纯色,在角上点两个色块。我是非科班转过来写工具的。内核内部为什么这么设计,我读不了源码,只能从表现去猜。猜的地方都会标出来。


文章目录

一、canvas 最大尺寸是多少:先看这张按阶段查的表

  • 1.1 表怎么读
  • 1.2 为什么「canvas 上限」不是一个数

二、canvas 能画多大:面积上限和单边上限

  • 2.1 Chromium 与 WebKit 构建卡在 268,435,456 像素
  • 2.2 Firefox 构建的界线不是 2^29
  • 2.3 单边:65,535 和 4,194,303
  • 2.4 OffscreenCanvas 放进 Worker 能不能画得更大
  • 2.5 代码一:三个阶段各判一次
  • 2.6 1 亿像素在三家都在界内

三、canvas 超过上限会报错吗

  • 3.1 getContext 返回对象不代表能用
  • 3.2 三家唯一一致的信号

四、canvas 能导出多大:PNG、WebP、JPEG 各有一条线

  • 4.1 WebKit 构建能画 400 万宽却只能导出 100 万宽
  • 4.2 toBlob 导出 WebP 为什么宽度变成 16383
  • 4.3 JPEG 的 65500 和 65535
  • 4.4 代码二:导出之后读回宽高

五、img 能解多大和 canvas 能画多大是两回事

  • 5.1 20000×20000 的图:缩略图好好的,原尺寸是空白
  • 5.2 解不了的图照样触发 load
  • 5.3 代码三:img.decode() 报错但图能画

六、大图占多少内存

七、代码四:把这张表写成一个查询函数

八、适用边界与风险提示

九、测量方法自身的坑

十、还没解决的

参考资料


一、canvas 最大尺寸是多少:先看这张按阶段查的表

1.1 表怎么读

先把答案摆出来。每一行是一个阶段,每一列是一个内核;格子里的数是「这一步还能成功的最大值」。它们全部来自逐档加大尺寸、再二分到相邻整数的实测。

阶段 Chromium 149 WebKit 26.5 构建 Firefox 151 构建
拿到 context 测到 10 亿像素仍返回 同左 同左
能画:面积 268,435,456 像素 268,435,456 像素 行字节×高 < 2 GiB
能画:方形 16384² 16384² 23168²
能画:单边 65,535 4,194,303 65,535
宽 8192 时最大高 32,768 32,768 65,535
宽 16384 时最大高 16,384 16,384 32,767
导出 PNG:单边 65,535 1,000,000 65,535
导出 WebP:边长 > 16383 不报错、裁成 16383 不编 WebP、回落 PNG 回调 null
导出 JPEG:边长 > 65500 不报错、裁成 65500 按原尺寸写到 65535 回调 null

读这张表时我自己只看两件事。第一件是「方形」和「单边」两行要一起看:面积规则和单边规则同时生效,哪个先撞到算哪个。第二件是导出那几行和画那几行不是一回事:能画出来的图换个格式导出,可能就变小了、变空了,或者压根拿不到文件。

这张图是上面那张表的完整版,多了判定方法和 OffscreenCanvas 那一行。最该盯的是 WebKit 那一列的两个数:单边能画 4,194,303,导出 PNG 只有 1,000,000,中间差了 4 倍多。第二个该盯的是第一行。三家都标着橙色的「测到 10 亿像素仍返回」。意思是「拿到 context」这个阶段在我设的安全上限以内从没失败过;拿它判断画布能不能用等于没判。

1.2 为什么「canvas 上限」不是一个数

搜「canvas 最大尺寸」能搜到好几个说法:32767、16384×16384、2.68 亿像素。每个说法都有人信。我测完的感觉是它们各自说对了一小块,然后被当成了全部。16384×16384 只在 Chromium 和 WebKit 构建的「能画」阶段成立;到了 Firefox 构建就不对了。2.68 亿像素说的是面积,它挡不住单边:Chromium 画 65536×8 这种细长条,面积才 52 万像素,照样画不上。32767 在我测的三个构建里没有作为哪一步的上限出现过。唯一沾边的是 Firefox 构建宽 16384 时的最大高 32,767;它正好是按行字节规则算出来的那个高度。

我后来都不问「上限是多少」了,改问三句:哪个内核?哪个阶段?导出成什么格式?三句都答了,数才有意义。

二、canvas 能画多大:面积上限和单边上限

2.1 Chromium 与 WebKit 构建卡在 268,435,456 像素

「能画」的判定我做得比较笨。整幅 fillRect 填红、左上角 4×4 填绿、右下角 4×4 填蓝。然后用 getImageData 读回左上、中心、右下三个点。三个点全对才算画上了。只看一个点会被骗,这个第九章细说。

Chromium 149 和 WebKit 26.5 构建在这一步的规则一模一样:宽×高不超过 268,435,456(2^28)。16384×16384 能画也能导出;16384×16385 只多一行,就画不上了。宽固定 8192 时最大高是 32,768;宽固定 16384 时最大高是 16,384。两个数都正好落在同一个面积上。我在失败点之上又抽了 +1、+97、×1.5 三档。全部还是失败,没有出现「过了线又能画」的情况。

2.2 Firefox 构建的界线不是 2^29

Firefox 151 构建方形能画到 23168×23168,面积 536,756,224。这差不多是另两家的 2 倍。一开始我顺手把它记成「2^29 像素」,看着很像;结果 23169×23169 画不上。可它的面积 536,802,561 其实还小于 2^29。这就对不上了。

我换了个思路:固定宽度,二分最大高度。挑了四个宽度。宽 10000 最大高 53,687;宽 10001 最大高 53,665。宽 12345 最大高 43,478;宽 30000 最大高 17,895。拿「每行字节数按 16 字节对齐再乘高度、结果小于 2 GiB」去算,四个宽度全部吻合。拿「面积不超过 2^29」去算,10001 和 12345 两个宽度都对不上。Firefox 构建这边我只敢写「实测符合行字节×高 < 2 GiB」;这是从四个宽度加一个方形推出来的规则。我没读源码,它到底是不是这么写的我说不准。另外它还有一条单边 65,535 的硬线,和面积规则同时生效。

2.3 单边:65,535 和 4,194,303

单边上限我用细长条测:宽拉到很大,高固定 8。Chromium 149 和 Firefox 151 构建都停在 65,535。再多 1 像素就画不上。WebKit 26.5 构建能一路画到 4,194,303(2^22 减 1)。

WebKit 在 4,194,304 这一档的失败方式很怪。左上和中心读回都是对的。只有右下角读回 (0,0,0,0)。等于左边画上了、右端没画上。如果只读左上一个点,这一档会被判成成功;这也是我坚持读三个点、而且一定读右下角的原因。

2.4 OffscreenCanvas 放进 Worker 能不能画得更大

不能。我把同一套探针在三种画布上各跑了一遍:普通 <canvas>、主线程里 new OffscreenCanvas() 和 Worker 里的 OffscreenCanvas。三个内核上所有阈值完全相同,一个数都没变。Worker 换来的是不堵主线程。它换不来更大的画布。至于 Worker 里的内存是不是另算,这次没单独量,我不下结论。

2.5 代码一:三个阶段各判一次

下面这段是我平时拿来自查的最小版本;它只做三件事:拿 context、画上并读回右下角、导出 PNG 看是不是 null。尺寸挑的是单边那几条线的两侧。画布最大才 1000001×8,内存几十 MB,放心跑。

js 复制代码
async function stageProbe(w, h) {
  const cnv = document.createElement('canvas');
  cnv.width = w; cnv.height = h;
  const surf = cnv.getContext('2d');
  const result = { size: `${w}x${h}`, ctx: surf !== null };
  surf.fillStyle = '#f00'; surf.fillRect(0, 0, w, h);
  surf.fillStyle = '#00f'; surf.fillRect(w - 4, h - 4, 4, 4);
  try {
    const px = surf.getImageData(w - 1, h - 1, 1, 1).data;
    result.draw = px[2] === 255 && px[3] === 255;
  } catch (e) { result.draw = 'throw ' + e.name; }
  const blob = await new Promise(r => cnv.toBlob(r, 'image/png'));
  result.png = blob !== null;
  cnv.width = cnv.height = 0;
  return result;
}

我在三个内核里各开一个空白页,把函数贴进控制台依次调 stageProbe 跑 65535×8、65536×8、1000000×8、1000001×8 四档。真实输出如下。

text 复制代码
== chromium 149.0.7827.55
{"size":"65535x8","ctx":true,"draw":true,"png":true}
{"size":"65536x8","ctx":true,"draw":false,"png":false}
{"size":"1000000x8","ctx":true,"draw":false,"png":false}
{"size":"1000001x8","ctx":true,"draw":false,"png":false}
== webkit 26.5
{"size":"65535x8","ctx":true,"draw":true,"png":true}
{"size":"65536x8","ctx":true,"draw":true,"png":true}
{"size":"1000000x8","ctx":true,"draw":true,"png":true}
{"size":"1000001x8","ctx":true,"draw":true,"png":false}
== firefox 151.0
{"size":"65535x8","ctx":true,"draw":true,"png":true}
{"size":"65536x8","ctx":true,"draw":"throw NS_ERROR_FAILURE","png":false}
{"size":"1000000x8","ctx":true,"draw":"throw NS_ERROR_FAILURE","png":false}
{"size":"1000001x8","ctx":true,"draw":"throw NS_ERROR_FAILURE","png":false}

代码说明 。ctx 那一列十二行全是 true;这就是我说「拿到 context 没意义」的现场证据。Chromium 超过 65,535 之后三步里只有第一步「成功」;后两步都静悄悄地失败,没有抛错。Firefox 构建是在 getImageData 那一步抛 NS_ERROR_FAILURE。它算是三家里唯一肯出声的。这次顺带看到它在超单边时也抛,之前我只在超面积时见过。最能看出「能画」和「能导出」是两条线的是 WebKit 构建。1000001×8 画上了、读回也对,toBlob 却给了 null;这段只判 PNG 非 null,没解开文件核对内容。要判导出的内容对不对,得把文件拿回来解。第四章那段代码干的就是这个。

2.6 1 亿像素在三家都在界内

回到最常见的场景。用户传的图大多是几千像素见方,一亿像素级已经算大户了;10000×10000 是 1 亿像素,在三个内核的面积规则里都有富余。40000×2500 也是 1 亿像素,单边 40000 也没过 65,535。我拿 10000×10000 的 JPEG 和 PNG、40000×2500 的 JPEG 各跑了三条路:<img> 出缩略图、createImageBitmap 出缩略图、画进原尺寸画布再 toBlob。三内核九个格子全部成功。

裸 API 看完我也拿现成工具对照了一下。用的是图映 ImgIng(https://imging.cn/)的「压缩转换」,还是这台 M4 加同样三个内核构建。其中 10000×10000 的合成 JPEG 转 WebP 和转 JPG 在三个内核上都做完了。整轮换着几张大样本共 12 个用例里非 GET 请求一共 0 次,这个量级的图也没往外传。上传这种尺寸时界面会给一个可选的「缩到 2048px」建议。不点它就按原尺寸处理。

三、canvas 超过上限会报错吗

3.1 getContext 返回对象不代表能用

这是我踩得最冤的一个坑。很多自查代码写的是「getContext 返回 null 就提示用户图太大」。我在三个内核上把画布一路加到 10 亿像素、单边 1.25 亿;getContext('2d') 每次都老老实实返回对象。width 和 height 读回来也还是我设的原值;这个判断在我测的三个构建里一次都不会触发。

fillRect 也帮不上忙。超过上限之后整幅填色,三家都不抛错;Chromium 和 WebKit 构建读回来是全 0。Firefox 构建读回时直接抛错,没法确认到底画没画上。

这张是我写的复现小页面在 Firefox 151 构建里的样子。同一张 sample.jpg 同时画进两张画布。左边 4,096 × 4,096 是对照。图在而导出的 export.png 有 13.5 MB。右边 23,169 × 23,169 是它第一档画不上的方形。红框里只剩棋盘格,导出的 export.png 只有 4 bytes。页面没判 null 就把 toBlob 的结果包成了文件,这 4 个字节就是 null 这几个字母。页面上看不到的部分是这样的:getContext 照样给对象而 fillRect 不抛错。三个点的 getImageData 全抛 NS_ERROR_FAILURE。toDataURL 返回 "data:,"。控制台是空的。换成 Chromium 和 WebKit 构建的 16384×16385,读回是全 0,不抛错。isContextLost() 也各说各的。Chromium 给 true 并触发 contextlost。WebKit 构建没有这个方法;Firefox 构建返回 false。只有 WebKit 构建会打一条「Canvas area exceeds the maximum limit」。在 Worker 里 104 次超面积探针只收到 1 次。

3.2 三家唯一一致的信号

这些信号里三家说法一致、又能在代码里直接判断的只有一条:toBlob 回调拿到 null。对应的 toDataURL 返回 "data:," 这 6 个字符。另一条勉强算:画完读回一个你自己写进去的已知像素。Chromium 和 WebKit 会读回 0,Firefox 会抛错。两种都能接住。

OffscreenCanvas 的 convertToBlob 三家都会 reject,报的错却五花八门。WebKit 构建是 EncodingError;Firefox 构建是 NS_ERROR_FAILURE。Chromium 报 IndexSizeError,文案说画布「尺寸为 0」。可这时候画布宽高读回来明明还是 16384×16385。第一次看到这句我真去查了半天是不是自己把 width 设成了 0。结果不是。它的报错信息和真实原因对不上,别被文案带跑。

四、canvas 能导出多大:PNG、WebP、JPEG 各有一条线

4.1 WebKit 构建能画 400 万宽却只能导出 100 万宽

导出 PNG 这一步 Chromium 和 Firefox 构建的单边上限和「能画」一样都是 65,535。WebKit 构建不一样。它单边能画到 4,194,303,toBlob('image/png') 却只到 1,000,000。宽 1,000,001、高 8 的画布能画也能读回。toBlob 回调 null,convertToBlob reject EncodingError。控制台没有任何提示。

这一步我还核对了「导出内容对不对」,不只看非 null:做法是把 Blob 存成文件,再用 node 流式解 PNG。先看 IHDR 里的宽高是不是等于画布;再看左上、中心、右下三个点的颜色。三内核在各自上限以内全部对得上。

4.2 toBlob 导出 WebP 为什么宽度变成 16383

这个坑最危险。它会给你一个「看起来成功」的文件。Chromium 149 把宽或高超过 16383 的画布导出成 WebP 时不报错,也不返回 null。它交出一个类型正确、能正常打开的 WebP。只是边长被改成了 16383。

光看宽高分不出是缩放还是裁掉,我又加了一步:画一条横向渐变,红色通道从 0 走到 255。再在最右端画 64 像素宽的蓝条,然后导出;如果是缩放,蓝条会被压窄但还在。如果是裁掉,蓝条会消失。而且最后一列的颜色正好等于原图第 16383 列的渐变值。结果是后者。20000×64 导出成 16383×64,蓝条剩 0 像素。末列颜色是 (209,127,0),按渐变算原图第 16383 列应该是 208.9。右边 3617 像素就这么没了;65535×64 更狠:导出只剩左边 16383 像素,丢了 75%。竖着也一样。64×20000 导出后下边被裁。

Firefox 151 构建在同样的情况下 toBlob 回调 null,convertToBlob reject NS_ERROR_FAILURE。至少它让你知道失败了。WebKit 26.5 构建根本不编 WebP:要 WebP 就给你回落成原尺寸 PNG。三家三种行为里只有 Chromium 这种最难发现。

这张是源图和三份导出文件并排摆在一起。为了看得清我把画布换成了 20000×1200。画法一样是渐变加右端蓝条。最上面一条是画布存成的 PNG,蓝条在最右边。第二条是 Chromium 149:要 WebP 拿到的也是 WebP,可只有 16383×1200。红框里右边那一截空着,蓝条跟着没了。第三条是 WebKit 26.5 构建:要 WebP 给的是原尺寸 20000×1200 的 PNG。蓝条完整。最后一格红框属于 Firefox 151 构建,里面什么都没有是因为 toBlob 回调了 null。关键数还是 16383:比很多人记的 16384 少 1。16384×16384 的画布在 Chromium 上能画,导出 WebP 却是 16383×16383。这一格只差 1 像素,是裁掉还是缩放我分不出来。

4.3 JPEG 的 65500 和 65535

JPEG 那一行是同一类事,线换成了 65500。Chromium 149 导出 65535×64 的 JPEG,拿到的是 65500×64。蓝条还剩 28 像素。裁掉的话应剩 29,缩放的话应该还有 64。结论是右边 35 像素被裁掉。Firefox 151 构建在这里回调 null;WebKit 26.5 构建按原尺寸写出 65535×64。

WebKit 写出来的这个 65535 宽 JPEG,我拿别的解码器去开,结果很乱。Chromium 的 <img> 触发 error,decode() 报 EncodingError。Firefox 的 <img> 也是 error。Pillow 11.3 报 broken data stream;macOS 自带的 sips 能打开,像素也是对的。我第一反应是 WebKit 写坏了文件;后来把宽度拉到 65500 和 65501 交叉比。65500 三家都能开。65501 起 Chromium、Firefox 和 Pillow 一起打不开。这更像是解码那头的边长限制,不是文件坏了;JPEG 格式本身允许写到 65535。我测到的只是 Chromium 149 与 Firefox 151 构建只认到 65500。65500 这个数和 libjpeg 系列里的 JPEG_MAX_DIMENSION 一样。这是我的推断,没去翻各家源码。

4.4 代码二:导出之后读回宽高

这段是我现在导出前后一定会加的检查。思路就一句话:别信 toBlob 给了文件,要把文件读回来看宽高和类型。

js 复制代码
async function exportAndMeasure(w, h, type) {
  const cnv = document.createElement('canvas');
  cnv.width = w; cnv.height = h;
  const surf = cnv.getContext('2d');
  surf.fillStyle = '#888'; surf.fillRect(0, 0, w, h);
  const blob = await new Promise(r => cnv.toBlob(r, type, 0.8));
  cnv.width = cnv.height = 0;
  if (!blob) return { asked: `${w}x${h} ${type}`, got: null };
  const back = await createImageBitmap(blob);
  const result = { asked: `${w}x${h} ${type}`, got: `${back.width}x${back.height} ${blob.type}` };
  back.close();
  return result;
}

三个内核各跑四个用例,真实输出如下。

text 复制代码
== chromium 149.0.7827.55
{"asked":"16383x64 image/webp","got":"16383x64 image/webp"}
{"asked":"20000x64 image/webp","got":"16383x64 image/webp"}
{"asked":"65500x64 image/jpeg","got":"65500x64 image/jpeg"}
{"asked":"65535x64 image/jpeg","got":"65500x64 image/jpeg"}
== webkit 26.5
{"asked":"16383x64 image/webp","got":"16383x64 image/png"}
{"asked":"20000x64 image/webp","got":"20000x64 image/png"}
{"asked":"65500x64 image/jpeg","got":"65500x64 image/jpeg"}
{"asked":"65535x64 image/jpeg","got":"65535x64 image/jpeg"}
== firefox 151.0
{"asked":"16383x64 image/webp","got":"16383x64 image/webp"}
{"asked":"20000x64 image/webp","got":null}
{"asked":"65500x64 image/jpeg","got":"65500x64 image/jpeg"}
{"asked":"65535x64 image/jpeg","got":null}

代码说明 。Chromium 第二行和第四行就是「要的和拿到的不一样」的现场:要 20000 宽给了 16383。要 65535 宽给了 65500;这两行不看宽高根本发现不了,因为类型是对的、文件也能打开。WebKit 那两行要 WebP 拿到的是 image/png;检查时除了宽高还得看 blob.type。Firefox 的两个 null 反而最省心:按 null 处理就行。这段只比宽高和类型。裁掉还是缩放要靠上一节那个渐变加蓝条的办法才分得出;我平时在工具里的做法是比出来宽高不对就直接报错。文件不交出去。

五、img 能解多大和 canvas 能画多大是两回事

5.1 20000×20000 的图:缩略图好好的,原尺寸是空白

开头那个场景就发生在这里。20000×20000 是 4 亿像素,JPEG 文件才 19.6 MB 左右。三个内核的 <img> 都能把它解出来,画进 256×256 的缩略图后颜色分块也和原图对得上。可一旦把它画进原尺寸画布,Chromium 和 WebKit 构建就是空白图,toBlob 回调 null。原因前面讲过:4 亿像素超过了这两家 268,435,456 的面积线。Firefox 151 构建这一步能成功:20000×20000 还在它的行字节规则以内。

这张是复现小页面在 Chromium 149 里打开这张 20000×20000 的合成 JPEG。页面上写的 18.7 MB 是按 MiB 算的,和上面的 19.6 MB 是同一个量。左边 Preview 是 <img> 直接显示,中间是缩到 380×380 画进小画布的结果。两块都有图。右边红框是点 Export JPEG 按原尺寸导出的结果:一个破图图标,export.jpg 只有 4 bytes。缩略图正常证明不了原尺寸能导出。同一个文件在 Firefox 151 构建的原尺寸画布上能读回颜色,也能导出。再往上,Chromium 的 <img> 在 23171² 触发报错,createImageBitmap 在 23170² 就已经 reject。解码上限落在 2^29 像素附近。WebKit 构建的 <img> 到 25000² 还能解。createImageBitmap(blob) 从 23170² 起却返回宽高正确、内容全透明的位图,不报错。

5.2 解不了的图照样触发 load

Firefox 151 构建这边有一个更隐蔽的情况。23170² 和 25000² 的 JPEG、70000×1500 的 PNG,它都解不了。可 <img> 照样触发 load,naturalWidth 也是真实宽度;只看这两个字段,你会以为图没问题。画出来才发现全透明。三例里唯一能提前捕获的信号是 img.decode() reject,报「Invalid encoded image data.」。PNG 那一例控制台另有一句「Image corrupt or truncated.」。

5.3 代码三:img.decode() 报错但图能画

那是不是遇到 decode() reject 就当图坏了?在 Chromium 上不能这么干。下面这段把 onload、decode() 和「真画上去」三件事分开看。

js 复制代码
async function probeDecode(url) {
  const srcImg = new Image();
  srcImg.src = url;
  await new Promise((ok, bad) => { srcImg.onload = ok; srcImg.onerror = bad; });
  const result = { file: url.split('/').pop(), natural: `${srcImg.naturalWidth}x${srcImg.naturalHeight}` };
  try { await srcImg.decode(); result.decode = 'ok'; }
  catch (e) { result.decode = e.name; }
  const cnv = document.createElement('canvas');
  cnv.width = cnv.height = 64;
  const surf = cnv.getContext('2d');
  surf.drawImage(srcImg, 0, 0, 64, 64);
  const a = surf.getImageData(0, 0, 64, 64).data;
  let clear = 0;
  for (let i = 3; i < a.length; i += 4) if (a[i] === 0) clear++;
  result.transparentPx = clear;
  return result;
}

我在 Chromium 149 构建上依次喂 4000²、6000²、8000² 三张合成 JPEG。一共关掉重开了 3 次浏览器,每次输出一行。

text 复制代码
1 4000x4000 decode=ok transparentPx=0 ; 6000x6000 decode=ok transparentPx=0 ; 8000x8000 decode=EncodingError transparentPx=0
2 4000x4000 decode=ok transparentPx=0 ; 6000x6000 decode=ok transparentPx=0 ; 8000x8000 decode=EncodingError transparentPx=0
3 4000x4000 decode=ok transparentPx=0 ; 6000x6000 decode=ok transparentPx=0 ; 8000x8000 decode=EncodingError transparentPx=0

代码说明 。8000² 三次都 reject EncodingError,可 transparentPx 是 0。图其实画上了。这里我得改个口。主体数据那一轮在 1000 到 10000 边长上扫了 10 档,两种调用顺序、3 个实例各跑。结果是 6000² 及以上 6/6 reject、5000² 3/6、4000² 以下 0/6。这次我只跑 3 档,只用「先等 load 再 decode」一种顺序。6000² 反而 3/3 成功;加上第一次跑的那一次,一共 4/4。reject 从哪一档开始会随调用方式移动。为什么会这样我没查出来。不变的只有一句:Chromium 149 构建上 decode() reject 的那几档图照样能画。WebKit 和 Firefox 构建这三档各跑一次,全部成功。decode() 这个信号在 Chromium 和 Firefox 构建身上意思正好相反。在 Firefox 构建上它是「图其实是空的」唯一的提醒;在 Chromium 上它可能只是虚惊。我现在的写法是 reject 之后不急着报错;先画进一个小画布数透明像素,数出来再决定。

六、大图占多少内存

能画进画布只是第一关。机器扛不扛得住是另一件事。这一章只挑和「能处理多大」直接相关的几个数;完整的内存预算不在这篇展开。

量法是在 macOS 上把本次启动的全部浏览器进程的 phys_footprint 加起来看每一步相对页面空闲时涨了多少。先说一个反直觉的:只建画布、拿到 context 但不画时三个内核的增量都不到 1 MiB。内存是第一次绘制时才真正分配的;画一次之后 Chromium 和 Firefox 构建的增量正好是宽×高×4 字节。16384×16384 这张填完涨 1025 MiB。整幅 getImageData 再涨一份,到 2050 MiB。WebKit 构建同一张填完是 1206.4 MiB,读回后到 3265.4 MiB。Firefox 构建能画的 23168² 填完是 2049.3 MiB,读回后 4090.9 MiB。

这张图横轴是画布像素,纵轴是内存增量;每个内核两条线:填充后、整幅读回后。先看 Chromium 和 Firefox 的线几乎重合;读回那条正好是填充那条的两倍高。这条规律可以直接拿来估预算。再看 WebKit 读回那条浅橙线翘得最高。16384² 那个点是 3265 MiB;我不清楚它为什么这么高,只能照实写这个点的数。口径是全部浏览器进程之和。

解码的开销比文件大小大得多。一张 13.4 MB、1 亿像素的 JPEG,每次新开浏览器解它。各进程峰值之和是 Chromium 1289 MiB、Firefox 1550 MiB、WebKit 3525 MiB。26.6 MB 的 16384² JPEG 在 WebKit 构建上峰值是 7757 MiB。这些是各进程峰值直接相加。峰值不一定同时发生,会高估同时占用,只能当量级看。

还有一个问题常被问到:画布建多了会不会崩?我不断新建 4096×4096 的画布并全部留着引用,每张像素数据 64 MiB。纯色画布一路加到 7 GiB,噪声画布加到 4 GiB。三个内核都没崩。也没有哪张画布突然失效,控制台也没提示。停下来是因为我设了安全阈值,不是浏览器撑不住了;这台 16 GB 的机器同时还跑着别的活,我不敢往上加。多大会崩,这篇没有答案。回来翻日志才注意到纯色那组系统可用内存几乎没动,噪声那组降到了 2.7 GiB。我猜是 macOS 的内存压缩把纯色页压得很小。压缩器的数没存下来,只能算猜。

分块处理和渐进降采样是绕开这些上限的常见思路:前者是把大图切成若干块,每块都在上限以内,分别处理再拼。后者是先让浏览器解出一个小尺寸版本,在小图上干活;原理上两条都能避开单张画布的面积线。这两条我没实测,手上没有数。这里只讲到原理为止。

七、代码四:把这张表写成一个查询函数

前面几章的阈值我最后都塞进了一个函数里。给定内核和宽高它会告诉我这张图能不能画、能不能导出 PNG、导出 WebP 和 JPEG 会怎样。纯 node、不开浏览器。

js 复制代码
const LIMIT = {
  chromium: { edgeDraw: 65535, edgePng: 65535, area: 268435456 },
  webkit:   { edgeDraw: 4194303, edgePng: 1000000, area: 268435456 },
  firefox:  { edgeDraw: 65535, edgePng: 65535, area: null },
};
function firefoxFits(w, h) {
  const rowBytes = Math.ceil(w * 4 / 16) * 16;
  return rowBytes * h <= 2 ** 31 - 1;
}
function plan(engine, w, h) {
  const L = LIMIT[engine];
  const edge = Math.max(w, h);
  const areaOk = L.area ? w * h <= L.area : firefoxFits(w, h);
  const draw = areaOk && edge <= L.edgeDraw;
  const result = { engine, size: `${w}x${h}`, draw, png: draw && edge <= L.edgePng };
  if (!result.png) return { ...result, webp: '-', jpeg: '-' };
  if (engine === 'chromium') {
    result.webp = edge > 16383 ? `裁成 ${Math.min(w, 16383)}x${Math.min(h, 16383)}` : 'ok';
    result.jpeg = edge > 65500 ? `裁成 ${Math.min(w, 65500)}x${Math.min(h, 65500)}` : 'ok';
  } else if (engine === 'firefox') {
    result.webp = edge > 16383 ? 'null' : 'ok';
    result.jpeg = edge > 65500 ? 'null' : 'ok';
  } else {
    result.webp = '回落 PNG';
    result.jpeg = edge > 65535 ? 'null' : edge > 65500 ? '写得出,别家打不开' : 'ok';
  }
  return result;
}

拿 8 个常见或刁钻的尺寸过一遍。下面是真实输出的节选。

text 复制代码
10000x10000  chromium  draw true  png true  webp ok                 jpeg ok
10000x10000  webkit    draw true  png true  webp 回落 PNG             jpeg ok
10000x10000  firefox   draw true  png true  webp ok                 jpeg ok
16384x16384  chromium  draw true  png true  webp 裁成 16383x16383     jpeg ok
16384x16384  firefox   draw true  png true  webp null               jpeg ok
16384x16385  chromium  draw false png false webp -                  jpeg -
16384x16385  firefox   draw true  png true  webp null               jpeg ok
20000x20000  webkit    draw false png false webp -                  jpeg -
20000x20000  firefox   draw true  png true  webp null               jpeg ok
40000x2500   chromium  draw true  png true  webp 裁成 16383x2500      jpeg ok
70000x1500   chromium  draw false png false webp -                  jpeg -
70000x1500   webkit    draw true  png true  webp 回落 PNG             jpeg null

代码说明。这个函数是按实测阈值推算的,不是每个格子都实测过。大多数格子和实测对得上。比如 20000² 在 WebKit 上画不了、在 Firefox 上能画。70000×1500 在 WebKit 上能画,导出 JPEG 却回调 null。这几格都实测过。但 70000×1500 在 WebKit 上导出 PNG 那一格我没有直接测。它是拿单边 1,000,000 的线推出来的,用的时候心里要有数。另外它只管画布这一段,解码那一段没写进去;解码的线我只有几个离散的点,没有能写成公式的规则。第一版我把 WebKit 的 JPEG 写成「超过 65500 都写得出」。套到 70000×1500 上才发现和实测的 null 矛盾,这才补上 65535 那条线。表写成代码之后最大的好处就在这儿:它逼你把每条规则的边界说清楚。

八、适用边界与风险提示

这些数只代表我手上这三个内核构建。Chromium 149、WebKit 26.5 构建、Firefox 151 构建都跑在一台 Apple M4、16 GB 的 Mac 上。真 Safari、iOS、安卓、微信内置浏览器和各家正式版都没测;换个大版本面积线和导出格式的行为都可能变。我估计最容易变的是 WebKit 能不能编 WebP,以及 Chromium 导出 WebP 时是裁掉还是改成报错。

上限以内不等于稳。表里给的是「这一步还能成功的最大值」,不是「放心用到这里」。一张 16384² 的画布在 Chromium 上填完就是 1025 MiB,读回一次 2050 MiB。手机和低内存设备扛不扛得住这篇给不了答案。

上限以外的失败大多是静默的 。别指望异常和控制台。能依赖的只有三条:toBlob 是否为 null、读回已知像素是否正确、导出文件的宽高和类型是否符合预期。三条都查了才算查完。

导出 WebP 有裁切风险:在 Chromium 149 上任一边超过 16383 的画布导出 WebP 会被裁掉。文件看起来完全正常。要导出 WebP 的场景我会在导出前先把边长缩到 16383 以内。

「缩略图正常」证明不了原图能处理 :<img> 能解到的尺寸比画布能画的大得多。预览正常只说明解码这一步过了。

九、测量方法自身的坑

这一章是我最想让同行看的。下面几条每一条都让我的第一版脚本给出过看着说得通、其实错的结论。

只判 context 会把什么都判成成功 :第一版探针只看 getContext 是不是 null。结果三个内核在 1.25 亿宽的画布上都「成功」;后来改成真的画、再读回已知像素。读回也要挑位置。WebKit 宽 4,194,304 那档左上和中心都对,只有右下是 0。我现在读三个点,右下角是必读的。

导出非 null 不等于导出对了。Chromium 导出 WebP 时给的 Blob 完好、类型正确,只是宽度被改成了 16383。只看非 null 会判成功。只比宽高又分不出裁掉和缩放。我在画布里画了渐变加末端蓝条:裁掉的话蓝条消失、末列颜色等于原图对应列;缩放的话蓝条会被压窄但还在。这个判据帮我确认了 Chromium 是裁而不是缩。

一个解码器说坏了,不一定是文件坏了 。WebKit 写出的 65535 宽 JPEG,Chromium、Firefox 和 Pillow 都说打不开。sips 和 WebKit 自己却能开;我差点就写成「WebKit 导出的大 JPEG 是坏的」。直到拿 65500 和 65501 两个宽度交叉比,才看清是解码端的边长限制。现在判断一个文件好坏时我至少找两个彼此独立的解码器。

二分查找的前提是单调。每个阈值我都在失败点上面又抽了 +1、+97、×1.5 三档;全部还是失败,这才敢说「从这里往上都不行」。但我给单张画布设了 10 亿像素的安全上限,超过的档一律跳过;「拿 context 从不失败」这句话因此只在 10 亿像素以内成立。

img.decode() 的结果会抖:主体数据那一轮 5000² 在 6 次里 3 次 reject。同一种调用顺序换一个浏览器实例,结果也会变;我这次补跑又撞上了一次:6000² 从「6/6 reject」变成了「4/4 成功」。单跑一次的结论不能用。至少要换实例重复几次。

我收集到的控制台消息不一定是全部。「Chromium 和 Firefox 超限时控制台没提示」这句话严格说是「我抓到的控制台输出里没有」。内核的内部警告确实会出现在这一路里,Firefox 的「Image corrupt」就抓到了。可 DevTools 其他面板里是不是另有信息我没去看。

十、还没解决的

有几件事我到现在也没搞明白。WebKit 构建单边为什么在 4,194,304 这一档变成「左边画上、右边画不上」我不知道。它导出 PNG 的 1,000,000 是从哪来的我也不知道。Chromium 的 img.decode() 为什么对能画的图 reject,reject 的起点为什么会在 6000² 和 8000² 之间挪我没查出来。多大的内存会让浏览器崩掉,我在 7 GiB 和 4 GiB 就停了。这个问题这篇没有答案。

如果你手上正好有一个处理用户大图的页面,我会先照第七章那个函数对一遍自己支持的最大尺寸。再在导出那一步加上第四章的读回检查:toBlob 为 null 就报错,宽高或类型和预期不一样也报错。最后挑一张 20000×20000 的合成图,在要支持的每个浏览器里各跑一次,核对导出的文件到底是什么样。

参考资料

  • MDN:HTMLCanvasElement.toBlob()、HTMLCanvasElement.toDataURL()
  • MDN:CanvasRenderingContext2D.getImageData()、CanvasRenderingContext2D.isContextLost()
  • MDN:OffscreenCanvas.convertToBlob()、HTMLImageElement.decode()、createImageBitmap()
  • libjpeg:jmorecfg.h 中的 JPEG_MAX_DIMENSION 定义(本文只做数值比对、未核对各内核源码)
相关推荐
零基础1231 小时前
LLM Agent 驱动的物模型构建:从设备手册到边缘接入的自动化实践
运维·人工智能·经验分享·python·自动化
这张生成的图像能检测吗1 小时前
(论文速读)LINN:液体神经网络与脉冲神经元融合的可解释旋转机械故障诊断
人工智能·深度学习·神经网络·故障诊断
东方佑1 小时前
v24 (Hybrid2Fast) 架构与实验报告
人工智能·深度学习·语言模型·自然语言处理·架构
阿部多瑞 ABU1 小时前
重复的辩证法:从哲学僵尸到历史唯物主义——论意识、重复与人类解放
人工智能·ai写作
枫叶丹41 小时前
从一次推理请求出发:模型、显存、网络与服务系统如何共同决定性能
网络·人工智能·chatgpt·开源·agent·codex
YangYang9YangYan1 小时前
2027 秋招|应用统计学专业投递快消市场部,岗位 JD 拆解与统计能力落地
人工智能·数据分析
lie..1 小时前
30天从零开始学AI应用开发(Day 17):ChromaDB 上手:给本地文档建一个“外挂大脑”
数据库·人工智能·oracle
deepdata_cn1 小时前
Seedance 2.5如何敲开工厂车间的门
人工智能
一水鉴天1 小时前
差-余-残三词体系:术语定稿与设计方案 20261004(豆包)
人工智能