<?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: AsyncMonk</title>
    <description>The latest articles on DEV Community by AsyncMonk (@shu_jing_915fa287b22539ad).</description>
    <link>https://dev.to/shu_jing_915fa287b22539ad</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%2F4087883%2F59a5c86a-9fa5-4349-971d-f26ba405bf1e.png</url>
      <title>DEV Community: AsyncMonk</title>
      <link>https://dev.to/shu_jing_915fa287b22539ad</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shu_jing_915fa287b22539ad"/>
    <language>en</language>
    <item>
      <title>Which lines survive upscaling a 300px product shot</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Tue, 22 Sep 2026 08:05:49 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/which-lines-survive-upscaling-a-300px-product-shot-1k58</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/which-lines-survive-upscaling-a-300px-product-shot-1k58</guid>
      <description>&lt;p&gt;Every AI upscaler demo shows you the same thing: the blurry input on the left, the sharp output on the right. I stared at a lot of those before realising the comparison cannot answer the one question I actually had. A client had sent me 300×300 product shots for a page that needed 750px wide images, the originals were long gone, and I needed to know whether the batch number printed on the bottle would survive the trip. Before-and-after tells you whether something looks sharp. It tells you nothing about whether it is still the same number.&lt;/p&gt;

&lt;p&gt;So I built a sample where I knew the answer in advance. I drew a fictional product label at 1200×1200: a made-up brand called MEIJIA, invented ingredients, an invented lot code, with type ranging from 92px down to 14px. Then I shrank it to 300×300 and upscaled it back 4x, landing exactly on 1200×1200. Now I had ground truth and result at the same size, and I could diff them line by line instead of squinting at them.&lt;/p&gt;

&lt;p&gt;I ran the upscaling in ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), which downloads the model into the browser and runs inference locally. With the network panel open across roughly forty runs, the images themselves never left the machine: zero bytes outbound. The only heavy traffic is the model download coming the other way, which people mix up with uploading all the time. For freelance work that distinction matters — unreleased product photos passing through somebody else's server is a conversation I would rather not have.&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="Ground truth on the left, the same label shrunk 4x and upscaled back on the right" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The boundary turned out to be crisp, and it has nothing to do with text versus graphics. What decides everything is how many pixels tall a thing is &lt;em&gt;after&lt;/em&gt; the shrink.&lt;/p&gt;

&lt;p&gt;The 92px brand name survives the round trip at 23 pixels tall and comes back with clean edges, shippable as is. The 44px latin lettering survives at 11 pixels, every character intact. Flat colour, the bottle silhouette, dividers and rounded corners come back with no colour fringing and no invented texture at all. That is most of the image area, which is why "can I upscale a thumbnail for a product page" is not a yes-or-no question.&lt;/p&gt;

&lt;p&gt;The 34px spec line is the boundary itself. At 8.5 pixels tall in the small version, &lt;code&gt;200 mL&lt;/code&gt; comes back correct while the two Chinese characters next to it get swapped for different characters entirely. Digits right, words wrong, on the same line, rendered so cleanly that nothing about it makes you stop and look twice.&lt;/p&gt;

&lt;p&gt;Below that it falls apart. The 20px ingredient line is 5 pixels tall after the shrink; of nine characters, three at best are recognisable. The lot code &lt;code&gt;A7K-2291&lt;/code&gt; came back as &lt;code&gt;A7K(2201&lt;/code&gt;: the tail of the 9 gone, the hyphen redrawn as an opening bracket. The 14px english footnote became an evenly spaced row of black blocks.&lt;/p&gt;

&lt;p&gt;Here is the part I did not expect. I also ran the same small image through plain LANCZOS interpolation, no model involved. The lot code is a blurry mess, and all eight characters are still readable. Interpolation is blurry but honest; the model is sharp but wrong. Blurry tells you that you did not read it, so you go find the original. Sharp tells you that you did.&lt;/p&gt;

&lt;p&gt;The metrics agree in a way that is useless in practice. On this sample the best model tier scores 26.48 dB PSNR against ground truth while LANCZOS scores 27.85 dB — the model is 1.37 dB &lt;em&gt;worse&lt;/em&gt;. I still shipped the model output, because the interpolated version is too soft to put on a product page. PSNR averages error across the whole frame and does not care whether the wrong pixels are background or a lot code, so it cannot make this call for you. The only criterion that held up was simpler: can I retype this line into the catalogue from the upscaled image?&lt;/p&gt;

&lt;p&gt;By that rule, three categories always go back to a human: identifiers such as lot codes, barcodes and order numbers; spec figures such as volume, weight and dimensions; and small-set body text such as ingredient lists and expiry notes. Mistakes anywhere else are cosmetic. Mistakes in those three get products delisted.&lt;/p&gt;

&lt;p&gt;One caveat before you copy my numbers: 8.5 pixels is where the line sat for this typeface, this language and this type scale. Change any of them and it moves. Take a sharp photo you already own, shrink it 4x, upscale it back, and diff it against the original. Half an hour, and you get a boundary that actually applies to your own work.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I tuned a demo GIF for an afternoon before checking if it could shrink at all</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Mon, 21 Sep 2026 08:05:37 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/i-tuned-a-demo-gif-for-an-afternoon-before-checking-if-it-could-shrink-at-all-29e0</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/i-tuned-a-demo-gif-for-an-afternoon-before-checking-if-it-could-shrink-at-all-29e0</guid>
      <description>&lt;p&gt;I spent most of an afternoon tuning a GIF that was never going to get smaller, and the only useful thing I got out of it was a table of what each setting actually costs. The demo animation at the top of my landing page was the single heaviest asset on first paint — heavier than all the scripts combined. I run this thing alone and I pay for bandwidth by the month, so every extra megabyte on the hero is a bill I hand myself once per visitor. I did not want a smaller file so much as I wanted to know which knob to turn.&lt;/p&gt;

&lt;p&gt;Touching the live asset means re-deploying every time, so I drew my own sample instead: a fictional analytics dashboard, 1280x800, 40 frames at 100 ms each, four seconds of KPI numbers ticking up, bars growing, a pie chart unfolding and a highlight sweeping across. As a GIF that is 1,127,031 bytes. I drew a second one to test the knobs against — a fake admin screen recording, 960x600, 75 frames, with small table text, a clock ticking in the header and a blinking cursor. Both are hand-drawn fictional interfaces, not screenshots of any real product.&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="Four ways to shrink a screen-recording GIF and what each one costs. Self-made fictional UI animation; the interface text in the sample is Chinese. Each panel changes one setting on the same baseline output" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the screen-recording sample the baseline output was 983,087 bytes, and I changed exactly one thing per run. Dropping every second frame saved 28.8%, every third frame 42.5%, and the frames that survive are pixel-identical to the originals — nothing gets blurrier. Resizing to 70% throws away half the pixels and saves 12.3%, by which point the small table text has already gone soft; going down to 50% saves 48.5% but the text is unreadable. Trimming ten frames off the front and twelve off the end deletes nearly a third of the frames for 12.7%, because the frames you delete are the static ones that were already the cheapest in the file. And changing playback speed saves nothing at all: at 2x and at 0.5x the output was byte-for-byte identical to the baseline.&lt;/p&gt;

&lt;p&gt;The speed one deserves a sentence of its own, because it is the trap in the whole exercise. A GIF has no global frame rate — every frame carries its own display duration, and total runtime is just those durations added up. Drop half the frames without lengthening the ones you keep and the animation plays twice as fast: my 4500 ms clip came out at 2280 ms. That is arithmetic built into the format, not a quirk of any particular tool, so any frame-dropping step has to be paired with a speed adjustment to get the original pacing back. The happy accident is that speed costs zero bytes, so putting the pacing back does not give up the 28.8%.&lt;/p&gt;

&lt;p&gt;Resizing being such a bad deal took me longer to understand. GIF compresses with LZW, which only rewards runs of identical pixels along a single row, and a UI screenshot is compressible mainly because it is full of long flat horizontal runs. Interpolated scaling invents intermediate colours along every edge, which chops those runs into a mess of near-but-not-quite matches, and those new colours also eat into the 256-colour palette each frame gets. Half the pixels, but each pixel compresses much worse, and the two effects mostly cancel out.&lt;/p&gt;

&lt;p&gt;Then I went back to the dashboard clip I actually cared about and none of it helped. Re-encoding it as a GIF saved 3%: 1.07 MB in, 1.04 MB out. The reason is that GIF's inter-frame diffing can only mark one rectangular dirty region per frame, and everything inside that rectangle is re-encoded in full. On the admin recording, 68 of 75 frames use a partial refresh and the dirty region averages 59% of the canvas, so there is real slack to recover. On the dashboard, with numbers changing top-left, bars growing in the middle and a highlight sweeping across, one rectangle has to swallow all of it: the dirty region averages 90.5%, which means the diffing is doing essentially nothing. That number is the thing I now check first.&lt;/p&gt;

&lt;p&gt;So the order I had been working in was backwards. Look at what kind of picture it is, then pick the knob. Mostly-static recordings with small text: drop frames, fix the speed, and leave resizing for last. Busy animations where everything moves: stop tuning and change the container. The same dashboard clip exported as animated AVIF came out at 208,937 bytes — 1.07 MB down to 204 KB, 18.5% of the original, with the labels still legible. Animated WebP landed at 696 KB. APNG is lossless, so it went up 36%, which in hindsight is exactly what lossless should do to a file like this.&lt;/p&gt;

&lt;p&gt;I ran the whole comparison in ImgIng's animation workshop (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;). Two practical reasons for that choice: the processing happens locally in the browser — I had the Network panel open for the entire run and there was not a single non-GET request, so a few dozen throwaway attempts cost me no traffic at all — and it exposes frame dropping, playback speed and per-frame duration as separate inputs, which is the only way the one-variable-at-a-time table above was possible. It also says so when it fails: the APNG attempt on the dashboard came back with a note that APNG is a poor fit for this kind of picture, and a button to re-run it as something else. Across all four exports the frame count, per-frame durations and the infinite loop flag matched the source file exactly, so switching formats did not mean re-checking the timeline.&lt;/p&gt;

&lt;p&gt;The part I have not settled is compatibility. I have only confirmed that the AVIF output decodes and plays in three desktop browser engines; I have not tested it inside messaging apps, on older Android devices, or in embedded WebViews, and the AVIF output carries no alpha channel, so anything that needs a transparent background is out. My desktop hero is AVIF now and the mobile path still falls back to the GIF, which is not elegant, but I would rather ship the ugly fallback than find out the hard way. If you only take one number away from this: measure what fraction of the canvas is actually changing before you touch any of the settings.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The ZIP of images from a PDF has no page numbers, so I rebuild them</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Sun, 20 Sep 2026 08:05:36 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/the-zip-of-images-from-a-pdf-has-no-page-numbers-so-i-rebuild-them-196o</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/the-zip-of-images-from-a-pdf-has-no-page-numbers-so-i-rebuild-them-196o</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%2F20d9uw6an9fn4bs7si8k.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%2F20d9uw6an9fn4bs7si8k.png" alt=" " width="800" height="556"&gt;&lt;/a&gt;&lt;br&gt;
A kind of small web job I see a lot: build a simple product site, and the only place the product photos exist is a brochure PDF someone exported from a design tool years ago. Screenshotting each page and cropping is what people usually do first, and it gets you pictures at whatever size the brochure happened to display them. Before I'd take on a job like that I wanted a repeatable way to go from one PDF to a folder of usable original files, so I built a test brochure and ran it end to end. It's my own file, generated with reportlab: 8 pages for a made-up brand called LEAF CO, with a logo in the top-right corner of every page, four product shots (two JPEGs, one PNG with transparency, one flat PNG), page 7 repeating all four, and page 6 drawn as a vector flowchart and bar chart. Everything below comes from that file. This is the checklist I ended up with.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Count what the PDF stores, not what the pages show. My brochure has 17 image placements across 8 pages, but only 5 distinct image objects, because the logo is the same object painted 8 times and each product shot is reused on page 7. ImgIng's Extract PDF images returned exactly 5 results, 693 KB in total. The logo came out once, not eight times, and its card lists every page it sits on. That list is long enough that the card cuts it off after page 5 with an ellipsis; the full text "Page 1, Page 2, … Page 8" is in the page, you just can't read it on the card. If I'm quoting a job, "5 files" is the honest scope, not "17 images".&lt;/li&gt;
&lt;/ol&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="Extract PDF images on my 8-page test brochure: 5 results, named q008-brochure-image-001 to 005 (self-made file; its page titles are in Chinese)" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The ZIP forgets the page numbers, so rebuild them. The download is &lt;code&gt;q008-brochure-images.zip&lt;/code&gt; with five files sitting flat inside, &lt;code&gt;q008-brochure-image-001.png&lt;/code&gt; to &lt;code&gt;-005.png&lt;/code&gt;, stored without compression, no folders. The page numbers exist only on the cards in the browser. For a client handover I want a sheet that says which file is on which page, so I rebuild the map from the PDF by matching pixel sizes. A short PyMuPDF pass lists every image object on every page with its width and height and groups them by size. Then it opens each extracted file and looks up its dimensions: if exactly one object has that size, the file gets that object's page list, and if two different objects share it, the row says "check by eye" instead. On the brochure all five files matched. The 360×120 logo is on pages 1 through 8, the 1000×1000 JPEG on 1, 2 and 7, the 1200×900 JPEG on 3 and 7, and the two PNGs (800×800 and 900×600) on 4 and 7 and on 5 and 7. That goes into a small CSV of file, size and pages, which travels with the images.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pairing by size is cheap and it's enough here because every image has its own dimensions. A real brochure with a row of identical 800×800 thumbnails would hit the "check by eye" branch, and I'd rather it say so than guess.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Check that what you got is the original, once, then stop worrying. I had the source images since I made the brochure, so I compared. Both JPEG product shots had the same SHA-256 as the files I embedded. The PNGs matched pixel for pixel, including the transparent one, whose alpha survived. One quirk: all three PNGs came back as RGBA, including the logo and the flat shot that went in as plain RGB; they just gained a fully opaque alpha channel. Harmless for a website, worth knowing if something downstream complains about an alpha channel.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Vector pages give you nothing, and that's correct. Page 6 is a flowchart and a bar chart drawn with lines and rectangles, not a bitmap. The only thing it contributed was the logo in its corner. If that chart is wanted on the new site, it's either a page render (the PDF to images mode on the same workspace, 216 DPI gives 1786×2526 for an A4 page) or, better, asking whoever made the brochure for the source file.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;One PDF per run. The file picker only accepts one file. When I dragged two PDFs onto the drop zone together, it loaded the first one (the brochure showed up as 8 pages, 740 KB) and ignored the second without saying anything. If a job comes with a dozen brochures, that's a dozen runs, and I'd write the file names down as I go because nothing on screen tells you the second one was skipped. Loading a new file does warn that the current work gets replaced once you confirm.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;More than one result means ZIP only. There's no save button on individual cards. With a single result the button changes to "Save this image" and writes that file directly; with five you get "Save extracted images (ZIP)" and pick from the unpacked folder afterwards. I haven't tried right-click saving from the card thumbnails, so I can't say whether that gives you the full file.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the whole routine: count objects, extract, rebuild the page map, spot-check, and flag vector pages. The workspace I used for the extraction is at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt; and it doesn't need an account.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The Screen/email PDF preset made my product manual unreadable</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Sat, 19 Sep 2026 08:05:23 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/the-screenemail-pdf-preset-made-my-product-manual-unreadable-59kf</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/the-screenemail-pdf-preset-made-my-product-manual-unreadable-59kf</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%2Fs8dcvttlxhjtwfpelw8o.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%2Fs8dcvttlxhjtwfpelw8o.png" alt=" " width="800" height="556"&gt;&lt;/a&gt;&lt;br&gt;
Part of my freelance work is handing over a product manual as a PDF. The layout person drops full-size photos and screenshots in, the file lands somewhere around 20 MB, and a week later the client writes back: "Can you make it smaller? I can't send it." It's unpaid work, so I've always just picked the smallest option in whatever tool was open and moved on.&lt;/p&gt;

&lt;p&gt;I finally sat down and tested that habit on a manual built the way my deliverables usually are. It turns out "smallest" was the wrong default, and the preset literally labelled for email is the one that breaks the manual.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test file
&lt;/h2&gt;

&lt;p&gt;To be clear, this is not a client's document. I built a fake 8-page A4 manual for a made-up brand, LEAF CO (the text inside is Chinese). Every page has one photo, one admin-dashboard screenshot with sample data, a few paragraphs and a vector bar chart. The photo started as a CC0 image from Wikimedia Commons, which I cropped, recolored and upscaled to 4032×3024. The screenshots are 2880×1800 PNGs.&lt;/p&gt;

&lt;p&gt;Total: 19,458,294 bytes. Images are 99.23% of that. Fonts, text and the charts together are under 1%, so compressing this PDF really means compressing its images.&lt;/p&gt;

&lt;p&gt;The number that explains the size is effective DPI: pixels divided by the inches the image occupies on the page. The photo is 4032 px wide but only 9 cm on the page, which works out to 1138 dpi. The screenshots come out at 430 dpi. Nobody needs 1138 dpi on a screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four presets, four outcomes
&lt;/h2&gt;

&lt;p&gt;I ran it through the PDF compressor at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;imging.ai&lt;/a&gt;, mostly because it runs in the browser and I don't like uploading client material anywhere. You can check that yourself: with DevTools open on the Network tab, I saw no non-GET requests during the whole run, only &lt;code&gt;blob:&lt;/code&gt; URLs. Its presets are really DPI ceilings for embedded images.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Screen / email, 72 dpi:&lt;/strong&gt; 352,318 bytes (the UI shows 344 KB, 1.8% of the original). The screenshots get squeezed to 482×301 and a 256-color palette. The small text in the tables is gone. Not "a bit soft", unreadable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;E-book, 150 dpi (the default):&lt;/strong&gt; 864,614 bytes (844 KB, 4.4%). Screenshots end up at 1005×628 and the table text is clear. All 8 pages are there, and the 3,357 characters of text compare identical and stay searchable. Rendering every page at 150 dpi and comparing with the original gives an average PSNR of 41.3 dB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Print, 300 dpi:&lt;/strong&gt; 3,722,437 bytes (3.55 MB), PSNR 54.7 dB. The photos drop to 1063×797. The screenshots are left alone, because 430 dpi is under the print threshold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lossless structure:&lt;/strong&gt; doesn't touch pixels, saved only a few KB on this file.&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="Table text from the same manual after each preset, 150 dpi render crop. The red box is the Screen/email preset. Sample is a self-made fictional manual with Chinese sample data." width="800" height="400"&gt;&lt;/a&gt;&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%2F22rj2v8dku0y6k9eakt5.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%2F22rj2v8dku0y6k9eakt5.png" alt="The compressor with the E-book preset selected. Red boxes: the preset button and the result card, 18.56 MB → 844 KB. The per-image decision list at the bottom right shows 1138 dpi photos going to 531×399. Sample is a self-made fictional manual." width="800" height="573"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What about the macOS built-in option?
&lt;/h2&gt;

&lt;p&gt;My previous go-to was the "Reduce File Size" Quartz filter you can pick when exporting from Preview. For this test I called the same system filter file directly rather than clicking through Preview's export dialog, so I haven't confirmed the GUI gives byte-identical output.&lt;/p&gt;

&lt;p&gt;It produced 1,432,231 bytes: 66% larger than the E-book preset, with a lower PSNR of 38.0 dB. It resamples everything to 144 dpi and re-encodes everything as JPEG, screenshots included. And simply re-saving the file with PDFKit made it 3% bigger, which I did not expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I use now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Client will read it on screen, forward it, or put it on a shared drive: E-book (150 dpi).&lt;/li&gt;
&lt;li&gt;Client will print it: Print (300 dpi). At 72 dpi each photo is 255 px wide sitting in 9 cm of paper. I didn't actually print it, but the arithmetic is enough.&lt;/li&gt;
&lt;li&gt;Screen / email only for photo-only booklets with no small text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more thing that bit me: the UI counts 1 MB as 1024 KB, so the original shows as 18.56 MB, while Finder reports about 19.5 MB. If someone gives you a size limit, check it both ways.&lt;/p&gt;

&lt;p&gt;And whatever you pick, open the result and zoom into the page with the densest table before you hit send. That quick check is what caught the 344 KB version for me.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Four kinds of web images, four different things to check after compressing</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Fri, 18 Sep 2026 08:05:38 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/four-kinds-of-web-images-four-different-things-to-check-after-compressing-209n</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/four-kinds-of-web-images-four-different-things-to-check-after-compressing-209n</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%2Fvfblc8n7krnkjivqocx4.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%2Fvfblc8n7krnkjivqocx4.png" alt=" " width="800" height="430"&gt;&lt;/a&gt;&lt;br&gt;
Every client site I build ends up with the same four kinds of images in the assets folder: product screenshots from their admin panel, a gradient hero band, real product photos, and a handful of badges and soft-edged icons with transparency. For years I ran all of them through one batch script at a fixed quality and shipped. Then a client flipped a section background from white to dark grey, and the badge set I had delivered six months earlier showed a bright fringe around every edge. I had checked those on a white page. Rework on a fixed-price job is just unpaid work.&lt;/p&gt;

&lt;p&gt;So I ran all four types through ImgIng one at a time and wrote down what actually needs checking for each. Same tool, same defaults, and the savings ranged from 60% down to 10%; one file came back completely unchanged. That spread is the useful part, so I'll go type by type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Screenshots: check the text edges
&lt;/h2&gt;

&lt;p&gt;My sample is a browser-rendered admin page I built for this, 1280×800, 94.0 KB. On upload the tool picked 8-bit color reduction on its own and returned a 208-color indexed PNG at 37.6 KB, 60% off. Pixel-for-pixel it came out at SSIM 1.000 and PSNR 60.7, so there was nothing to spot by eye. That makes sense: an admin UI is a few widget colors over large flat areas, and truecolor is mostly wasted on it.&lt;/p&gt;

&lt;p&gt;The one thing worth checking here is the text. Zoom the output to 200% and look at body-size letterforms and thin table rules. When color reduction goes too far, large flat blocks survive fine; what breaks first is the thin ring of antialiasing grey around each stroke, and the copy starts looking furry.&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="Three approaches to compressing the same UI screenshot, size and quality side by side. Sample is a browser-rendered admin page I built; the content is fictional" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Gradients: check whether it compressed just for the sake of it
&lt;/h2&gt;

&lt;p&gt;The hero band, 960×480, 53.5 KB, came back at exactly 53.5 KB. The UI said the original was already the smallest option and the size was unchanged, and the downloaded file was byte-identical in size. My first thought was that the feature had failed.&lt;/p&gt;

&lt;p&gt;It hadn't. A gradient is hundreds of continuous steps, and forcing it into one or two hundred colors leaves two options: visible banding, or dithering to hide the banding. Dithering scatters the pixel pattern so much that the lossless layer underneath stops finding anything to squeeze, and the file grows. Handing back the original beats handing back something bigger, or something striped.&lt;/p&gt;

&lt;p&gt;This flipped a habit for me. I used to read a 0% result as a weak tool. Now I read it as a decision that was made on my behalf. Conversely, if some tool reports a beautiful ratio on a gradient, the first thing I do is open it full-screen and hunt for bands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Photos: check whether the look and the size are both good enough
&lt;/h2&gt;

&lt;p&gt;For photos I used a CC0 image from Wikimedia Commons (shot on an iPhone 6), 2048×1536, 638.7 KB. The default pass produced 578.0 KB at JPEG quality 88, 10% off, and that's it.&lt;/p&gt;

&lt;p&gt;That's not laziness. The default has no idea whether the photo is a full-bleed hero or a 320px-wide list thumbnail, and those two tolerate very different quality levels. Photos are the one category where I always take over: I export the same file at two settings and look at both, pushing thumbnails into the mid-60s and keeping hero images above 80.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transparency: check the edges on a dark background AND a light one
&lt;/h2&gt;

&lt;p&gt;The badge and the soft icon are both mine, drawn for this test. Instead of fighting PNG, I exported to other formats. The badge went from 35.2 KB to 20.2 KB as WebP (43% off) and to 2.4 KB as AVIF (93% off). The 256×256 soft-edged icon went from 11.0 KB to 5.4 KB as WebP (51% off). All three kept their transparency graded rather than flattened — the drop shadow still fades continuously instead of turning into a hard cut.&lt;/p&gt;

&lt;p&gt;That matters more than the size here, because everything valuable in a badge lives in that ring of semi-transparent pixels. Which is also why the check is: drop the output onto a dark background and a light one, and look at the edge in both. A damaged alpha edge is nearly invisible on white, since the fringe was fading into white anyway, and only shows up as a bright halo or a too-hard outline once the background goes dark. Two seconds per file, and it's the step that would have saved me that rework.&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%2F73ogixsxsyt9967hj0ar.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%2F73ogixsxsyt9967hj0ar.png" alt="Transparent badge and soft-edged icon exported to WebP and AVIF: file sizes and alpha levels. Samples are my own: a drop-shadowed transparent badge and a soft-edged icon" width="800" height="459"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I did not switch everything to AVIF over that 2.4 KB. I didn't test old browsers or old devices in this round and I don't have the hardware to do it properly, so client sites that need coverage still get WebP as the baseline with AVIF as the first &lt;code&gt;source&lt;/code&gt; in a &lt;code&gt;picture&lt;/code&gt; element.&lt;/p&gt;

&lt;p&gt;One practical note for anyone handling other people's assets: this all ran in the browser, and the compression step issued no upload request — you can confirm that in the Network panel yourself. Client files never leaving the machine has saved me a conversation on more than one project. The tool is at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt; .&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Client says the WebP looks washed out: a color profile checklist before delivery</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Thu, 17 Sep 2026 08:22:40 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/client-says-the-webp-looks-washed-out-a-color-profile-checklist-before-delivery-5fjd</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/client-says-the-webp-looks-washed-out-a-color-profile-checklist-before-delivery-5fjd</guid>
      <description>&lt;p&gt;If you hand images over to clients, sooner or later someone says the product shots on the new site look washed out compared to the files they sent. Nothing is blurry and the dimensions are right, so the first suspect is compression quality. Raising the WebP quality usually changes nothing, because the pixels were never the problem. What went missing is the ICC color profile, the small block of data that tells software which color space the RGB numbers belong to. Lose a Display P3 or Adobe RGB profile and every viewer falls back to reading the same numbers as sRGB, which makes saturated colors duller.&lt;/p&gt;

&lt;p&gt;To see how much that matters, I took a CC0 photo from Wikimedia Commons (an airport apron shot on an iPhone 6), converted it on my Mac into a Display P3 JPEG and an Adobe RGB JPEG with the profiles embedded, then compared each one displayed correctly against the same pixels read as sRGB. On this sample, dropping the P3 profile cut average chroma by 16.6% (mean CIELAB ΔE76 of 1.83), and dropping Adobe RGB cut it by 20.1% (mean ΔE 2.91, with 7.4% of pixels above ΔE 5). Whites, greys and shadows barely moved; the red tail fin and the sky took the hit. The photo's colors all sit inside sRGB, so I can't tell you what happens to a genuinely wide-gamut image. Apple's developer documentation notes that iPhone cameras have been able to capture P3 since the iPhone 7, so client files carrying a non-sRGB profile are normal. Below is the checklist I now run before a delivery goes out.&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="The same pixels shown with their Display P3 profile and after the profile is dropped. Sample: CC0 photo from Wikimedia Commons (iPhone 6), converted locally to Display P3; its colors are within sRGB" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Check what profile the originals carry
&lt;/h2&gt;

&lt;p&gt;On macOS, &lt;code&gt;sips -g profile photo.jpg&lt;/code&gt; prints the profile name, so you'll see Display P3 or Adobe RGB (1998) when one is embedded. If you'd rather not open a terminal, the profile is usually listed in Preview's inspector or the file info panel. One thing surprised me: for a JPEG with no embedded profile at all, sips still prints sRGB IEC61966-2.1. So "sRGB" from sips doesn't prove a profile exists. It only means the file will be treated as sRGB, which is harmless. The originals that report P3 or Adobe RGB are the ones to watch.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Check whether the profile survived export
&lt;/h2&gt;

&lt;p&gt;I fed the same P3 JPEG through a few common tools with default settings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pillow 11.3 dropped the profile when saving JPEG and WebP, and also after resize or thumbnail followed by a JPEG save. PNG kept it. Passing the original icc_profile to save() keeps it for JPEG.&lt;/li&gt;
&lt;li&gt;ffmpeg 7.1 kept it for JPEG and PNG, and dropped it for WebP.&lt;/li&gt;
&lt;li&gt;macOS sips kept it when converting to PNG, resizing, and saving JPEG at quality 60. My attempt to write WebP with sips produced no file, so no verdict there.&lt;/li&gt;
&lt;/ul&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%2F8twn8xfkl97wl8dq0eau.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%2F8twn8xfkl97wl8dq0eau.png" alt="Which common tools keep the color profile with default settings. Input: a JPEG with an embedded Display P3 profile. Sample: CC0 photo from Wikimedia Commons (iPhone 6)" width="800" height="381"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;WebP is the format where both Pillow and ffmpeg drop the profile by default, which is probably why "WebP makes colors dull" gets repeated so often. Run sips again on the output. A WebP without a profile prints nil, which is easy to spot. A JPEG without one prints sRGB, so compare against what the original reported.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Look at them side by side in a browser
&lt;/h2&gt;

&lt;p&gt;I put the original, the exported file and a profile-stripped copy on one test page. In the Chromium 149 and WebKit 26.5 engines bundled with Playwright (macOS 26.5), the tagged and untagged images rendered differently, so both engines honor the profile and treat untagged images as sRGB. The difference shows up in saturated areas. In Playwright's own Firefox 151 build the two looked identical. That only reflects that build's defaults, and I haven't tested release Firefox.&lt;/p&gt;

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

&lt;p&gt;For images going onto a website, I convert to sRGB and embed the sRGB profile before compressing. If some later step (a CDN, a client's upload widget, another script) strips the profile, browsers still read the pixels as sRGB and the colors hold. The trade-off is that any colors outside sRGB get mapped into it. I have no sample to measure that loss on real wide-gamut photos yet. When a client explicitly needs wide gamut, I keep the original profile instead, use tools I've confirmed preserve it, and spot-check one file per batch with sips.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>HEIC to JPG made a sample photo 38% bigger: 3 checks before handing client photos over</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Wed, 16 Sep 2026 08:08:22 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/heic-to-jpg-made-a-sample-photo-38-bigger-3-checks-before-handing-client-photos-over-52mn</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/heic-to-jpg-made-a-sample-photo-38-bigger-3-checks-before-handing-client-photos-over-52mn</guid>
      <description>&lt;p&gt;A common small-project moment: a client zips up photos straight off their iPhone and asks you to put them on the site. Every file ends in .heic. The reflex is to batch-convert to JPG and move on, because JPG opens everywhere. I wanted to know what that reflex actually costs, so I ran a sample through it. The HEIC went in at 745.1 KB and the JPG came out at 1.01 MB, 38% bigger than the file I started with.&lt;/p&gt;

&lt;p&gt;About the sample, since it matters: it is not an iPhone original. It's a CC0 photo from Wikimedia Commons (shot on an iPhone 6 in 2015, 3264×2448) that I encoded to HEIC with the macOS system encoder. I made one copy tagged as a portrait shot (EXIF orientation 6) and one upscaled to 4032×3024. HDR gain maps and Live Photos weren't part of it, because I have no real device originals to test those with.&lt;/p&gt;

&lt;p&gt;That gave me three things I now check before anything goes back to a client.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Can the other side open HEIC at all?
&lt;/h2&gt;

&lt;p&gt;In the browser engines bundled with Playwright on macOS, a plain img tag pointing at the HEIC failed in Chromium 149 and Firefox 151. WebKit 26.5 displayed it with the right orientation, but it leans on macOS's own decoder, so I don't read that as "Safari everywhere", and I didn't test Windows or phones. On Windows, Microsoft says opening .heic needs both the HEIF Image Extensions and the HEVC Video Extensions, and the HEVC one may be a paid item in the Store. Server side, Pillow 11.3 without the pillow-heif plugin throws UnidentifiedImageError, and ffmpeg 7.1's default command gave me a 512×512 image, which is one tile of the full picture. Both can handle HEIC once set up properly, but I don't get to set up the client's stack. If any step in their pipeline is unknown, I don't ship HEIC.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Is the portrait still a portrait downstream?
&lt;/h2&gt;

&lt;p&gt;There are two ways to store a portrait shot. You can rotate the pixels, or you can keep landscape pixels and write an orientation tag that viewers are expected to honour. macOS sips does the second. Its JPG of my portrait sample is 3264×2448 pixels with EXIF orientation 6. Preview on the Mac shows it upright, so it looks fine locally. Anything downstream that ignores EXIF, like a thumbnail script, a crop step that decides by width vs height, or an old upload widget, will show it lying on its side. Opening that file with Pillow gives a size of (3264, 2448), and applying the tag is left to you.&lt;/p&gt;

&lt;p&gt;The browser converter I compared against, imging, rotates the pixels instead: 2448×3264 out, orientation written as 1, and a mean per-pixel difference of 0.7 to 1.5 against the correctly oriented sips render, so it isn't rotated twice. Its result panel prints the output dimensions, which makes the check quick.&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="imging's result panel after converting the 745.1 KB HEIC to JPG: 2448×3264 output and a warning that it is 38% larger than the original. Sample: CC0 Wikimedia Commons photo (iPhone 6), encoded to HEIC with the macOS system encoder" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. How big did it get?
&lt;/h2&gt;

&lt;p&gt;This is the one I had wrong. HEIC compresses far better than JPEG, so matching the look in JPEG costs bytes. The converter auto-picked quality 88 for this photo (it filed the airport shot under its graphics/text preset), and that is where the +38% came from. The landscape copy went +37%. The 4032×3024 copy went from 840.1 KB to 1.32 MB, +61%, and even at quality 80 it was still 982.9 KB, +17%.&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%2F5ev6ixes2apcgiu5vxy0.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%2F5ev6ixes2apcgiu5vxy0.png" alt="File size of the same portrait HEIC converted to JPG, WebP and PNG. Sample: CC0 Wikimedia Commons photo (iPhone 6), encoded to HEIC with the macOS system encoder; numbers apply to this sample only" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For the 3264×2448 portrait: JPG at quality 80 was 742.2 KB, about the same as the source. WebP at its default 84 was 423.2 KB, 43% smaller. Lossless PNG was 8.64 MB, roughly 11.9×. So I pick by where the photo ends up. If it's a display image on a site that serves WebP, it gets WebP, and the bandwidth saving is real money for whoever pays the hosting. If the client asks for JPG or will edit or print it, it gets JPG, starting around quality 80 rather than whatever preset the tool chose. PNG only when someone explicitly wants lossless.&lt;/p&gt;

&lt;p&gt;A few practical notes from using it. imging makes you load its HEIC decoder with one click before it can read the file, and decoding happens locally in the browser. After upload it preselects WebP, so JPG is a manual switch. It also takes one file at a time, so for a big batch I'd still run sips first (&lt;code&gt;sips -s format jpeg x.heic --out out/&lt;/code&gt;) and only redo the portraits.&lt;/p&gt;

&lt;p&gt;One thing worth passing on to the client: Settings › Camera › Formats › Most Compatible makes the iPhone save new photos as JPEG. It only affects photos taken after the change, so whatever is already in their library stays HEIC, including the batch you're holding.&lt;/p&gt;

&lt;p&gt;The converter used for these numbers is at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A background-swapped MP4 came out twice the size of its input, and what else I check before delivery</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Tue, 15 Sep 2026 08:07:39 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/a-background-swapped-mp4-came-out-twice-the-size-of-its-input-and-what-else-i-check-before-delivery-4o1d</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/a-background-swapped-mp4-came-out-twice-the-size-of-its-input-and-what-else-i-check-before-delivery-4o1d</guid>
      <description>&lt;p&gt;A talking-head clip on a clean background is an easy extra to offer next to a small business site, and doing the background swap in the browser means no GPU server and no bandwidth bill for uploading someone's video. The part that costs money is the redo. So before offering it, I ran three CC0 clips from Wikimedia Commons through a browser-based video matting tool and read the output files the way a client's upload form or a picky editor would. The preview looking right told me very little. Everything below comes from the compatibility model, a solid white background, MP4 output, the default "Visually lossless · Recommended" quality and "Keep source audio" left on.&lt;/p&gt;

&lt;p&gt;The first thing I check is whether the audio came back and in what form. It did on all four outputs, but none of them kept the original codec. A Vorbis track came out as AAC, an Opus track came out as AAC, and even an input that was already AAC was re-encoded: 160 kb/s went in, 173 kb/s came out, and the packet count changed from 372 to 375. That last one is a single sample, and it was a file I had converted myself with ffmpeg, so I wouldn't generalise from it. Sample rates were kept each time. The practical side for a client is simple. If they asked for the "original audio" and their editor checks codecs, it isn't the original stream any more, and the bitrate can go either way. The juggling clip's Opus went from 132 kb/s down to 94 kb/s AAC.&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="Source vs output for four clips: duration, video bitrate, file size with the UI estimate, and audio codec" width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Sources: Wikimedia Commons CC0 "Tarun speaking 01", "Juggling with swing poi", "Folkloristic dance in Naples". Values read with ffmpeg.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Size is where I'd have got burned. On the three original Commons files, the output video bitrate stayed close to the source, between about 0.98× and 1.2×, and file sizes moved between −0.5% and +11%. That's what you'd hope for. Then I ran the same talking-head clip, but the H.264 copy I had made myself at 1487 kb/s, and the output came out at 2988 kb/s. The file went from 1.69 MB to 3.22 MB, roughly double, from the same clip. I don't know what the tool keys on to pick that bitrate, and with one sample I'm not going to guess. What I took away is cheaper than a theory: give it the file the client actually shot, not something you've already squeezed, and look at the output size before promising anything about upload limits.&lt;/p&gt;

&lt;p&gt;The same goes for the size estimate in the export panel. It said 2.53 MB for the original talking-head clip and I got 3.42 MB. For the Naples street clip it said 1.90 MB and I got 1.55 MB. The juggling clip matched almost exactly at 606 KB. So it's in the right range and not something to quote. If a client's platform caps uploads at some number, I measure the real file.&lt;/p&gt;

&lt;p&gt;Duration is the check that came out clean, with one footnote. Video duration matched the source within a millisecond on every clip, frame counts included. The audio track, though, runs 60 to 80 ms longer than the video in every MP4. When I decoded input and output audio with ffmpeg and lined them up by cross-correlation, the output was consistently late by 2112 samples, which is 47.89 ms at 44.1 kHz and 43.99 ms at 48 kHz, the same at every point I checked, so no drift. The source MP4 had an edit list and the output doesn't, which would fit an encoder delay that nothing compensates for. I have not checked whether a browser, a phone player or an editing app compensates on its own, and I haven't sat and watched lips against sound to see if anyone could notice. Right now it's an ffmpeg reading and nothing more. For a talking head I'd watch a few seconds of the final file in whatever the client uses before sending it.&lt;/p&gt;

&lt;p&gt;One thing I only saw because I looked at the raw stream info: the MP4 carries metadata. The title tag reads the source filename plus the tool's name, and the comment says "Processed locally in the browser with ImgIng". Harmless for most jobs. But if the client is going to pass the video off as made in-house, they'd rather it didn't say where it came from, and I'd rather find that out before they do.&lt;/p&gt;

&lt;p&gt;I don't have ffprobe installed on this machine, so the whole check was &lt;code&gt;ffmpeg -hide_banner -i out.mp4&lt;/code&gt;, which prints the container, streams, bitrates and metadata and then complains that you didn't give it an output. The complaint is fine. Run it on the source and the output, compare codec, bitrate, duration and size, and open the file in the player the client will actually use.&lt;/p&gt;

&lt;p&gt;The other two export quality settings, "High quality · Smaller" and "Balanced · Size first", I didn't try, so I can't say what they do to the size numbers above. That's the next thing I'd measure before quoting anyone a file size. The tool is at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Pillow dropped GPS from my test photos, sips kept it, and one PNG had it twice</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Mon, 14 Sep 2026 08:15:03 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/pillow-dropped-gps-from-my-test-photos-sips-kept-it-and-one-png-had-it-twice-4clo</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/pillow-dropped-gps-from-my-test-photos-sips-kept-it-and-one-png-had-it-twice-4clo</guid>
      <description>&lt;p&gt;Most images on the small sites I build for clients start as phone photos, and before deploy they go through whatever resize step the project has. On a budget that tends to be a short Pillow script, or macOS sips in a shell loop because it's already installed and costs nothing. I had always assumed that shrinking a photo produces a new file and the location goes away with the old one. I didn't want to find out otherwise on a live site, so I set up a throwaway folder and checked. The short version: it depends entirely on the tool, and the same folder can end up half clean and half not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test folder
&lt;/h2&gt;

&lt;p&gt;The source photos are two. One I generated with a script: a 4032×3024 JPEG with iPhone-style EXIF, Orientation 6 and GPS set to a made-up point on the sea at 30°N 123°E. The other is a real photo, "Avions à l'aéroport de Madrid - 2015" from Wikimedia Commons, released under CC0 and taken with an iPhone 6, which has GPS at 40°29′27.76″N. I ran both through the operations a build step typically does: Pillow's default save to JPEG and WebP, Pillow thumbnail(), Pillow saving with the original EXIF passed back, sips converting to JPEG, PNG and HEIC, sips -Z resizing, and piexif.remove(). That gave 11 files in a folder called public/, laid out like a static site's image directory.&lt;/p&gt;

&lt;h2&gt;
  
  
  A scanner that fails the build
&lt;/h2&gt;

&lt;p&gt;The scanner walks a directory, reads EXIF through Pillow so it covers JPEG, PNG and WebP with one code path, and returns exit code 1 if anything has GPS or can't be read. For each file it looks at the GPS block in the EXIF and also searches the XMP packet for a GPSLatitude entry, printing the coordinates when it finds them. The XMP check is there because of what the first version missed, which I'll get to.&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="Output of my scan script on the throwaway test folder: 11 files, 5 with GPS, 1 unreadable. Synthetic sample plus a CC0 iPhone 6 photo from Wikimedia Commons; Python 3.9, Pillow 11.3, macOS sips" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Five of 11 files still had coordinates, and the pattern is clean: every sips output that Pillow could read kept GPS, including both resizes, one of them the real iPhone 6 photo at 1200×900. Every Pillow output without exif= lost it. The one Pillow file that kept it is the one where I passed the old EXIF back on purpose, which is exactly the line someone adds after a user complains that their photos lost the camera info. The HEIC can't be opened by Pillow on my machine, so the scanner counts it as unreadable and fails rather than calling it clean. I'd rather have a false alarm there than a file nobody actually looked at.&lt;/p&gt;

&lt;p&gt;The PNG line with +XMP is what the first version of the scanner missed. When sips converts to PNG it writes the GPS twice: once in the EXIF chunk and once as exif:GPSLatitude inside the XMP packet. My first scanner only read the EXIF GPS block. Had I stripped that block and rescanned with the same scanner, the PNG would have reported clean with a full copy of the coordinates still sitting in XMP.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each way of stripping costs
&lt;/h2&gt;

&lt;p&gt;Once you know which files have it, there are four ways I considered to get rid of it. I'm counting cost the way I count everything on these projects: CPU on every build, extra dependencies on the CI box, and bytes served.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What it did in my test&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Re-encode everything with Pillow's default save&lt;/td&gt;
&lt;td&gt;GPS gone, along with all EXIF and the ICC profile for JPEG/WebP; Orientation dropped without rotating, so sideways photos stay sideways&lt;/td&gt;
&lt;td&gt;A full lossy decode and encode per file per build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;piexif.remove()&lt;/td&gt;
&lt;td&gt;Removes all EXIF from JPEG without re-encoding; ICC kept; Orientation gone too&lt;/td&gt;
&lt;td&gt;Cheap, but you need to rotate first or portraits break&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete only the GPS IFD (details below)&lt;/td&gt;
&lt;td&gt;JPEG: pixels identical, 219 to 325 bytes smaller per file. PNG: needs a re-save&lt;/td&gt;
&lt;td&gt;Cheap for JPEG; PNG re-save grew the file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ExifTool -gps:all=&lt;/td&gt;
&lt;td&gt;Not run: ExifTool isn't installed on this machine&lt;/td&gt;
&lt;td&gt;One more binary on CI, and backup files to clean up&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The GPS-only route is what I'd pick. For JPEG it's a piexif job: load the EXIF, empty the GPS block, drop the pointer to it from the main IFD, and swap the APP1 segment back in without touching the pixels. PNG has no such shortcut, so the same step opens the file in Pillow, clears the GPS entry from its EXIF and saves it again, passing the ICC profile through. I ran it on the five flagged files and rescanned the whole folder: 11 files, 0 with GPS, 1 unreadable. Each JPEG got a little smaller: 219 bytes for the Pillow file that had kept its EXIF, 325 for the Madrid resize, and 232 for each of the two sips JPEGs.&lt;/p&gt;

&lt;p&gt;The PNG went the other way, from 24,387,228 to 25,081,704 bytes, because Pillow's re-save compressed it less tightly than sips had. I compared pixels before and after for the three sips files and all were identical, so nothing visible changed, but that's 0.7 MB more for nothing on a single image. It's also worth being honest about why the PNG came out clean: Pillow's PNG save didn't write the XMP or the sRGB chunk back, so the second copy of the GPS disappeared as a side effect I didn't plan. Luck, not design. For PNGs I'd now convert to a web format in the build anyway and never ship the 24 MB file.&lt;/p&gt;

&lt;p&gt;On ExifTool, I'm going by its official documentation only. &lt;code&gt;exiftool -gps:all= file.jpg&lt;/code&gt; removes the GPS group, and by default the tool keeps the original next to it with _original appended. In a deploy folder, that means the untouched file with full coordinates is sitting right beside the cleaned one and gets uploaded with everything else unless the build deletes it. For a static site that is the trap I'd most expect to fall into.&lt;/p&gt;

&lt;p&gt;The HEIC is still in the "unreadable" column, and I haven't solved it. For now the scanner failing on it is the answer: it's a signal to convert HEIC earlier in the pipeline, not to teach every step about HEIC. The scan itself is one Pillow import and runs on the CI box that already builds the site, so it costs me nothing beyond a few lines in the build config, which is the price range I like.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Page count is the wrong unit for estimating a PDF-to-text job</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Sat, 12 Sep 2026 08:04:47 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/page-count-is-the-wrong-unit-for-estimating-a-pdf-to-text-job-5f82</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/page-count-is-the-wrong-unit-for-estimating-a-pdf-to-text-job-5f82</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%2Fkoqn6yy2a3vu4v8eee0c.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%2Fkoqn6yy2a3vu4v8eee0c.png" alt=" " width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"How many pages?" is the first question anyone asks about turning a pile of PDFs into text, and it's the number that ends up in the estimate. I spent a day running seven English documents through a PDF extractor and counting, for each, how many places I'd have to touch afterwards. Pages were a poor predictor. One page of a book printed in 1734 needed more fixes than two pages of an arXiv paper, and what kind of fix it was mattered more than how many.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I counted
&lt;/h2&gt;

&lt;p&gt;A spot is one place I'd have to touch to turn the output into clean running text that matches the page: a wrong, missing or extra word, a line fused with its neighbour or cut into fake table cells, a word broken across a line end, two conflicting versions of one line, or a block in the wrong position. It counts once however many characters it covers. I left page furniture out (running heads, footers, page numbers) because it gets stripped anyway. Four samples are test files I generated myself with invented content, so I know them line by line. For two scanned public-domain books from Internet Archive I transcribed one printed page each and diffed word by word with Python's difflib, which counts a moved phrase twice, once where it went missing and once where it landed. The paper is RAG-Safety-Bench by Adithiyan Rajan Indira Saravanan and Kathleen C. Fraser (arXiv:2609.11758, CC BY 4.0), pages 1–2. Everything went through ImgIng's Extract PDF content with OCR on Auto, and each spot went into one of three buckets: a script can fix it, a person has to look, or editing is the wrong move and the page should be rendered and OCR'd again.&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="Stacked bar chart of spots to fix per sample, split into script-fixable, needs a person, and re-OCR instead. A2 2, B 6, C 3, D 5, arXiv 43, 1921 book page 41 (10 after re-OCR), 1734 book page 55 (15 after re-OCR)" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Blue is cheap
&lt;/h2&gt;

&lt;p&gt;The long bars are mostly blue. The arXiv pages have 42 words broken across a line end (&lt;code&gt;re-&lt;/code&gt; / &lt;code&gt;trieve&lt;/code&gt;), and on the 1921 page 34 justified lines came out as two Tab-separated cells, as if the paragraph were a table. A short script handles most of it with three rules. It joins a lowercase fragment ending in a hyphen to the lowercase word that opens the next line, and prints every pair it joined so a person can skim the list. On pages I've marked as plain prose, it turns each Tab back into a space. And it puts the space back between a name and a timestamp that got glued to it. On the arXiv pages that meant 40 joins, with two breaks inside the paper's own name left untouched. On the 1921 page it made 4 joins and replaced 34 Tabs. On the weekly report it only had 2 timestamps to fix.&lt;/p&gt;

&lt;p&gt;"Most" is carrying weight there. The join rule turned &lt;code&gt;open-&lt;/code&gt; / &lt;code&gt;source&lt;/code&gt; into &lt;code&gt;opensource&lt;/code&gt;, which is wrong, and it left both breaks inside &lt;code&gt;RAG-Safety-Bench&lt;/code&gt; alone because a capital follows. That's 3 of 42 that still need eyes, and it's why the script prints what it joined instead of doing it silently. Keeping the Tab rule to prose-only pages matters too: the same Tab rule on the weekly report would flatten its sales table, which the extractor had correctly split into columns. On that report the script only had to put the space back into &lt;code&gt;Leo Park14:09&lt;/code&gt; and &lt;code&gt;Leo Park14:20&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Orange is where the effort goes
&lt;/h2&gt;

&lt;p&gt;Orange spots are few, and each one needs someone who can see the page. On my one-page synthetic scan, the hidden text layer said "24 stores" and the image said "42 stores". The extractor kept both lines, which is the honest thing to do, but only a person can say which is right, and it also placed a recovered line above the heading.&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%2Fczqbrr3zi2t92kzn357m.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%2Fczqbrr3zi2t92kzn357m.png" alt="Extract PDF content result for a synthetic scan whose hidden text layer disagrees with the image: " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The two-column report had four lines fused with no separator (&lt;code&gt;Finance4. Result&lt;/code&gt;), so no regex knows where to cut. The weekly report's bar chart came out as &lt;code&gt;342 355 372 360&lt;/code&gt; and &lt;code&gt;310 295 _ _ 180 _&lt;/code&gt;: every number is there, and which day it belongs to is gone. One kanban card came back as &lt;code&gt;OPS-416&lt;/code&gt; with no title. The lecture notes lost ŷ entirely and read &lt;code&gt;e = y – .&lt;/code&gt;. Each of these is a single spot on the chart, and none of them can be found or fixed without the image open next to the text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Green means stop editing
&lt;/h2&gt;

&lt;p&gt;The 1734 page is the only green bar. The extractor used the book's embedded tesseract layer, and besides letter errors like &lt;code&gt;Hull&lt;/code&gt; and &lt;code&gt;Poiks&lt;/code&gt;, 22 spots are words that went missing or moved to another line. You can't repair a sentence whose words are shuffled without the page beside you, and at that point you're retyping. Rendering the page and running OCR on the image took it from 55 spots to 15. It isn't a universal cure: on the 1921 page re-OCR went from 41 spots to 10 only because the Tabs vanished, while its letter-level errors went from 3 to 6.&lt;/p&gt;

&lt;p&gt;The furniture I didn't count had its own problems. The lecture notes lost both page numbers, and the weekly report lost all three running headers and all three footers; I don't know why for the report, since my two-column file kept its headers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I look at before giving a number
&lt;/h2&gt;

&lt;p&gt;One page of each kind of document in the batch, run through the exact pipeline I'd use, spots sorted into those three buckets. Blue grows with volume but a script absorbs it. Orange grows with volume and needs a person, so it's the column I read first. Green says the pipeline is wrong for that document, and that has to change before counting means anything. Page count can't tell you which of these you're holding: the one-page synthetic scan had only 2 spots, and both were orange. The extractor I used is at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why free tiers of image compressors look the way they do (and what that means for a solo dev)</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Fri, 11 Sep 2026 08:05:49 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/why-free-tiers-of-image-compressors-look-the-way-they-do-and-what-that-means-for-a-solo-dev-1a8p</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/why-free-tiers-of-image-compressors-look-the-way-they-do-and-what-that-means-for-a-solo-dev-1a8p</guid>
      <description>&lt;p&gt;I run two small web products on my own, and I have a habit of reading pricing pages the way other people read changelogs. Image compressors are a nice little case study, because the free tiers look wildly different from each other, and once you ask "who pays for the CPU" the differences stop looking arbitrary.&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="Five image-compression tools compared (from public product info)" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Start with the upload-based services. TinyPNG gives you 500 images a month for free, capped at 5 MB each, and charges beyond that. compressor.io lets you go up to 10 MB per file but takes one file at a time. docsmall, which is big in the Chinese market, offers batch handling and PDF compression, keeps the free ceiling at 5 MB per file, and moves large files behind a membership. Three companies, three different shapes of "free", and all three limits are about the same thing: every file you send costs them server time and bandwidth. A monthly count, a per-file ceiling, or a one-at-a-time queue are three different ways of putting a lid on that cost.&lt;/p&gt;

&lt;p&gt;Now look at the tools that run in your browser. Squoosh from Google has no quota, no file cap in the commercial sense, and no account, because the encoding happens on your laptop and Google's bill for your usage is roughly a static page load. ImgIng at imging.ai follows the same logic with WASM builds of the encoders, which is why it can offer batch processing with no monthly count, no watermark, and no login: the compute is yours, so there is nothing to meter. The limit that remains is physical, in that a weak machine or a mobile browser will slow down on a large batch of AVIFs.&lt;/p&gt;

&lt;p&gt;So the industry pattern, at least as I read it, is that free tiers track the cost structure rather than the brand's generosity. Server-side tools meter something because they have to. Browser-side tools do not meter because there is nothing to meter. The interesting part is what each side does with that position.&lt;/p&gt;

&lt;p&gt;Server-side tools lean into the thing a browser cannot do well, which is unattended automation. TinyPNG's API is the obvious example: if your app ingests user uploads at runtime, you want a service with an SLA and a maintained SDK, and 500 free images is plenty to prototype the integration before it costs anything. docsmall leans into document workflows with PDF support. compressor.io covers SVG, which most of the others skip. Each of them has a scenario where paying is reasonable, and that scenario is usually "no human is present".&lt;/p&gt;

&lt;p&gt;Browser-side tools lean into the human-present workflow. Squoosh is the place to go for a single image where you want the full encoder lineup and a side-by-side viewer; that single-image experience is its strength and I am not going to pretend a batch tool replaces it. ImgIng takes the same local approach and widens it: drop a folder of mixed PNG, JPEG, WebP and animated GIFs, adjust quality and scale, and get the folder back, with files that would not shrink returned as-is instead of bloated. AVIF is produced with libavif on the client, the same encoder family Squoosh uses, so the ratio argument between the two is mostly a wash on one photo. The difference is scope, not quality.&lt;/p&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;Runs&lt;/th&gt;
&lt;th&gt;What "free" means&lt;/th&gt;
&lt;th&gt;Where it fits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TinyPNG&lt;/td&gt;
&lt;td&gt;server&lt;/td&gt;
&lt;td&gt;500/month, 5 MB each&lt;/td&gt;
&lt;td&gt;runtime pipelines via API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;compressor.io&lt;/td&gt;
&lt;td&gt;server&lt;/td&gt;
&lt;td&gt;10 MB per file, single&lt;/td&gt;
&lt;td&gt;SVG and one-off large files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docsmall&lt;/td&gt;
&lt;td&gt;server&lt;/td&gt;
&lt;td&gt;5 MB per file, big files paid&lt;/td&gt;
&lt;td&gt;Chinese teams, PDFs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Squoosh&lt;/td&gt;
&lt;td&gt;browser&lt;/td&gt;
&lt;td&gt;no limits&lt;/td&gt;
&lt;td&gt;one image, careful tuning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ImgIng&lt;/td&gt;
&lt;td&gt;browser&lt;/td&gt;
&lt;td&gt;no limits, batch&lt;/td&gt;
&lt;td&gt;folders, animation, private files&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What this means for a solo developer is a simple split. Pay for a server-side API where your users generate images and you are not around. Use a local tool for everything you touch by hand, because the marginal cost of that work is zero and the quota you would otherwise burn is better saved for the pipeline. I keep TinyPNG wired into one product's upload path and use ImgIng for every batch I process myself, and the monthly bill has stayed at the free tier for a year.&lt;/p&gt;

&lt;p&gt;One caveat so the argument does not overreach: "runs locally" is a per-format statement, not a blanket one. Common formats are handled in the browser; check the tool's own notes for anything unusual before assuming a file never left your machine. That is true of every client-side tool, ImgIng included.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I loaded a 24-page, 24-layer project into a browser tab to see it break</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Thu, 10 Sep 2026 08:03:44 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/i-loaded-a-24-page-24-layer-project-into-a-browser-tab-to-see-it-break-51b6</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/i-loaded-a-24-page-24-layer-project-into-a-browser-tab-to-see-it-break-51b6</guid>
      <description>&lt;p&gt;I wanted to know where a browser-based image editor actually falls over, so I built the worst project I reasonably could: 24 pages, one of them stacked to 24 layers, canvases pushed toward the 8192px ceiling. The hard limits are stated up front — up to 8192px per side, around 48 million pixels, 24 layers per canvas, 24 pages per project — and I wanted to feel what living at those limits is like, not just read the numbers off a spec.&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="Online Design workspace" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Online Design (imging.ai) runs entirely client-side; nothing you import is uploaded. That is reassuring for privacy, but it also means the tab's memory is the entire budget. A single 8192×5900 layer is roughly 48 megapixels, and at 4 bytes per pixel that is about 190 MB uncompressed — per layer. Twenty-four of those held simultaneously at full resolution would be several gigabytes, which no tab will tolerate. So the real question is not whether the tool is fast, it is how it avoids ever doing that.&lt;/p&gt;

&lt;p&gt;The answer is that it never holds everything at full resolution while you work. The editing preview is rendered at a resolution matched to your viewport, not the canvas. Dragging layers, reordering them, tweaking opacity on the busy 24-layer page all stayed responsive because I was manipulating a downscaled composite, not four-and-a-half gigabytes of pixels. Full resolution is only assembled at export time, and even then not all at once.&lt;/p&gt;

&lt;p&gt;Export is where a naive implementation would crash. Instead of allocating the entire full-resolution canvas plus every filtered intermediate, high-resolution work is done in horizontal strips: a band is composed and filtered, written out, and its memory released before the next band begins. Exporting the maxed-out page took time and the fan spun up, but the tab held steady. Peak memory tracks the size of a strip, not the size of the whole canvas — that is the entire trick, and everything else follows from it.&lt;/p&gt;

&lt;p&gt;Rough behaviour from the run:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Behaviour&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;24 pages, light layers&lt;/td&gt;
&lt;td&gt;Smooth, near-instant page switching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single 24-layer page, editing&lt;/td&gt;
&lt;td&gt;Responsive; preview stays fluid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8192px export, filtered&lt;/td&gt;
&lt;td&gt;Slow but completes, tab stable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two maxed pages open at once&lt;/td&gt;
&lt;td&gt;Noticeable lag, no crash&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you want to push it yourself, the flow is straightforward. Create a canvas at a large custom size, import images and stack layers up toward the 24 limit, add more pages up to 24, apply a filter to the whole canvas, then export — try both single-page PNG and the per-page ZIP. Watch the browser's task manager while the export runs and you will see memory climb during a strip and settle again between strips, rather than growing without bound until the tab dies.&lt;/p&gt;

&lt;p&gt;The three export routes behave differently under load, which is worth knowing before a big job. Exporting the current page is the lightest. The per-page ZIP walks all pages sequentially, so it stays flat in memory even across a 24-page project. The high-resolution multi-page HTML bundle is the heaviest but keeps everything in one file. For the maxed project the ZIP was the route I trusted most not to spike.&lt;/p&gt;

&lt;p&gt;The honest verdict: a browser tab will not match a desktop app with disk-backed scratch space, and you can feel that at the ceiling, where the maxed export is not instant. But it did not break, and the reason is architectural rather than luck. Adaptive preview keeps editing cheap, strip processing keeps export bounded, and per-page limits keep any single canvas inside what a tab can actually hold. The limits are not arbitrary — they are the numbers that stop it from becoming the thing that crashes.&lt;/p&gt;

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