Same block count, same picture, a 4-fold gap at export
The input behind the 43890 in the title: a synthetic 1080×2400 PNG built in Node (printed with only the fictional tracking number DEMO-A), clientWidth=764, innerHeight=800, preview canvas dw×dh=252×560, scale=0.233333, batched over 4 selections. Dragging the slider to 4 and dragging it to 8 both preview 43890 blocks, and the sample point (20,30) stays [147,123,161]; the export side gives 201613 blocks and 50468 blocks.
The page under test, the image mosaic tool, is 488 lines / 20006 bytes long. It runs two block-width formulas: preview uses Math.max(2, mosaicSize * scale) (L224, fractional, counted in preview pixels), export uses Math.max(2, mosaicSize) (L449, integer, counted in original-image pixels), and between them sit the four Math.floor calls at the applyMosaic entry, L261–262.
The clamp obeys one predicate only
Clamp whenever scale < 2/mosaicSize. This page runs scale=0.233333: slider 4 needs <0.5, slider 6 needs <0.333333 and slider 8 needs <0.25 — all three hold — while slider 16 needs <0.125 and misses. Do not mix the two denominators: of 8 images × 13 stops = 104 combinations, 21 get clamped; of 8 images × 15 stops = 120 combinations, 26 do (21.7%). maxH stays 560 whenever innerHeight ≥ 760, so any original taller than 1120px is clamped at slider 4.
| Image | Clamped stops | Preview-equivalent M |
|---|---|---|
| 1080×2400 | 4…8 (5 stops) | 8.571px |
| 1170×2532 | 4…9 (6 stops) | 9.043px |
| 4032×3024 | 4…10 (7 stops) | 10.800px |
| 1080×4000 | 4…14 (11 stops) | 14.286px |
| 1080×1080 | 0 stops | M always equals N |
Of the four presets only 「细致」 at stop 6 gets eaten; the export side still honors 6px. On a narrow screen at clientWidth=298 the scale of 4032×3024 falls to 0.072917, and 「细致」 plus 「中等」 clamp to the same bs=2.0000, with M at 27.429 for both.
Figure 1: at the same stop the gray bar is shorter than the outlined one.
Three stops crash, and the clamped ones do not
Real template shell page, HeadlessChrome/156, image 1080×2400, dw=252, region (10,20,110,24): mosaicSize=10, 12 and 20 all throw IndexSizeError (source width is 0; N=20 reports source height is 0), while the remaining stops render normally. The root cause is that the four arguments of getImageData are truncated toward zero as WebIDL long, and the fractional tails of bw, 0.33333333333341386, 0.8000000000000824 and 0.6666666666666714, get truncated to 0 in 12, 9 and 1 places respectively.
A different selection may avoid the crash: (60,90,140,210) has 0 truncation sites at N=10 and 12, and stops 4/6/8 never crash at all because bs is pinned to the integer 2. The template try/catch counts 0, and since L165 writes the caption before L166 repaints, the label already reads 20px while the picture stays frozen on the previous grid.
Four floor calls and the leftover column
(10,20,110,24) maps into the original image as 42.857,85.714,471.429,102.857 and floors to 42,85,471,102: a net right-edge error of −1.286px and a bottom-edge error of −1.571px; on the same image (60,90,140,210) → 257,385,600,900 and (100,20,60,40) → 428,85,257,171.
The grid is counted from the top-left corner of the selection, so the last column is usually partial: at bs=16 this selection is 7 columns × 2 rows = 14 blocks with a leftover column 14 wide; sweeping the selection width 1…200 at a fixed bs=16, the average non-zero leftover column is 6.851px.
The 6px test runs on preview pixels
tr.w >= 6 && tr.h >= 6 (L365) compares preview pixels, so the smallest boxable height in the original = 6/scale: 11.571px for 1080×1080, 25.714px for 1080×2400, 32.4px for 4032×3024 and 42.857px for 1080×4000.
Modeling a single line of text: on 1080×2400 an original line height of 18px previews to only 4.2 and 24px to only 5.6 — both stops are silently dropped with no hint, and it takes 30px → 7 before the line fits; on 4032×3024 even 30px → 5.556 is still dropped, needing 40px → 7.407.
Alpha is averaged with the same weight as RGB
The four channels are summed with equal weight (L281–285), and L286 splices a/255 into the fillStyle, which source-over paints on top of a canvas where the original is already drawn (L222, L443): a=128 leaves 0.498039, a=64 leaves 0.749020, a=0 leaves 1.000000. 128 is written into the string as 0.5019607843137255; per-channel Math.round adds at most 0.5 of deviation, 0.196% on a single channel.
The 「导出原图」 branch is one re-encode
Clicking download with 0 selections takes the 「导出原图」 branch; decoding the output back and comparing it channel by channel against the original, all 4665600 values give diffChannels=0 and maxChannelDelta=0, yet the bytes go from 913850 to 163971, only 17.94% left — same pixels, different bytes.
toBlob(mime, 0.92) as measured: 1080×2400 plus 1 selection gives png 70882 / jpeg 320379 / webp 29190; the alpha-bearing 1080×1080 gives png 167624 / jpeg 300531 / webp 245724. The label 「JPG(体积更小)」 is inverted on both noise images (4.520 times and 1.793 times), and jpeg first paints a #ffffff base at L442, flattening alpha to 255.
Separating the arithmetic layer from the browser layer
Grid rounding, leftover-column width, floor offsets, clamp thresholds and alpha compositing come from a pure-Node arithmetic model and have been checked against Chrome 156 stop by stop, 15/15 consistent; the three IndexSizeError stops and the anti-aliasing fringe (alpha=64/32 levels at a 3.5px-wide block edge) are browser measurements. Not measured: byte distribution of real photographs, drawImage resampling artifacts, toBlob returning null, download timing. Which fields to mask is settled in the on-site after-sales post-mortem.
Frequently asked questions
Q: I moved the slider but the picture did not change — why?
Inside scale < 2/mosaicSize the preview block width is pinned to 2, so the nominal 4, 6 and 8 all run the same grid; only the export honors the nominal value.
Q: I dragged a box but nothing got blocked out?
Selections under 6px in the preview are thrown away by L365 as accidental touches; dragging to w=190 h=280 and then switching the window away, blur and touchcancel only set isDragging=false without pushing, so regions stays 0 and no hint appears.