<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lank_M</title>
    <description>The latest articles on DEV Community by Lank_M (@pm_cheng_3f36acecfb9c59f5).</description>
    <link>https://dev.to/pm_cheng_3f36acecfb9c59f5</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4086794%2F931fea88-28cd-4592-bb00-cbf315e37b06.png</url>
      <title>DEV Community: Lank_M</title>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pm_cheng_3f36acecfb9c59f5"/>
    <language>en</language>
    <item>
      <title>A 25 MB video target came back at 14 MB, and the fix is checking how far under you landed</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Tue, 27 Oct 2026 08:31:45 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/a-25-mb-video-target-came-back-at-14-mb-and-the-fix-is-checking-how-far-under-you-landed-1f96</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/a-25-mb-video-target-came-back-at-14-mb-and-the-fix-is-checking-how-far-under-you-landed-1f96</guid>
      <description>&lt;p&gt;I asked x264 for a 25 MB file from a 60-second, 1080p screen recording. It returned 14.0 MB on the single pass and 14.3 MB on the two-pass run. Then I let a quality-based search try as hard as it could, and the smallest it would go down to was still 11.05 MB, with the quality setting pushed almost to the bottom of its scale. The budget was simply bigger than the video needed.&lt;/p&gt;

&lt;p&gt;Most of the target-size math I know is about not going over, and this run is the opposite case. Everything below is a single local run on an M4 Mac: FFmpeg 7.1, libx264 at the medium preset, AAC at 128 kbps, one pass per cell, nothing repeated. Sizes are real bytes, and 1 MB here is 1,000,000 bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The formula was never the hard part
&lt;/h2&gt;

&lt;p&gt;The formula is the familiar one: target size times 8, divided by duration. For 25 MB over 60 seconds that is 3,333,333 bits per second, minus the audio. At 128 kbps, 60 seconds of AAC takes about 0.97 MB, and the MP4 container adds roughly 66 KB. On a 1080p action clip I will call clip A, skipping the audio subtraction put the file 3.1% over at 25 MB and 9.2% over at 10 MB. With the subtraction, single pass landed at -0.66% and -0.35%.&lt;/p&gt;

&lt;p&gt;On clip A the arithmetic works. The open question is a clip where rate control has nothing hard to chew on, so I made clip B: a synthetic screen recording, 60 seconds at 1080p and 30 fps, with a slide-like page, a moving cursor and scrolling, all drawn frame by frame. It is calmer than a real recording, so read everything about B as this one clip, not as screen recordings in general.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the 25 MB budget did on a quiet clip
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Clip B, 25 MB target&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Off target&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single pass, audio subtracted&lt;/td&gt;
&lt;td&gt;14,020,677 bytes&lt;/td&gt;
&lt;td&gt;-43.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two pass, audio subtracted&lt;/td&gt;
&lt;td&gt;14,326,893 bytes&lt;/td&gt;
&lt;td&gt;-42.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CRF search, range 0 to 51&lt;/td&gt;
&lt;td&gt;11,053,804 bytes&lt;/td&gt;
&lt;td&gt;-55.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The CRF row settled it. I bisected the quality value for the largest file that stayed under the target, and it ran down to CRF 0.4 and stopped at 11.05 MB, with no room left to spend. My first attempt used a range of 14 to 51 and stopped at 4,111,034 bytes, because 14 was the lowest value allowed. When a search ends on the edge of its range, the answer is the range, not the video.&lt;/p&gt;

&lt;p&gt;None of this is a bug, since the encoder simply spent less than it was given. The gap is in the wrapper that says "25 MB target, done": it cannot tell you that you were sitting at 56% of the budget (14.0 MB of 25 MB) and never needed the rest, or that the full 1080p frame size fit with room to spare.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same clip at 10 MB is a different story
&lt;/h2&gt;

&lt;p&gt;Drop the target to 10 MB and the budget is tight again, so the same clip B behaves differently. Single pass with audio subtracted came back 8.65% under, the CRF search 1.6% under (CRF 1.99), and two pass 11,195,510 bytes, which is 12.0% over. On clip B the video bitrate swings from 40 to 5,000 kbps, with sharp spikes where the page changes. My reading, which I did not verify, is that two pass plans around those spikes and ends up too generous.&lt;/p&gt;

&lt;p&gt;So a bigger budget is not the only way to miss. Two pass was also the only method that went over target on clip A at both sizes, and CRF bisection never overshot but costs a full encode per trial: 157 to 217 seconds for seven trials on clip A, against about 23 seconds for a single pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do with a budget
&lt;/h2&gt;

&lt;p&gt;Treat the target as a ceiling rather than a goal. Subtract the audio and about 70 KB of container first, run one fast pass, and look at the size. If it came back near half of the target, keep that file or spend the spare room on resolution. Whether a hard rate cap such as a maximum bitrate would keep a two-pass run from overshooting on a clip like B, I have not tested.&lt;/p&gt;

&lt;p&gt;One note on tools that wrap all this. ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) runs in a desktop browser and has no "compress to this size" setting; it works from presets. Its interface also counts a "MB" as 1,048,576 bytes, so a "25 MB" there is about 4.9% more bytes than the figure I used. On clip B its smart recommendation was accepted and wrote 9,351,418 bytes, while on clip A the same feature rejected its own candidate because the saving was 10.3%, under its 12% bar. The hosted 8mb.video does have a size picker, and for clip B it returned 7,550,319 bytes when I chose 25 MB, at 1280x720. A picker tells you the ceiling, not whether the video needed it, so the under-target check above is still yours to do.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Table structure after OCR: why I'd rather get word boxes than tab-separated text</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Mon, 26 Oct 2026 08:04:17 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/table-structure-after-ocr-why-id-rather-get-word-boxes-than-tab-separated-text-hdp</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/table-structure-after-ocr-why-id-rather-get-word-boxes-than-tab-separated-text-hdp</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwmsc1npyxggrz99p9gyz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwmsc1npyxggrz99p9gyz.png" alt=" " width="799" height="238"&gt;&lt;/a&gt;&lt;br&gt;
Should an OCR engine hand back a table, or just word boxes and let the caller build the table? OCR is not my area, so I read it as a caller would: I cut columns out of one set of word boxes through three interfaces. Short version: I'd rather get boxes. Setup: tesseract 5.5.3, English pack, three clean tables I generated (63, 63 and 45 cells), and one browser OCR tool. Every number below belongs to those samples.&lt;/p&gt;
&lt;h2&gt;
  
  
  Plain text has already lost the columns
&lt;/h2&gt;

&lt;p&gt;First interface: text only. I grouped tesseract's boxes into rows, joined each row's words with single spaces, and split on whitespace. Cell accuracy was 16%, 33% and 84% on the three tables. Cells here contain spaces: &lt;code&gt;Steel Thermos&lt;/code&gt;, &lt;code&gt;Power Bank&lt;/code&gt;, a full name, a report title.&lt;/p&gt;

&lt;p&gt;Second interface: the engine inserts a tab wherever the space between two words exceeds 1.0 times the median word height, and the caller splits on tabs. That gave 84%, 100% and 91%, the same as cutting the boxes myself at that threshold, because it is the same decision.&lt;/p&gt;
&lt;h2&gt;
  
  
  A tab freezes a threshold the caller can't move
&lt;/h2&gt;

&lt;p&gt;The difference shows up when the caller can move the threshold. With boxes in hand I swept it from 0.4 to 2.0 times word height, scoring each setting against the ground-truth cells.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cells&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;cut_row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;med_h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;truth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the bordered score table, 0.6 gave 100% against 84% at 1.0. On the borderless quote table, 0.6 to 1.2 gave 100% and 1.5 dropped to 11%. The merged-header table sat at 91% across most of the range. The 1.0 was fixed in advance and 0.6 turned up afterwards on the same samples, so it is no recommended value. It only shows that a fixed number costs something on some tables, and behind a tab-only interface nobody finds out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same tool flipped which separator works
&lt;/h2&gt;

&lt;p&gt;For the browser tool I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) on a desktop, the default Professional model, and copied out the text. On the three Chinese tables all 171 cells came back right, and rows mostly came back right too: 9 lines for a 9-row table on five of the six clear images and 10 on the sixth, with no tuning on my side.&lt;/p&gt;

&lt;p&gt;Columns were looser. The score and quote tables have 7 columns, and tabs split most rows into only 2 to 6 cells. I rescored four of the tables by splitting the text two ways.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table (same data, two languages)&lt;/th&gt;
&lt;th&gt;Split on tab only&lt;/th&gt;
&lt;th&gt;Split on tab or space&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Score table, Chinese&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;td&gt;94%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Score table, English&lt;/td&gt;
&lt;td&gt;43%&lt;/td&gt;
&lt;td&gt;16%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quote sheet, Chinese&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;td&gt;84%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quote sheet, English&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;td&gt;24%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The better rule flips between the Chinese and English version of the score table. A script that consumes this text has to pick a rule before it sees the table. That doesn't make it worse than tesseract, since the two work differently, and I can't see why the separators fall where they do. I only looked at its copy and TXT output, so what I say is about that channel. It runs only in a desktop browser, doesn't read Korean, and numbers still need checking by eye.&lt;/p&gt;

&lt;h2&gt;
  
  
  What only the pixels know
&lt;/h2&gt;

&lt;p&gt;Boxes don't contain everything. Ruling lines live in the pixels. Run straight on the bordered score table, tesseract found 1 of 63 cell texts; after I painted the lines white, 62 of 63. For a photo of a table taken at an angle, rotating the image was not enough on the borderless sheet: 0% to 11% after rotation, 86% to 92% after rotation plus a perspective fix. Those photos are my simulation, not real shots. The same simulated shots came out of the browser tool as one scrambled line each, and its page offers only 90-degree rotation, so I had no way to give it a hint.&lt;/p&gt;

&lt;p&gt;That points to two stages. Pixel evidence such as lines and perspective belongs before recognition. Geometry from boxes belongs after it, where the code can see every box at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Slope is a number you get from all the boxes
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="Word-box centres clustered into rows on a straight sheet, a 2-degree tilt with a 0.5x word-height tolerance, and the same tilt after a slope correction" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the middle panel the right end of each row has drifted from the left end, so the same row is cut in two and 9 rows become 18. The right panel subtracts an estimated slope from every y first, and 9 rows come back.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd ask an engine to return
&lt;/h2&gt;

&lt;p&gt;Boxes with confidence in input pixels, word height, and the skew estimate if it has one. Structure assembly then runs where thresholds can be tuned and failures told apart: a header glued into one string might be a recognition or a gap problem, and text can't say which.&lt;/p&gt;

&lt;p&gt;I haven't tested a learned table-structure model, which could use cues that boxes lose. The samples are clean, English-only for tesseract, and no spreadsheet program was involved.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why PDF image extractors return the stored picture, not the one on the page</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Sun, 25 Oct 2026 08:01:52 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/why-pdf-image-extractors-return-the-stored-picture-not-the-one-on-the-page-418c</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/why-pdf-image-extractors-return-the-stored-picture-not-the-one-on-the-page-418c</guid>
      <description>&lt;p&gt;Every extractor I pointed at my test PDF gave me back a rotated picture unrotated and a half-hidden picture whole. After three years of codec work I took that as the correct answer, but I wanted to be able to say why.&lt;/p&gt;

&lt;p&gt;The file is one hand-written page with four lossless images. Each has four coloured corners and a big letter, so I can see which way it faces. A is hidden by a clip path, so the page shows only its left half. B is turned 90° counter-clockwise by the placement matrix. C is mirrored. D is a 16-colour palette image with &lt;code&gt;/Decode [15 0]&lt;/code&gt;, which swaps palette entries 0 and 15. I ran pdfimages 26.08.0, PyMuPDF 1.26.5, the decoded bitmap from pdf.js 4.10.38 in Chromium 149 (pdf.js has no extract call), and ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) in a desktop browser. One file, default settings. Sizes are pixels; pt is 1/72 inch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three tools stopped at the same place
&lt;/h2&gt;

&lt;p&gt;On A, B and C, pdfimages, PyMuPDF and the pdf.js bitmap returned the stored pixels, the same SHA-256 on every tool: the whole 240×160 image for A, the unrotated one for B, the unmirrored one for C. D went differently. Reading its palette indexes as stored gives 566fb92e549e, yet all three tools returned the page's colours, 4b16acc5dcfa. They applied &lt;code&gt;/Decode&lt;/code&gt; and ignored the clip and the matrix.&lt;/p&gt;

&lt;p&gt;My reading is that it depends on where the instruction lives. &lt;code&gt;/Decode&lt;/code&gt; sits in the image's own dictionary, beside its colour space. The clip (&lt;code&gt;re W n&lt;/code&gt;) and the matrix (&lt;code&gt;cm&lt;/code&gt;) sit in the page's content stream, around the operator that paints the image. The tools seem to decode the object and stop where the page starts placing it. That is a reading of four images, nothing I found documented.&lt;/p&gt;

&lt;h2&gt;
  
  
  One stored image can sit on a page four times
&lt;/h2&gt;

&lt;p&gt;The strongest argument came from a second file: one image object painted four times, each under a different clip (a rectangle, a triangle, a circle, and a box that never touches it).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;
&lt;span class="n"&gt;pdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clip-shapes.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;pg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_images&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;drawn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_image_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xrefs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;cuts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_drawings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;extended&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stored,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;drawn&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;drawn,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cuts&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clips&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 stored, 4 drawn, 4 clips
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pdfimages -list&lt;/code&gt; prints four rows for that page, all object 5. The page shows 50%, 50%, 37% and 0% of the picture. An extractor that returned the visible part would have to return four images from one object, or quietly pick one. Returning the object is the only answer that doesn't depend on which placement you mean.&lt;/p&gt;

&lt;p&gt;A 10-page Creative Commons newsletter (CC BY 3.0, via Wikimedia Commons) shows it from the other side: 36 rows in &lt;code&gt;pdfimages -list&lt;/code&gt;, 32 distinct images, since one banner is painted on pages 2, 4 and 6. ImgIng returned 32 results.&lt;/p&gt;

&lt;h2&gt;
  
  
  The visible part would cost a dpi and an alpha channel
&lt;/h2&gt;

&lt;p&gt;On A, the clip is 90 pt wide and the picture is drawn 180 pt wide for 240 pixels, so it is stored at 96 pixels per inch and the visible part is the left 120 columns. Cropped to 120×160, it matches a 96 dpi render of the page with an average error of 0.00 on a 0 to 255 scale. At 72, 150, 300 and 600 dpi the error is 0.98 to 2.51, from resampling. "What the page shows" depends on a dpi somebody has to choose, and extraction has none to choose.&lt;/p&gt;

&lt;p&gt;The shape is the second cost: the circle exposes 37% of the picture, so a visible-part result would need an alpha channel. PyMuPDF reports each clip as a bounding box (&lt;code&gt;scissor&lt;/code&gt;), and estimating from that gave 100% for the triangle (truth 50%) and 46% for the circle (truth 37%). Only rectangles came out right.&lt;/p&gt;

&lt;p&gt;I don't know how these tools were designed, so this is an inference from the behaviour.&lt;/p&gt;

&lt;h2&gt;
  
  
  One image where ImgIng and the page disagree
&lt;/h2&gt;

&lt;p&gt;On A, B and C, ImgIng returned the same pixels as the other three. On D it returned the stored palette, 566fb92e549e, so its colours are reversed against the page. A grey image and an RGB image carrying &lt;code&gt;/Decode&lt;/code&gt; in a second file did match the page, which narrows the gap to a palette plus &lt;code&gt;/Decode&lt;/code&gt;, in the 4-bit &lt;code&gt;[15 0]&lt;/code&gt; form only; I haven't run the 8-bit &lt;code&gt;[255 0]&lt;/code&gt;. My guess is that its palette path never reads &lt;code&gt;/Decode&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One PDF per run, 0 results for a page with no images, ZIP entries named by number.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do with it
&lt;/h2&gt;

&lt;p&gt;An extractor answers "which image objects does this file store". For the picture a reader sees, I read the placement (&lt;code&gt;transform&lt;/code&gt; and &lt;code&gt;clip&lt;/code&gt; in PyMuPDF, the matrix and clip operators in pdf.js) or render the page at a dpi I choose and crop.&lt;/p&gt;

&lt;p&gt;What I can't answer is how often real files hide half a picture. A two-page public brochure has 9 placements and 20 clip entries, but I never matched clips to images.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why a JPEG white background isn't 255: blocks, quantization tables and chroma subsampling</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Sat, 24 Oct 2026 08:01:37 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/why-a-jpeg-white-background-isnt-255-blocks-quantization-tables-and-chroma-subsampling-5d6d</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/why-a-jpeg-white-background-isnt-255-blocks-quantization-tables-and-chroma-subsampling-5d6d</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft30p4bpf59f1r9iiiy5t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft30p4bpf59f1r9iiiy5t.png" alt=" " width="800" height="803"&gt;&lt;/a&gt;&lt;br&gt;
I wanted to know why a white background saved as JPEG stops being exactly 255. On one sneaker photo the drift split three ways. Quantization steps move even a flat white image, but only at low quality. At normal quality the drift lives in the blocks an edge crosses. And 4:2:0 chroma adds a coloured share. Amazon's US seller guide (viewed 2026-10-23) asks for RGB 255, 255, 255, so the last digit matters to someone.&lt;/p&gt;

&lt;p&gt;The photo is a Wikimedia Commons shot of white sneakers on beige tile (JustinLiang522, CC BY-SA 4.0). I made the white PNG in ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) in a desktop browser: AI cutout, background set to white, exported at 1,280×1,707. I did not look into how it composites, only at what it exported. The export row lists WebP, PNG, AVIF, TIFF and GIF with no JPEG, so every JPEG below comes from Pillow 11.3.0 and numpy 2.0.2 on an Apple M4. "Background" means the 55.2% of pixels the cutout left transparent, all exactly 255 in the PNG.&lt;/p&gt;
&lt;h2&gt;
  
  
  Flat white only drifts at low quality
&lt;/h2&gt;

&lt;p&gt;JPEG cuts the image into 8×8 blocks, turns each into 64 coefficients, divides every coefficient by an entry of a quantization table and rounds. The luma table's first row at quality 85 is 5, 3, 3, 5, 7, 12, 15, 18, with a largest entry of 36.&lt;/p&gt;

&lt;p&gt;A flat white block has a single nonzero coefficient, the DC term, 8×(255−128) = 1016. At quality 85 that is divided by 5 and stored as 203, which by my arithmetic (not the decoder's own path) decodes to 254.875 and rounds back to 255. I swept a flat white 64×64 image over quality 1 to 100. Every setting from 46 to 100 came back all 255, while 20 of the 45 settings below 46 moved every pixel, to 254 in 14 of them and down to 248 at quality 5.&lt;/p&gt;
&lt;h2&gt;
  
  
  At normal quality the drift lives in edge blocks
&lt;/h2&gt;

&lt;p&gt;Above that line a flat area is safe, so drift needs detail, and the simplest detail is one hard edge. In a 48×16 grey image, white left and black right, I slid the edge across the block spanning x = 24 to 31 and counted white-side pixels that were not 255. The second number counts those left of the edge block.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;strays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;85&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;full&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uint8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="n"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JPEG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;back&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;))[:,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;back&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;back&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;strays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;off&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
&lt;span class="c1"&gt;# [(0, 0), (0, 0), (0, 0), (16, 0), (16, 0), (32, 0), (32, 0), (16, 0)]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An edge sitting on the block boundary leaves the white side alone, an edge inside the block cost 0 to 32 pixels at quality 85 depending on where it fell, and nothing outside the edge block ever moved. The real photo shows the same pattern at scale. At quality 85 with 4:2:0, 11.58% of the background pixels within 8 pixels of the shoe drifted, against 0.0162% beyond that.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="Heatmap of background pixels that are not 255 after saving the white-background sneaker photo as JPEG at quality 85, with a 4x zoom of one edge" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the zoomed pair on the right, the red specks form a ragged strip hugging the dark edge and the white further out has none; the left panel shows it following the whole outline. Photo by JustinLiang522, Wikimedia Commons, CC BY-SA 4.0, adapted.&lt;/p&gt;

&lt;h2&gt;
  
  
  White is a ceiling that hides most of it
&lt;/h2&gt;

&lt;p&gt;Ringing swings both ways around an edge, but a decoder clamps at 255. To see how much that hides, I repainted the sneaker's background at a flat grey level and re-encoded at quality 85. At 255, 7,916 background pixels changed and none went up, by construction. At 200, 27,894 changed, 15,263 with a channel above 200 and 14,460 with one below, and a pixel can be in both groups. Grey 128 gave 28,392. White shows about 28% of the drift a mid-tone background would.&lt;/p&gt;

&lt;h2&gt;
  
  
  4:2:0 adds a coloured share
&lt;/h2&gt;

&lt;p&gt;JPEG stores colour as two chroma planes. At 4:2:0 each chroma sample covers 2×2 pixels, so a chroma block spans 16×16 pixels. Of the 7,916 strays at 4:2:0, 6,284 are coloured, meaning R, G and B differ, like 254, 255, 253. At 4:4:4 the total falls to 3,501 and the coloured part to 1,458. The farthest stray sat 13 pixels from the subject at 4:2:0 and 6 at 4:4:4. That fits a luma block reaching at most 7 pixels and a chroma block up to 15, but I only counted, so the reach is my reading and not confirmed in the encoder.&lt;/p&gt;

&lt;p&gt;A neutral grey square on white drifted by 250 pixels in both modes and none of them were coloured, so in that toy case chroma changed nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I could not explain
&lt;/h2&gt;

&lt;p&gt;Grey-only strays rose from 1,632 to 2,043 when I switched to 4:4:4, and I have not traced why. An orange square on white drifted by 674 pixels at 4:2:0 and 575 at 4:4:4, nowhere near the photo's halving, so my toy case does not reproduce the effect.&lt;/p&gt;

&lt;p&gt;Everything here is one photo and a few synthetic images through one Pillow build. Nothing took the sneaker background to 100%: quality 100 with 4:4:4 still left 0.052%. To check a pipeline I would count strays inside the 8-pixel band and beyond it separately: the far background should stay near 0.0162%, and the band is where a change of encoder shows up.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Budgeting browser memory for a 300-photo batch: the ZIP step added 830 MiB at the very end</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Fri, 23 Oct 2026 08:01:53 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/budgeting-browser-memory-for-a-300-photo-batch-the-zip-step-added-830-mib-at-the-very-end-12lm</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/budgeting-browser-memory-for-a-300-photo-batch-the-zip-step-added-830-mib-at-the-very-end-12lm</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5l9vtjorklkce8dbljjd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5l9vtjorklkce8dbljjd.png" alt=" " width="800" height="448"&gt;&lt;/a&gt;&lt;br&gt;
I wanted a written memory budget for a page that re-encodes every photo in a folder before anyone on the team picks a save strategy. Most lines of it were easy. One only shows up in the last two seconds of the run, and one number I almost copied into it turned out to be the browser starting up. The numbers below come from desktop Chromium 149 on a 16 GB Mac and 300 synthetic 3024×4032 JPEGs in three nested folders. Every photo goes through &lt;code&gt;createImageBitmap&lt;/code&gt;, a same-size &lt;code&gt;OffscreenCanvas&lt;/code&gt; and &lt;code&gt;convertToBlob&lt;/code&gt; as JPEG at 0.8. Memory means all browser processes added together, and MiB means 1024×1024 bytes.&lt;/p&gt;
&lt;h2&gt;
  
  
  The pixel line is the easy one
&lt;/h2&gt;

&lt;p&gt;A decoded 3024×4032 photo is width × height × 4 bytes, 46.5 MiB. Decoding the whole folder first made that line grow with the folder (5,192 MiB at 100 photos; the 300-photo run was at 14,382 MiB 3.3 seconds in and I stopped it). A loop that releases the bitmap and zeroes the canvas before the next file keeps it at one photo's worth. That much is arithmetic. The two lines below took reading the samples.&lt;/p&gt;
&lt;h2&gt;
  
  
  The peak column was measuring startup
&lt;/h2&gt;

&lt;p&gt;My summary table gave peaks of 668 MiB for the one-at-a-time loop, 653 MiB for writing each result as it finished, and 1,375 MiB for keeping all 300 results and zipping them at the end. Then I read the raw samples, taken about every 1.2 seconds rather than the 1 second I'd assumed. The 668 and the 653 were both the very first sample, 0.05 seconds in, before the loop had touched a photo. I haven't worked out what that startup memory is. It shows up in runs that do different work afterwards, so I don't charge it to the loop. During the loop, the version that throws results away sat between 111 and 192 MiB.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="Total browser memory over about 25 seconds for four ways of processing the same 300 photos one by one" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The high points at t≈0 are that startup sample, so read from the second sample on. Blue, the discard version, stays low and flat. Green, write each file now, climbs during the loop. Orange, keep everything for a ZIP, follows green almost exactly until the last two samples, then jumps. Purple is the same loop inside a Worker; its spike at about 1.2 s is a single sample I haven't traced. The test images are my own synthetic files.&lt;/p&gt;
&lt;h2&gt;
  
  
  Held results add about 1.43 MiB per photo
&lt;/h2&gt;

&lt;p&gt;The 300 re-encoded JPEGs total 448,872,129 bytes, an average of 1.43 MiB each, and a ZIP built at the end needs every one of them alive until the end. To make that line visible I gave the loop a save step that reports what it still holds (&lt;code&gt;mib&lt;/code&gt; formats bytes, &lt;code&gt;kept&lt;/code&gt; is a plain array):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processWithLedger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fileList&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sink&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;held&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;photo&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;fileList&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rgbaBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// costs this whatever the file size&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scratch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OffscreenCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;scratch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;scratch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convertToBlob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;scratch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;scratch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;held&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                     &lt;span class="c1"&gt;// bytes the sink still holds&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;decoded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;mib&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rgbaBytes&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;held&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;mib&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;held&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keepForZip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six of the test photos through &lt;code&gt;keepForZip&lt;/code&gt;, first and last lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IMG_0001.jpg decoded 46.5 MiB held 1.5 MiB
IMG_0006.jpg decoded 46.5 MiB held 8.5 MiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A second sink that writes each JPEG and returns 0 printed &lt;code&gt;held 0.0 MiB&lt;/code&gt; on all six lines. The chart doesn't agree. In the 300-photo run that green line climbed from about 175 to 563 MiB, while the JS heap stayed near 2 MiB. The folder picker couldn't open in my test setup, so those writes went to the browser's origin private file system (OPFS) through &lt;code&gt;createWritable()&lt;/code&gt;, the same call a real tool makes on a folder from &lt;code&gt;showDirectoryPicker()&lt;/code&gt;. My guess is that the browser buffers the writes outside the heap. I haven't found where.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ZIP step costs one to two archives at the end
&lt;/h2&gt;

&lt;p&gt;In its last 2.4 seconds the ZIP run went 545 → 845 → 1,375 MiB, so 830 MiB on top of the loop, against a 428.1 MiB archive. The ZIP writer is my own store-only one, no compression. My guess is that the kept parts and the finished archive were alive together. The 100-photo run doesn't fit cleanly, though: its final jump was 163 MiB against a 142.2 MiB archive. At 1.2-second sampling both peaks could be higher, so I budget the ZIP step at one to two times the output size, growing with photo count rather than concurrency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The budget I write down now
&lt;/h2&gt;

&lt;p&gt;Startup, plus the photo in flight, plus, on a ZIP path, every output held through the loop and one to two archives' worth at the end. Writing as you go isn't free either: the OPFS run crept up to 563 MiB, it just never jumped. A Worker doesn't change the arithmetic. It dropped the main thread's worst timer delay from 8.5 to 2.6 ms, and apart from that one untraced 856 MiB sample about 1.2 s in, it ran at 160–229 MiB, the same range as the main-thread loop. According to MDN's compatibility data (2026-10-15), Firefox and Safari have no &lt;code&gt;showDirectoryPicker&lt;/code&gt;, so on those browsers a page has to hand results back as downloads, one by one or as an archive.&lt;/p&gt;

&lt;p&gt;As an outside reference I use ImgIng's batch compressor (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;). Watching it on 100 of these photos in Chromium 149, it saved each one into a new folder before starting the next, and in the WebKit and Firefox engine builds I had, not the release browsers, its save option offered only one-by-one downloads. Before shipping a ZIP fallback, run it on the largest folder you expect and read the samples from the final seconds, not the peak column.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>In a frame-diffed GIF, ordered dither cost 15% more bytes and Floyd–Steinberg cost 91%</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Thu, 22 Oct 2026 08:02:41 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/in-a-frame-diffed-gif-ordered-dither-cost-15-more-bytes-and-floyd-steinberg-cost-91-4om9</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/in-a-frame-diffed-gif-ordered-dither-cost-15-more-bytes-and-floyd-steinberg-cost-91-4om9</guid>
      <description>&lt;p&gt;My first job was three years of codec work at an audio/video SDK company, and the rule I took from it is blunt: any stage that makes a still pixel come out differently in the next frame is a bug for inter-frame coding. Dithering a GIF can be that kind of stage. I'd also read that frame differencing halves a GIF. So I measured both claims on one real clip, and the answer depended on which dither I used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frame differencing saved nothing until I allowed loss
&lt;/h2&gt;

&lt;p&gt;The clip is a cat flicking its tail, 320×240, 208 frames, a low-light phone video turned into a 3,728,810-byte GIF (Mateus2019 / Wikimedia Commons, CC BY-SA 4.0). I re-encoded it in Python 3.9 with Pillow 11.3 on an M4 Mac, using one shared 255-color palette and three layouts: full frames, change rectangles, and rectangles with unchanged pixels made transparent, keeping the smaller per frame. Sensor noise kills the lossless case. Only 13.7% of pixels keep their palette index from one frame to the next, and the best layout came out at 100% of full frames. Everything below uses a tolerance of 16: any pixel within 16 levels per channel of the previous frame copies it. That is lossy, and it dropped PSNR against the source, sampled on every fourth frame, from 44.53 dB to 33.00 dB. In exchange, 86.3% of pixels became identical and the diffed file fell to 25.2% of full frames at the same tolerance (about 43% of the lossless full-frame file, since tolerance also inflates the full-frame baseline). My LZW encoder is worse than the one that made the original file, so I only compare ratios within my own runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Floyd–Steinberg nearly doubled the diffed file
&lt;/h2&gt;

&lt;p&gt;Dither is a switch that browser tools expose too. Before running my own encoder I opened the clip in ImgIng's animation workshop (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) in desktop Chromium 149. With GIF output selected, its dither setting offers Floyd–Steinberg or off, off by default, next to a frame-diff versus full-frame option. How it orders dithering against the diff I can't tell from the outside. The workshop runs only in desktop browsers, and Safari can't use it to compress an existing animated GIF. In my own runs I kept the palette and tolerance fixed and changed only the dither, quantizing with Pillow's &lt;code&gt;quantize(palette=...)&lt;/code&gt;. Without dither the frame-diffed GIF was 3,138,114 bytes. Floyd–Steinberg made it 6,004,213, which is 91% bigger. An 8×8 ordered (Bayer) dither made it 3,597,688, only 15% bigger. At tolerance 8 the Floyd–Steinberg penalty was 44%.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="Left: share of pixels identical to the previous frame for no dither, ordered and Floyd–Steinberg. Right: full-frame and frame-diff sizes in MB for each" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the left panel first. Ordered dither keeps exactly as many identical pixels as no dither (86.3%), while Floyd–Steinberg drops to 73.8%, and the right panel's blue bars follow that loss. Sizes are in MB of 1024×1024 bytes. Chart from my runs on the cat GIF by Mateus2019 / Wikimedia Commons, CC BY-SA 4.0; the chart is shared under the same license.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error diffusion moves pixels that did not change
&lt;/h2&gt;

&lt;p&gt;Ordered dither adds a threshold that depends only on pixel position, so the same color at the same spot gets the same index every frame. Floyd–Steinberg pushes each pixel's rounding error right and down, so a pixel's index also depends on everything scanned before it, as long as there is rounding error to push. I measured that directly. In the loop below, &lt;code&gt;stable&lt;/code&gt; holds the frames after the tolerance-16 freeze and &lt;code&gt;to_index&lt;/code&gt; maps one frame onto the shared palette with the given dither.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ordered&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;to_index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stable&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;flipped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stable&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:],&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]):&lt;/span&gt;
        &lt;span class="n"&gt;still&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# RGB identical to the previous frame
&lt;/span&gt;        &lt;span class="n"&gt;flipped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;still&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;still&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; still pixels whose index changed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flipped&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;5.1&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Averaged per frame, it printed 0.0% for no dither, 0.0% for ordered and 14.6% for Floyd–Steinberg. Then I nudged one pixel of frame 60 by 6 levels. Without dither or with ordered dither, one index changed. With Floyd–Steinberg, 65 changed, spread from x 34 to 54 and from that row down to y 92, never above it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgjhnazn23da1ow1ewhf5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgjhnazn23da1ow1ewhf5.png" alt="Changed-pixel maps for frames 59 to 60 at tolerance 16, white meaning changed: left no dither, middle ordered dither, right Floyd–Steinberg" width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The large white shape is the part of the cat that actually moved, and it looks the same in all three panels. Compare the background: left and middle are mostly black, while the right panel is covered in white specks where nothing moved. Derived from the cat GIF by Mateus2019 / Wikimedia Commons, CC BY-SA 4.0, shared under the same license.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ordered dither pays in image quality
&lt;/h2&gt;

&lt;p&gt;At tolerance 16, PSNR was 33.00 dB without dither, 33.11 dB with Floyd–Steinberg and 27.78 dB with ordered. Its full-frame file was also the largest of the three, so the Bayer pattern costs bytes in every frame and frame diffing only hides that. I set its amplitude to 32 levels and never tuned it; a weaker pattern would likely win some PSNR back, but I haven't run it. I also can't explain why, on the unfrozen frame, the one-pixel nudge spread to only 4 pixels under Floyd–Steinberg.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dither has to stay stable before diffing
&lt;/h2&gt;

&lt;p&gt;One condition sits under all of the Floyd–Steinberg numbers. This clip turns out to use only 254 distinct colors, so my 255-color palette covers every one of them. When I mapped colors with an exact nearest-color search, Floyd–Steinberg had no error to spread and produced the same bytes as no dither. The 73.8% and +91% above come from Pillow's quantizer with this palette, which I assume picks colors approximately; I haven't read its source. So the penalty shows up when the palette can't hold the source colors or the color lookup itself is inexact, which I'd expect to be common with real footage in 256 colors, but it isn't automatic.&lt;/p&gt;

&lt;p&gt;In my pipeline I now treat dithering as part of frame-stability design, decided before the diff stage. Noisy footage gets no dither or ordered dither, and Floyd–Steinberg stays on only for static images where nothing gets reused. Off by default, as in the workshop above, is the setting I would pick for noisy clips too. For your own encoder, run the still-pixel check above on two frozen frames; anything above 0% means some stage is not frame-stable.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The halo around a blurred video background is the dark-fringe bug from texture filtering</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Wed, 21 Oct 2026 08:02:48 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/the-halo-around-a-blurred-video-background-is-the-dark-fringe-bug-from-texture-filtering-1pb7</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/the-halo-around-a-blurred-video-background-is-the-dark-fringe-bug-from-texture-filtering-1pb7</guid>
      <description>&lt;p&gt;I zoomed into a blurred-background export from a browser video matting tool and found a dark ring around the speaker's head. My first suspect was the matte, and it turned out not to be the cause. At the graphics-tool company I worked at before, dark fringes around resized transparent PNG icons came in as bug reports every few months. This ring is the same bug in a different place: a blur filter is averaging pixels that belong to another layer. Both standard fixes from texture work carry over, and on one clip I measured how far each gets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ring is the person blurred into the background
&lt;/h2&gt;

&lt;p&gt;I used ImgIng's video matting (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) for the export, a Beta that runs in desktop Chrome or Edge and not on phones. I only saw its output, not its code. The clip is an 8-second, 478×850 sign language video from Wikimedia Commons (Sayvhior, CC BY-SA 4.0, signing by JudithNG12), one woman in a headscarf before a light curtain. I exported it with blur strength 18 (the default) and 40 in Chromium 149 on a desktop machine that had no GPU acceleration. In its blur-original-background mode the ring appears because what gets blurred is the original frame with the person still in it, so the dark of her clothes and skin spreads past her outline. To put numbers on it I compared brightness in rings outside her edge, on four frames, against an empty-curtain reference rebuilt from the clip itself; it covers 62.5% of the frame. Right at her edge, in the first 4 px, the background read 67.8 grey levels (0 to 255) darker than it should at strength 18, and 63.1 darker at 40. The visible part stretched about 16 to 32 px out at 18 and beyond 32 px at 40.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filtered transparent textures get the same dark fringe
&lt;/h2&gt;

&lt;p&gt;Take a sprite stored with straight alpha, where the fully transparent pixels still hold some colour, usually black. Scale it and the filter averages those invisible black pixels into the edge, leaving a dark outline that was never in the artwork. Background blur does the same thing with the layers swapped. The kernel should only see background, but it reaches across her edge and pulls her colour in. Real-time depth of field hits it too, where a sharp foreground leaks into the blurred background unless samples are weighted by depth. Texture pipelines settled on two fixes: premultiply, so excluded pixels carry zero weight, or bleed the edge colours outward into the empty area before filtering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Both texture fixes work on video frames
&lt;/h2&gt;

&lt;p&gt;I reproduced both with OpenCV on the same frames, taking the matte from a transparent export of the clip (WebM only in that tool). Here is the core, trimmed. It's a generic compositing order, not a feature of the tool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# r is the blur strength, 18 or 40
&lt;/span&gt;&lt;span class="n"&gt;keep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;matte&lt;/span&gt;                                  &lt;span class="c1"&gt;# how much of each pixel is background
&lt;/span&gt;&lt;span class="n"&gt;smear&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GaussianBlur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# blur everything, person included
&lt;/span&gt;&lt;span class="n"&gt;num&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GaussianBlur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;[...,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;den&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GaussianBlur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)[...,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;weighted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;num&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maximum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;den&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1e-3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# premultiplied-style blur
&lt;/span&gt;&lt;span class="n"&gt;grow4&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getStructuringElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MORPH_ELLIPSE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;fill_zone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dilate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;matte&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uint8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;grow4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# fill area grown 4 px
&lt;/span&gt;&lt;span class="n"&gt;plate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GaussianBlur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inpaint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img_u8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fill_zone&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INPAINT_TELEA&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;matte&lt;/span&gt;&lt;span class="p"&gt;[...,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;plate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;[...,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;     &lt;span class="c1"&gt;# sharp person on top
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The weighted version is premultiplication written out: blur colour times weight, blur the weight, divide. Averaged over the four frames on the original matte, it left −0.1 to −1.6 grey levels across the rings at strength 18, and −1.3 to −3.3 at 40. Filling first, which is edge bleeding by inpainting, landed within ±1 at both strengths. I haven't pinned down where the weighted version's leftover comes from; at 40 it is still a few levels, which I would not ship as a regression baseline without knowing why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filling has to reach past the matte
&lt;/h2&gt;

&lt;p&gt;Texture bleeding runs a few pixels past the visible edge, and the fill here needs that margin too. With a matte shrunk 2 px to a hard edge and the fill area taken straight from it, her outer 2 px stayed in the background, got blurred, and at strength 18 the 4 to 16 px rings came back at −14.1. Growing the fill 4 px past the matte brought them to +0.3. The plain blur-everything version sat at −15.4 on the original matte. Filling does cost an inpainting pass on every frame, which the weighted blur avoids.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="Bars of the 4 to 16 px brightness difference at strength 18 for four matte edges: blur everything, fill taken straight from the matte, fill grown 4 px" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the last group first: with the hard edge, the fill taken straight from the matte sinks almost as low as blurring everything, while the grown fill sits back at zero. Data: my own measurements on this clip, four frames.&lt;/p&gt;

&lt;h2&gt;
  
  
  A thin dark line is a different member of the family
&lt;/h2&gt;

&lt;p&gt;My filled frames still show a very thin dark outline right on her edge. I read it as the other straight-alpha problem: edge pixels already contain the old background mixed into their colour, so the curtain or the dark wall rides along when she is pasted onto anything new. Removing it is colour decontamination, a separate step I didn't measure. So in a compositing pipeline I'd place it this way: the blur stage only ever receives background pixels, through weights or a fill, and decontaminating the edge is its own stage between matting and the final paste.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Falling back to WASM x265 cost 25 MB once and about 6x the encode time on every run</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Tue, 20 Oct 2026 08:02:35 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/falling-back-to-wasm-x265-cost-25-mb-once-and-about-6x-the-encode-time-on-every-run-13h0</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/falling-back-to-wasm-x265-cost-25-mb-once-and-about-6x-the-encode-time-on-every-run-13h0</guid>
      <description>&lt;p&gt;ImgIng's in-browser video compressor added H.265 export recently, and from the outside it splits into two paths. When the machine has a hardware HEVC encoder, the browser does the work and the job is done in seconds. When it doesn't and the source is already H.265, the page loads x265 compiled to WebAssembly and encodes inside the tab. I spent three years on codec SDKs before I moved to browser tooling, and people still ask me what a fallback like that costs as if it were one number. I measured this one on 2026-10-19 with no view of its code, and it turned out to be two bills that behave very differently: a download of about 25 MB that you pay once, and an encode that is about six times slower on every run.&lt;/p&gt;

&lt;p&gt;I used the live build of ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) through its Chinese-language site, on a single Apple M4 machine running macOS 26.5 and Chromium 149. I don't own a machine without an HEVC encoder, so I made one by turning off hardware video encoding in Chromium. That simulates the condition. It is not a real low-end laptop. The source was a 60-second 1080p30 screen recording I generated frame by frame and converted to H.265 Main, 20,645,740 bytes, stiller and cleaner than a real recording. Preset "HD balanced", output MP4 H.265, one run per configuration.&lt;/p&gt;

&lt;p&gt;The switch between the two paths lines up with what the browser reports. With hardware encoding on, &lt;code&gt;VideoEncoder.isConfigSupported&lt;/code&gt; returned true for HEVC Main under all three &lt;code&gt;hardwareAcceleration&lt;/code&gt; values. With it off, all three returned false, and the capability panel changed from native hardware to local WASM. In the normal build &lt;code&gt;prefer-software&lt;/code&gt; also said true, but at about 6 seconds for 30 frames I wouldn't count that as a usable middle path, and the page doesn't seem to either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 25 MB download happens once
&lt;/h2&gt;

&lt;p&gt;Nothing is fetched when the page opens. About 2.0 seconds after I pressed Start, the page requested &lt;code&gt;ffmpeg-core.wasm&lt;/code&gt;, 25,357,446 bytes on the wire, which took about 5.1 seconds on that day's connection. An earlier HEAD request had reported 25,328,759 bytes, and I still haven't tracked down the 28 KB gap. A few small scripts came along, tens of KB in total, one of them an OPFS file-system shim. The glue script was requested 33 times and the worker script 32 times, every one after the first a cache hit with 0 bytes. My reading is that the multi-threaded build (directory &lt;code&gt;core-lto-mt&lt;/code&gt;) starts workers that each load their own copy. After the run, two console lines showed what stayed behind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                        &lt;span class="c1"&gt;// ['imging-x264-high10-0.12.10-imging-2-mt-lto1']&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 31334742&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A second run on the same page made no wasm request, and neither did a third after a reload. The cache is named after x264 High 10, which matches the panel's note that the core is shared with that export, so whichever path runs first should pay the download for both. About 31 MB of site storage stays on the user's disk until it's cleared. Those download and storage figures are decimal, 1 MB = 1,000,000 bytes. The MB values in the table below are what the interface shows, where 1 MB = 1024×1024 bytes. A private window should fetch it again each time, which I didn't check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The encode is where the time goes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;th&gt;VMAF&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hardware HEVC&lt;/td&gt;
&lt;td&gt;8.5 s&lt;/td&gt;
&lt;td&gt;6,299,368 bytes (6.01 MB)&lt;/td&gt;
&lt;td&gt;98.32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WASM x265, first run&lt;/td&gt;
&lt;td&gt;55.5 s&lt;/td&gt;
&lt;td&gt;6,005,135 bytes (5.73 MB)&lt;/td&gt;
&lt;td&gt;97.97&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WASM x265, same page again&lt;/td&gt;
&lt;td&gt;53.8 s&lt;/td&gt;
&lt;td&gt;5.73 MB&lt;/td&gt;
&lt;td&gt;not scored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WASM x265, after a reload&lt;/td&gt;
&lt;td&gt;76.9 s&lt;/td&gt;
&lt;td&gt;5.73 MB&lt;/td&gt;
&lt;td&gt;not scored&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first WASM row is from my earlier session. In the session that produced the next two rows, the first run took 56.5 seconds. The download accounted for about 5 of those 56.5 seconds. With nothing left to fetch, the second run still needed 53.8 seconds, roughly 6.3 times the hardware path. The cache buys back only those first few seconds. The run after a reload was about 20 seconds slower than the other two, and I don't know why yet. It's a single run, so I'm leaving it there. VMAF is libvmaf with outputs scaled back to 1080p. The WASM file came out 4.7% smaller and 0.35 points lower, which on this clip I'd call a draw.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why only H.265 sources fall back
&lt;/h2&gt;

&lt;p&gt;The panel's note for this path says x265 runs locally so the output keeps the source's codec, bit depth, resolution, frame rate and colour curves. Its job is keeping an H.265 file H.265. With hardware encoding off and an H.264 source, H.265 output shows as unavailable on this device and the tool points you to MP4 with H.264 or to WebM. With the hardware encoder, H.265 saved 19 to 30% over H.264 at the same preset on my two test clips. Spending 25 MB and six times the encode time to get that, for a file that still needs an H.264 copy for players without HEVC, is a poor trade. If it were my call, I'd draw the line in the same place. I didn't test 10-bit or HDR sources, and those are exactly the files the panel note says this path keeps intact.&lt;/p&gt;

&lt;p&gt;If you're building a fallback like this, time the first download and the per-run encode separately, because only one of them goes away. Check &lt;code&gt;caches.keys()&lt;/code&gt; after a run to see what stays on the user's disk. Two things are still open on my side: the 20-second slowdown after a reload, and how a real machine without an HEVC encoder behaves, as opposed to this one where I switched it off.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I exported a long PDF page at 300 DPI and got 131: the WebP 16383 cap</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Mon, 19 Oct 2026 08:04:30 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/i-exported-a-long-pdf-page-at-300-dpi-and-got-131-the-webp-16383-cap-34j</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/i-exported-a-long-pdf-page-at-300-dpi-and-got-131-the-webp-16383-cap-34j</guid>
      <description>&lt;p&gt;I had one PDF page that is 800×9000 pt. It's a test page I generated myself: thirty blocks of text, plus a BOTTOM-MARK line at the very end so I could tell whether anything got cut. I wanted it as a single tall image. I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), a browser-based PDF and image tool, in Chromium 149 on an Apple M4 Mac, and exported to WebP. At 300 DPI I got 1456×16380. At 216 I got 1456×16380. At 144, same again. All three files were 1,213,414 bytes.&lt;/p&gt;

&lt;p&gt;No error and no warning. The result card just listed the size. So the first job was working out what resolution I actually had.&lt;/p&gt;

&lt;h2&gt;
  
  
  Effective DPI is width over page width
&lt;/h2&gt;

&lt;p&gt;PDF measures in points, 72 to the inch. This page is 800 pt wide, which is 11.11 inches, and 1456 px divided by 11.11 in is 131. That's the effective DPI, whatever the dropdown said. The aspect ratio came out at 11.25, same as 9000/800, and the bottom mark was still there on row 16379. Nothing was cropped. The whole image had been scaled down evenly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where 131 comes from
&lt;/h2&gt;

&lt;p&gt;At 216 DPI the page should be 2400×27000. A WebP file stores width and height in 14 bits each, so neither side can go past 16383. The output is 16380 tall, three pixels under. Shrink the page until its height fits and the width shrinks with it, down to 1456. Every DPI option above 131 hits that same wall, 144 included, which is why all three exports were byte-identical.&lt;/p&gt;

&lt;p&gt;To check that the wall belongs to WebP and not to the tool, I exported the same page as JPG and PNG. At 144 DPI I got 1600×18000, a true 144. At 216 and 300 both formats landed on 2385×26832 and 2385×26834, about 215 effective DPI. So those formats go well past 16383 and stop somewhere else. From the sizes it looks like a total-pixel cap near 64 million, but that's my guess. I didn't find it documented.&lt;/p&gt;

&lt;p&gt;One thing I can only report, not explain. When the same tool stitches several pages into one long image, JPG and PNG are also held under 16383. Eight A4 pages came out at 1446×16376 in both WebP and JPG, which is 175 effective DPI. I haven't seen the code, so I don't know why that mode follows the WebP limit for every format.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the browser does if you export it yourself
&lt;/h2&gt;

&lt;p&gt;If you build this in your own front end, the canvas is where you meet the limit. I asked three engines to export a 1786×20208 canvas as WebP and as JPEG, then decoded whatever came back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;exportedSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;null&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;x&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;WebP request&lt;/th&gt;
&lt;th&gt;JPEG request&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chromium 149&lt;/td&gt;
&lt;td&gt;image/webp 1786x16383&lt;/td&gt;
&lt;td&gt;image/jpeg 1786x20208&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firefox 151&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;image/jpeg 1786x20208&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebKit 26.5&lt;/td&gt;
&lt;td&gt;image/png 1786x20208&lt;/td&gt;
&lt;td&gt;image/jpeg 1786x20208&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Chromium is the quiet one. It hands back a valid WebP that is 16383 tall. An earlier run with a gradient and color bars showed the missing rows are cut off the bottom, not scaled. Firefox gives you null, which at least you can catch. WebKit switches the format to PNG and keeps the size, so &lt;code&gt;blob.type&lt;/code&gt; is what to look at there. JPEG has its own ceiling too: Chromium returned 65500 rows when I asked for 65535.&lt;/p&gt;

&lt;p&gt;Put the tool and the engines side by side and you get several ways to hit the cap without any error. Scaling keeps every line of content and costs resolution. Cropping keeps resolution and loses the bottom of the page. Swapping formats keeps both and changes what you ship. Back when I worked on codecs, the tickets that took me longest to close looked like this one: every call succeeded and the output was still wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check now
&lt;/h2&gt;

&lt;p&gt;Before exporting I divide 16383 by the total height in inches. That's the best DPI a WebP long image can reach: about 175 for eight A4 pages, about 70 for twenty. After exporting I divide the image width by the page width in inches and compare the two. In my own code I decode the blob and check that width, height and type match the request before saving. When they don't match, I split the pages into shorter runs instead of shipping a 23 DPI image of a 60-page document.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I compressed a signed PDF with five tools. Every signature broke or vanished</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Sun, 18 Oct 2026 08:01:35 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/i-compressed-a-signed-pdf-with-five-tools-every-signature-broke-or-vanished-123</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/i-compressed-a-signed-pdf-with-five-tools-every-signature-broke-or-vanished-123</guid>
      <description>&lt;p&gt;A lot of e-contract pipelines sign first and compress later. The signed file often carries scans or photos, so shrinking it right before archiving feels natural. I wanted to see what that order actually does, so I built a test file, ran it through five compression or rewrite tools, and checked every output with two validators. Short version: every tool rewrote the whole file, and every signature either failed or disappeared.&lt;/p&gt;

&lt;p&gt;The test file is a one-page fictional agreement with an 1800×1200 synthetic photo, signed by pyHanko as &lt;code&gt;adbe.pkcs7.detached&lt;/code&gt; with a self-made test root CA. It came out at 931,980 bytes. I validated with poppler's pdfsig 26.08.0 and pyHanko 0.33.0, both trusting only that test root. No real CA and no timestamps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signature covers two byte ranges
&lt;/h2&gt;

&lt;p&gt;The signature dictionary had &lt;code&gt;ByteRange [0 923749 931275 705]&lt;/code&gt;: 923,749 bytes starting at offset 0, then 705 bytes starting at offset 931,275. Add 931,275 and 705 and you get 931,980, the end of the file. The 7,526-byte gap in the middle is &lt;code&gt;/Contents&lt;/code&gt;, where the signature value sits as hex. It can't hash itself, so it gets cut out. A validator concatenates the two ranges, runs SHA-256 over them, and compares the result with the signed digest.&lt;/p&gt;

&lt;p&gt;So the signature is about bytes, not about how the page looks. Reorder the objects or recompress a single stream and the hash stops matching, even if every glyph on the page is identical. I spent years on video codecs, and my reflex on a checksum failure is to hunt for the broken frame. There is nothing to hunt here: after a full rewrite almost every byte has moved.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;pdf_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/ByteRange\s*\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pdf_bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lo_len&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi_len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;signed_part&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdf_bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;lo_len&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;pdf_bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;hi_len&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;hi_len&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_bytes&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signed_part&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()[:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the original this prints &lt;code&gt;True 040cd959&lt;/code&gt;. After a plain qpdf rewrite (930,748 bytes, ByteRange copied over unchanged) it prints &lt;code&gt;False 571be5df&lt;/code&gt;. The regex only works while the ByteRange is stored as plain text. Once a tool packs objects into object streams, byte-level searching starts missing things.&lt;/p&gt;

&lt;h2&gt;
  
  
  All five tools rewrote the whole file
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Output bytes&lt;/th&gt;
&lt;th&gt;Signature dictionary&lt;/th&gt;
&lt;th&gt;pdfsig&lt;/th&gt;
&lt;th&gt;pyHanko&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;qpdf rewrite&lt;/td&gt;
&lt;td&gt;930,748&lt;/td&gt;
&lt;td&gt;kept, ByteRange copied&lt;/td&gt;
&lt;td&gt;Illegal values in ByteRange array&lt;/td&gt;
&lt;td&gt;INVALID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;qpdf object streams&lt;/td&gt;
&lt;td&gt;930,178&lt;/td&gt;
&lt;td&gt;kept&lt;/td&gt;
&lt;td&gt;Illegal values in ByteRange array&lt;/td&gt;
&lt;td&gt;INVALID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ghostscript /ebook&lt;/td&gt;
&lt;td&gt;39,876&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;td&gt;no signatures&lt;/td&gt;
&lt;td&gt;nothing to check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ImgIng, ebook preset&lt;/td&gt;
&lt;td&gt;64,903&lt;/td&gt;
&lt;td&gt;kept, ByteRange still ends at 931,980&lt;/td&gt;
&lt;td&gt;Illegal values in ByteRange array&lt;/td&gt;
&lt;td&gt;INVALID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iLovePDF, recommended&lt;/td&gt;
&lt;td&gt;37,513&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;td&gt;no signatures&lt;/td&gt;
&lt;td&gt;nothing to check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDF24, default&lt;/td&gt;
&lt;td&gt;50,877&lt;/td&gt;
&lt;td&gt;kept, ByteRange copied&lt;/td&gt;
&lt;td&gt;Illegal values in ByteRange array&lt;/td&gt;
&lt;td&gt;INVALID&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every output went from two &lt;code&gt;%%EOF&lt;/code&gt; markers to one, and none of them started with the original bytes. For ImgIng I used its in-browser PDF compressor at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt; on the ebook preset, in Chromium 149 on an Apple M4 Mac. Its output still declares a range ending at byte 931,980 inside a 64,903-byte file. Ghostscript and iLovePDF removed the signature field entirely. I think that case is worse. A reader can warn about a broken signature, but there is nothing to warn about once the signature is gone. I didn't check what Acrobat or Foxit actually display.&lt;/p&gt;

&lt;h2&gt;
  
  
  An appended edit still validates
&lt;/h2&gt;

&lt;p&gt;The way to change a signed file without breaking the signature is an incremental update: new objects, a new xref table and a new &lt;code&gt;%%EOF&lt;/code&gt; go after the old end of file, and the original bytes stay untouched. The signed file already had two &lt;code&gt;%%EOF&lt;/code&gt; markers because pyHanko's signing step is itself an incremental update.&lt;/p&gt;

&lt;p&gt;I appended three more revisions: a second signature, a text annotation written with pypdf, and a pyHanko edit to the page content stream that adds a line of text. All three still began with the original 931,980 bytes, the covered-range hash stayed &lt;code&gt;040cd959&lt;/code&gt;, and pdfsig said "Signature is Valid" for each one.&lt;/p&gt;

&lt;p&gt;The content edit is the one that matters. pdfsig only added "Not total document signed". pyHanko diffed the revisions and returned &lt;code&gt;ILLEGAL_MODIFICATIONS&lt;/code&gt;; for the second signature it returned &lt;code&gt;ACCEPTABLE_MODIFICATIONS&lt;/code&gt;. My understanding is that pyHanko judges post-signature revisions against PDF's modification rules (DocMDP and FieldMDP), while pdfsig only checks the bytes. A valid hash tells you the signed bytes are intact. It tells you nothing about what came after them.&lt;/p&gt;

&lt;p&gt;One result I can't fully explain. pyHanko also flagged the annotation as illegal ("unexplained xrefs"), which looks like its default policy not recognizing the way pypdf appends annotations. I haven't tried that file in Acrobat.&lt;/p&gt;

&lt;p&gt;Compression can't take the incremental route anyway. An incremental update has to keep every original byte, so the old image streams stay where they are and the file can only grow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do now
&lt;/h2&gt;

&lt;p&gt;Compression happens before signing, and a signed file never goes into a compressor. When a signed PDF comes in, I check whether the last ByteRange ends at end-of-file. If it doesn't, there are revisions after the signature, and a "Valid" from a byte-hash checker isn't enough. That file goes through a validator that diffs revisions.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Counting colors stops telling screenshots from photos after one JPEG save</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Sat, 17 Oct 2026 08:01:49 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/counting-colors-stops-telling-screenshots-from-photos-after-one-jpeg-save-14cd</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/counting-colors-stops-telling-screenshots-from-photos-after-one-jpeg-save-14cd</guid>
      <description>&lt;p&gt;I wanted an upload pipeline to pick compression settings by content: palette PNG for screenshots, lossy JPEG or WebP for photos. The obvious classifier is a unique-color count, since a screenshot has a few hundred colors and a photo has hundreds of thousands. That held up right until I fed it a screenshot that had already been saved as JPEG once, which is how a lot of screenshots actually arrive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Eight images, all measured on an M4 Mac with sharp 0.34.5 for encoding and NumPy for the math. Two screenshots I rendered myself at 1280×800: a fake Chinese meeting-notes page and a fake order table with colored status badges. Two photos from Wikimedia Commons, scaled to 1280 wide and kept lossless: a misty lake by Jakub Fryš and a fruit stall in Arequipa by Josep M. Gracia, both CC BY-SA 4.0. Two 413×579 portrait JPEGs, also from Commons, cropped to ID-photo proportions. Then each screenshot saved once more as JPEG quality 80. That is a small sample, and none of the numbers below are meant as accuracy figures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the color count broke
&lt;/h2&gt;

&lt;p&gt;Lossless, the count looks perfect: 174 and 1,402 colors for the screenshots, 119,846 and 634,115 for the photos. After one JPEG save at quality 80, the order table jumped to 21,500 colors. The blue-background portrait JPEG sits at 26,827. Same order of magnitude, so no threshold separates them.&lt;/p&gt;

&lt;p&gt;The reason is how JPEG works. It transforms and quantizes 8×8 blocks and halves chroma resolution, so every sharp edge picks up ringing. A table full of lines and colored badges is nothing but sharp edges, and each one spawns a fringe of new in-between colors. Each new color covers only a few pixels, but there are a lot of them. The meeting notes only went from 174 to 2,360, because black text on white gives chroma little to smear.&lt;/p&gt;

&lt;h2&gt;
  
  
  What survived the JPEG save
&lt;/h2&gt;

&lt;p&gt;So I stopped asking how many colors there are and asked how concentrated they are. A screenshot is mostly one background plus a handful of text colors, and JPEG noise does not move the big flat areas. Two numbers did the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;color_concentration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;px&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img_path&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uint8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;packed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uint32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;65536&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uint32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_counts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;top_share&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hist&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;top_n&lt;/span&gt;&lt;span class="p"&gt;:].&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;packed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;
    &lt;span class="n"&gt;repeat_right&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packed&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;packed&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hist&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;top_share&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repeat_right&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The share of pixels covered by the 16 most common colors was 0.920 and 0.954 for the lossless screenshots and still 0.838 and 0.745 after JPEG. All four photos landed between 0.039 and 0.135. The fraction of pixels identical to their right neighbor told the same story: 0.740 and 0.786 for the JPEG screenshots, at most 0.260 for any photo. I also tried a Sobel strong-edge ratio. It barely moves under JPEG (0.1408 vs 0.1396 on the text page), but a busy photo and a sparse UI only differ by about 3x (0.0222 for the fruit stall, 0.0692 for the table), so I would only use it as a tiebreaker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the direction of the mistake matters
&lt;/h2&gt;

&lt;p&gt;Sending a photo to a 256-color palette costs real bytes: both photos came out 2.5 to 3.8 times larger than JPEG quality 80. On the lake's sky, turning dithering off produced flat steps of color, and turning it on traded the steps for grain. The fog in the same photo showed no bands at all, which I read as one hue only needing 256 steps along a single axis.&lt;/p&gt;

&lt;p&gt;I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) in Chromium 149 as a reference point and just read its auto-detection label for eight images: the four originals plus both portraits at 413×579 and at full size. Both screenshots went to an 8-bit PNG preset. The lake, both small portraits and the full-size blue-background portrait were labeled photos at WebP quality 75. Two photos were labeled screenshot/UI: the fruit stall and a 2996×4200 portrait original. They stayed WebP and only got quality 84 instead of 75. No screenshot was labeled a photo.&lt;/p&gt;

&lt;p&gt;The next part is my reasoning, not a measurement. A screenshot pushed through photo settings would get the gray speckles around text that I saw in the JPEG versions, and people notice that right away. A photo on the screenshot side only costs bytes, as long as that side never forces it into a palette, and the numbers above show how many bytes a palette can cost. So if I were tuning such a classifier, I would let uncertain images lean toward the screenshot label but give them a middle tier that stays lossy at a higher quality. From the outside, the quality-84 result looks roughly like that.&lt;/p&gt;

&lt;p&gt;If you have a classifier like this, rerun it on screenshots that were re-saved as JPEG, and add a few detail-heavy photos to the test set before trusting the threshold.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A Web Worker fixed the freeze, not the wait: two checks for in-browser AVIF encoding</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Fri, 16 Oct 2026 08:02:27 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/a-web-worker-fixed-the-freeze-not-the-wait-two-checks-for-in-browser-avif-encoding-921</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/a-web-worker-fixed-the-freeze-not-the-wait-two-checks-for-in-browser-avif-encoding-921</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9zo3kjtgbuwz88k9s1jm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9zo3kjtgbuwz88k9s1jm.png" alt=" " width="560" height="894"&gt;&lt;/a&gt;&lt;br&gt;
You wire up an "Export as AVIF" button. You click it, the spinner you added stops mid-turn, the cancel button ignores you, and about a second later a file shows up. Two fixes get proposed almost immediately. The first is to move the encode into a Worker, because then it will be faster. The second is to ship the multi-threaded build, because that one is always faster. I built a test page and timed both before agreeing to either. Neither held in the form it is usually stated.&lt;/p&gt;

&lt;p&gt;The page needs its own encoder for a plain reason. AVIF is an image format built on the AV1 video codec. &lt;code&gt;canvas.toBlob(cb, 'image/avif')&lt;/code&gt; is the call that should turn a canvas into an AVIF file, and in Chromium 149, Chrome 155 and Firefox 151 it handed me a PNG with no error. Only WebKit 26.5 gave back an AVIF. So you bring your own encoder, the code that turns raw pixels into a compressed file. I used &lt;code&gt;@jsquash/avif&lt;/code&gt; 2.1.1, which is libavif v1.0.1 compiled to WebAssembly (WASM), a binary format the browser runs next to your JavaScript. My input was a lake photo from Wikimedia Commons by Jakub Fryš (CC BY-SA 4.0), scaled to 1738×1150 (about 2 MP), at the library defaults of quality 50 and speed 6. Everything ran on one Apple M4 Mac, and every millisecond below belongs to that machine.&lt;/p&gt;

&lt;p&gt;Start with the Worker claim. The main thread is the one thread that runs your JavaScript, handles clicks and paints the page, and a Worker is a second thread you can hand a job to. To measure a freeze, I logged the longest gap between two animation frames while the encode ran. &lt;code&gt;requestAnimationFrame&lt;/code&gt; asks the browser to call you right before the next paint, so a long gap means nothing was painted in between.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;worstGap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;watch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;worstGap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worstGap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the page idle, the worst gap sat between 17 and 25 ms. In Chromium 149 the encode took 762 ms on the main thread, and the worst gap was also 762 ms. In a Worker the same encode took 766 ms and the worst gap was 22 ms. Chrome 155 gave 776 and 775 ms, WebKit 26.5 gave 816 and 830 ms. Each cell is a single run, so I read nothing into which of 762 and 766 is larger.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="Longest frame gap while encoding, main thread versus Worker, in three browsers" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Red bars are the freeze with the encode on the main thread, green bars are the same job in a Worker. The grey encode times on the left barely differ.&lt;/p&gt;

&lt;p&gt;A Worker is a second checkout lane opened for one overloaded cart. The cart takes as long to scan, but everyone behind it keeps moving. So when a proposal says "Worker, therefore faster", I ask which complaint it answers. A frozen page, yes. A long wait, no, and the plan needs a second idea.&lt;/p&gt;

&lt;p&gt;That second idea is usually the multi-threaded build, which spreads one encode across several threads. At speed 6 it delivered: the 2 MP photo went from 696 / 696 / 673 ms to 327 / 324 / 323 ms in Chromium 149, and Chrome 155 and WebKit 26.5 were also 2 to 3 times faster. Then I changed &lt;code&gt;speed&lt;/code&gt;, libavif's effort setting, where higher means quicker. At speed 8 the single-threaded build took 163 ms and the multi-threaded one 174 ms. At speed 10 it was 103 against 149, so "always" broke on the first parameter I touched. I did not look into why. I also never measured image quality at 8 and 10, so this is not advice to raise the setting.&lt;/p&gt;

&lt;p&gt;The multi-threaded build also has a deployment condition. The page must be cross-origin isolated, a state it enters when the server sends two response headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My test server sent both. What the build does without them I did not test, and I would want that gap closed before approving it.&lt;/p&gt;

&lt;p&gt;To see how a shipped tool handles the loading side, I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) in Chromium 149 only, one run per image size. The request log comes from its Chinese-language site, and I watched from outside without reading its code. It fetched &lt;code&gt;avif_enc_mt.wasm&lt;/code&gt;, the multi-threaded build, at 1,135,337 bytes over gzip. The fetch began the moment I selected AVIF in the format grid, before I pressed Convert. Its wait times run from the click to the finished state, a different stopwatch from my &lt;code&gt;encode()&lt;/code&gt; numbers, so I am not comparing the two.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyflvn36sqbyaft4p1ggd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyflvn36sqbyaft4p1ggd.png" alt="ImgIng's convert panel right after selecting AVIF, with the On-demand WASM badge and a Load encoder button" width="800" height="293"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the English interface right after AVIF is selected. The tile carries an "On-demand WASM" badge, and the bottom note says the browser cannot encode the format natively. I never pressed the "Load encoder" button beside it.&lt;/p&gt;

&lt;p&gt;In a design review I now write these as two line items. "Does not freeze" is answered by where the encode runs. "Is faster" is answered by which build at which speed, and gets re-measured whenever either changes. Still open: the missing-headers case, why threads stop paying off at speed 8, and any hardware other than this one M4. No phones, no Windows.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
