<?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: Denny Guide</title>
    <description>The latest articles on DEV Community by Denny Guide (@dennyguide).</description>
    <link>https://dev.to/dennyguide</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%2F4189738%2Ffe4a9332-311b-4ae4-bcd9-c9009210908e.png</url>
      <title>DEV Community: Denny Guide</title>
      <link>https://dev.to/dennyguide</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dennyguide"/>
    <language>en</language>
    <item>
      <title>The palettegen GIF Trick Made My Files Bigger, Not Smaller — I Benchmarked What Actually Shrinks GIFs</title>
      <dc:creator>Denny Guide</dc:creator>
      <pubDate>Fri, 30 Oct 2026 08:44:13 +0000</pubDate>
      <link>https://dev.to/dennyguide/the-palettegen-gif-trick-made-my-files-bigger-not-smaller-i-benchmarked-what-actually-shrinks-4ali</link>
      <guid>https://dev.to/dennyguide/the-palettegen-gif-trick-made-my-files-bigger-not-smaller-i-benchmarked-what-actually-shrinks-4ali</guid>
      <description>&lt;p&gt;Every "how to make a GIF with FFmpeg" answer on the internet contains the same&lt;br&gt;
two-pass incantation: run your video through &lt;code&gt;palettegen&lt;/code&gt;, then pipe it back&lt;br&gt;
in with &lt;code&gt;paletteuse&lt;/code&gt;. The comments under those answers say things like&lt;br&gt;
"smaller file, better colors."&lt;/p&gt;

&lt;p&gt;So when I needed to shrink some demo GIFs this week, I ran the trick — and&lt;br&gt;
my files got &lt;strong&gt;bigger&lt;/strong&gt;. Not slightly bigger. Up to 117% bigger, at identical&lt;br&gt;
resolution and frame rate. So I benchmarked it properly, because either my&lt;br&gt;
FFmpeg was broken or the folk wisdom was.&lt;/p&gt;

&lt;p&gt;Spoiler: the folk wisdom conflates two different claims. The palette trick&lt;br&gt;
buys you &lt;em&gt;quality&lt;/em&gt;. It does not buy you &lt;em&gt;size&lt;/em&gt;. And once you know that, the&lt;br&gt;
actual size levers become obvious — they're just not the ones the&lt;br&gt;
copy-pasted answers emphasize.&lt;/p&gt;
&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Three synthetic 10-second clips, 1280×720, 30 fps, so everything is&lt;br&gt;
reproducible and nothing depends on a specific camera's noise profile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# screen-recording-like motion (sliders, bars, text)&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-f&lt;/span&gt; lavfi &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nv"&gt;testsrc2&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1280x720:rate&lt;span class="o"&gt;=&lt;/span&gt;30:duration&lt;span class="o"&gt;=&lt;/span&gt;10 &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuv420p &lt;span class="nt"&gt;-crf&lt;/span&gt; 20 screen.mp4

&lt;span class="c"&gt;# smooth color gradients, slowly zooming (worst case for a 256-color format)&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-f&lt;/span&gt; lavfi &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nv"&gt;mandelbrot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1280x720:rate&lt;span class="o"&gt;=&lt;/span&gt;30 &lt;span class="nt"&gt;-t&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuv420p &lt;span class="nt"&gt;-crf&lt;/span&gt; 20 gradient.mp4

&lt;span class="c"&gt;# hard edges, few colors (cellular automaton — the "flat UI animation" profile)&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-f&lt;/span&gt; lavfi &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nv"&gt;life&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1280x720:rate&lt;span class="o"&gt;=&lt;/span&gt;30 &lt;span class="nt"&gt;-t&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuv420p &lt;span class="nt"&gt;-crf&lt;/span&gt; 20 blocks.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FFmpeg 8.0 on macOS. Five conversion recipes per clip:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Recipe&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the naive one-liner: &lt;code&gt;fps=15,scale=640:-1&lt;/code&gt;, no palette pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;B&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the famous trick: two-pass &lt;code&gt;palettegen=stats_mode=diff&lt;/code&gt; + &lt;code&gt;paletteuse=dither=sierra2_4a&lt;/code&gt;, same &lt;code&gt;fps=15,scale=640:-1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;C&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;B, but &lt;code&gt;fps=10&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;D&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;B, but &lt;code&gt;scale=480:-1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;E&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;B at native 1280×720, 30 fps (the "just convert it" ceiling)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the record, the two commands behind B:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;.mp4 &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"fps=15,scale=640:-1:flags=lanczos,palettegen=stats_mode=diff"&lt;/span&gt; pal.png
ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;.mp4 &lt;span class="nt"&gt;-i&lt;/span&gt; pal.png &lt;span class="nt"&gt;-lavfi&lt;/span&gt; &lt;span class="s2"&gt;"fps=15,scale=640:-1:flags=lanczos [x];[x][1:v] paletteuse=dither=sierra2_4a"&lt;/span&gt; out.gif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Finding 1: the palette trick is a quality feature, not a compression feature
&lt;/h2&gt;

&lt;p&gt;All three clips, same filters, the &lt;em&gt;only&lt;/em&gt; difference is the palette pass:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Clip&lt;/th&gt;
&lt;th&gt;A (naive)&lt;/th&gt;
&lt;th&gt;B (palette)&lt;/th&gt;
&lt;th&gt;B vs A&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;screen&lt;/td&gt;
&lt;td&gt;2.05 MB&lt;/td&gt;
&lt;td&gt;3.37 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+64%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gradient&lt;/td&gt;
&lt;td&gt;10.95 MB&lt;/td&gt;
&lt;td&gt;17.56 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+60%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;blocks&lt;/td&gt;
&lt;td&gt;5.87 MB&lt;/td&gt;
&lt;td&gt;12.72 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+117%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The palette version is bigger. Every time. Why? Two reasons, both measurable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason one: dither noise.&lt;/strong&gt; &lt;code&gt;sierra2_4a&lt;/code&gt; is an error-diffusion dither. It&lt;br&gt;
sprays high-frequency noise across every pixel that isn't exactly&lt;br&gt;
representable in the 256-color palette — and GIF's LZW compression despises&lt;br&gt;
high-frequency noise. Switching the same palette pass to ordered dithering&lt;br&gt;
(&lt;code&gt;dither=bayer:bayer_scale=5&lt;/code&gt;) claws some of it back (3.37 → 3.03 MB on the&lt;br&gt;
screen clip) but never gets close to the naive path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason two: the naive path quietly throws colors away.&lt;/strong&gt; Count the&lt;br&gt;
distinct colors in the first frame of each output:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Clip&lt;/th&gt;
&lt;th&gt;A (naive)&lt;/th&gt;
&lt;th&gt;B (palette)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;screen&lt;/td&gt;
&lt;td&gt;184&lt;/td&gt;
&lt;td&gt;236&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gradient&lt;/td&gt;
&lt;td&gt;174&lt;/td&gt;
&lt;td&gt;255&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;blocks&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;18&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;221&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That &lt;code&gt;blocks&lt;/code&gt; row is the whole story in miniature. The cellular-automaton&lt;br&gt;
clip is mostly flat white and black with a handful of accent colors — and&lt;br&gt;
the default quantizer crushes it down to 18 effective colors, which LZW then&lt;br&gt;
compresses beautifully. The content-aware palette preserves 221 of them,&lt;br&gt;
which looks &lt;em&gt;better&lt;/em&gt; and compresses &lt;em&gt;worse&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So this is not a bug and I'm not dunking on the technique. If your source&lt;br&gt;
has gradients — screen recordings with soft shadows, anything photographic —&lt;br&gt;
the naive path will band into ugly stair-steps and the palette pass genuinely&lt;br&gt;
fixes that. But if you're reaching for &lt;code&gt;palettegen&lt;/code&gt; "to make the file&lt;br&gt;
smaller," you've been mislabeled by the comments section. It's a&lt;br&gt;
quality-for-size trade, priced at roughly +60–117%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: the levers that actually move size are fps and width
&lt;/h2&gt;

&lt;p&gt;Same palette recipe (B), one knob at a time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Clip&lt;/th&gt;
&lt;th&gt;B (640px, 15fps)&lt;/th&gt;
&lt;th&gt;fps→10&lt;/th&gt;
&lt;th&gt;width→480&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;screen&lt;/td&gt;
&lt;td&gt;3.37 MB&lt;/td&gt;
&lt;td&gt;2.32 MB (−31%)&lt;/td&gt;
&lt;td&gt;2.30 MB (−32%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gradient&lt;/td&gt;
&lt;td&gt;17.56 MB&lt;/td&gt;
&lt;td&gt;11.92 MB (−32%)&lt;/td&gt;
&lt;td&gt;10.19 MB (−42%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;blocks&lt;/td&gt;
&lt;td&gt;12.72 MB&lt;/td&gt;
&lt;td&gt;8.85 MB (−30%)&lt;/td&gt;
&lt;td&gt;8.43 MB (−34%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Dropping from 15 to 10 fps costs you a third of the size across the board,&lt;br&gt;
and for a UI demo or a loading animation, 10–12 fps is usually plenty —&lt;br&gt;
cursor trails get slightly choppier, nothing else changes. Width is the&lt;br&gt;
sharper lever on gradient-heavy content (−42%), because GIF stores every&lt;br&gt;
pixel of every frame; fewer pixels per frame compounds over 300 frames.&lt;/p&gt;

&lt;p&gt;Stack both (10 fps, 480px) and the gradient clip lands under 8 MB from a&lt;br&gt;
17.6 MB starting point — a bigger win than any palette-vs-no-palette choice,&lt;br&gt;
with zero quality debate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: know when GIF is simply the wrong format
&lt;/h2&gt;

&lt;p&gt;Recipe E converts at native 1280×720, 30 fps, full palette. The gradient&lt;br&gt;
clip — 10 seconds, remember — comes out at &lt;strong&gt;128.6 MB&lt;/strong&gt;. The MP4 it was made&lt;br&gt;
from is 9.7 MB. That's 13× the size for objectively worse color (256 colors&lt;br&gt;
dithered vs. the original).&lt;/p&gt;

&lt;p&gt;(Small honesty note: on the noisy &lt;code&gt;blocks&lt;/code&gt; clip the native-res GIF was 17.8&lt;br&gt;
MB against a 40.8 MB CRF-20 MP4 — GIF can "win" against a high-bitrate&lt;br&gt;
noisy source. But that's the exception profile: flat colors, zero gradients.&lt;br&gt;
If your content looks like a photograph or has any smooth shading, it is not&lt;br&gt;
that profile.)&lt;/p&gt;

&lt;p&gt;Modern answer for "a GIF but sane": MP4 or WebM in a &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; tag, or an&lt;br&gt;
animated WebP/AVIF if you need a single self-contained image file. Reserve&lt;br&gt;
actual GIF for the places that force it — README badges, Slack, email,&lt;br&gt;
that one legacy portal. And when it is forced, that's exactly when the&lt;br&gt;
fps/width discipline above earns its keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recipes, by what your clip looks like
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Screen recording / UI demo (flat colors):&lt;/strong&gt; naive path, no palette —
&lt;code&gt;fps=12,scale=640:-1&lt;/code&gt;. Smallest file, and flat content survives the
default quantizer just fine (184 colors on my test clip, no visible loss).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything with gradients or photographic content:&lt;/strong&gt; palette pass with
&lt;code&gt;dither=bayer:bayer_scale=5&lt;/code&gt;, then claw size back with &lt;code&gt;fps=10&lt;/code&gt; and
480–640px width. Accept that quality costs bytes here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both kinds, and you need it under a hard limit&lt;/strong&gt; (say an email
attachment or a 10 MB upload cap): drop to &lt;code&gt;fps=10&lt;/code&gt;, &lt;code&gt;scale=480:-1&lt;/code&gt; &lt;em&gt;and&lt;/em&gt;
trim the clip to the 3–5 seconds that matter. Cutting duration is the
one lever stronger than every other lever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more practical note: if the clip is a screen demo, check what's in it&lt;br&gt;
before uploading it to any converter — cursor paths and notifications leak&lt;br&gt;
context. The converter I keep bookmarked for this&lt;br&gt;
(&lt;a href="https://www.iloveconvert.com/en/convert/mp4-to-gif/" rel="noopener noreferrer"&gt;iLoveConvert's MP4 to GIF page&lt;/a&gt;)&lt;br&gt;
auto-deletes uploads within 24 hours and doesn't demand an account, which is&lt;br&gt;
the baseline I hold any such tool to, but "deleted" doesn't help with a&lt;br&gt;
screenshot of a customer record that's now in a GIF you pasted into a public&lt;br&gt;
issue thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The two-pass palette recipe is good FFmpeg advice with a bad reputation. It&lt;br&gt;
makes GIFs &lt;em&gt;look&lt;/em&gt; better and &lt;em&gt;weigh&lt;/em&gt; more. When your problem is weight, the&lt;br&gt;
cheap wins are boring: fewer frames, fewer pixels, fewer seconds. Measure&lt;br&gt;
instead of trusting the comments — it's two commands and a table like the&lt;br&gt;
one above.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I benchmark file-format tooling and write up the numbers — previously:&lt;br&gt;
500-image PNG→JPG conversion benchmarks, batch audio extraction from video,&lt;br&gt;
and five ways to turn PDF pages into images, all with reproducible scripts.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ffmpeg</category>
      <category>gif</category>
      <category>video</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>5 Ways to Turn PDF Pages Into PNG Images — I Benchmarked Them on a 20-Page Document</title>
      <dc:creator>Denny Guide</dc:creator>
      <pubDate>Mon, 26 Oct 2026 07:44:47 +0000</pubDate>
      <link>https://dev.to/dennyguide/5-ways-to-turn-pdf-pages-into-png-images-i-benchmarked-them-on-a-20-page-document-31dd</link>
      <guid>https://dev.to/dennyguide/5-ways-to-turn-pdf-pages-into-png-images-i-benchmarked-them-on-a-20-page-document-31dd</guid>
      <description>&lt;p&gt;Every few weeks someone asks a variant of the same question: "how do I get these PDF pages&lt;br&gt;
into PNG images?" — for slides thumbnails, for embedding pages in docs, for feeding pages&lt;br&gt;
into an OCR model. There are a lot of answers, and most of them are slower than they need&lt;br&gt;
to be.&lt;/p&gt;

&lt;p&gt;I generated a synthetic 20-page PDF (US Letter, text + vector shapes) and ran the&lt;br&gt;
contenders at 150 DPI on the same machine. Here's what I found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;20 pages @150 DPI&lt;/th&gt;
&lt;th&gt;Throughput&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;macOS Preview / Acrobat (manual export)&lt;/td&gt;
&lt;td&gt;~2 min of clicking&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pdftoppm&lt;/code&gt; (poppler)&lt;/td&gt;
&lt;td&gt;4.1 s&lt;/td&gt;
&lt;td&gt;4.8 pages/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ghostscript one-liner&lt;/td&gt;
&lt;td&gt;0.9 s&lt;/td&gt;
&lt;td&gt;21.2 pages/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PyMuPDF (Python)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.7 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29.4 pages/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web converter&lt;/td&gt;
&lt;td&gt;upload-bound&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The spread surprised me: the "standard" poppler route is &lt;strong&gt;6× slower&lt;/strong&gt; than PyMuPDF on the&lt;br&gt;
same document.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Preview / Acrobat — for one or two pages
&lt;/h2&gt;

&lt;p&gt;Fine for a single page. Painful for 20. If you're exporting more than a handful of pages,&lt;br&gt;
anything scripted below wins immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;pdftoppm&lt;/code&gt; — the default answer
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pdftoppm &lt;span class="nt"&gt;-png&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; 150 input.pdf page
&lt;span class="c"&gt;# -&amp;gt; page-01.png, page-02.png, ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Preinstalled almost everywhere, dead simple, and slow — 4.1 s for 20 pages here. It's also&lt;br&gt;
what the popular &lt;code&gt;pdf2image&lt;/code&gt; Python package shells out to under the hood, so that package&lt;br&gt;
inherits the same speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Ghostscript — the quiet workhorse
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gs &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-dNOPAUSE&lt;/span&gt; &lt;span class="nt"&gt;-dBATCH&lt;/span&gt; &lt;span class="nt"&gt;-sDEVICE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;png16m &lt;span class="nt"&gt;-r150&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
   &lt;span class="nt"&gt;-sOutputFile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;page-%02d.png input.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One process renders every page (the &lt;code&gt;%02d&lt;/code&gt; pattern handles numbering), at 21 pages/s.&lt;br&gt;
Already installed on most servers.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. PyMuPDF — the speed winner
&lt;/h2&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;pymupdf&lt;/span&gt;

&lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pymupdf&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;input.pdf&lt;/span&gt;&lt;span class="sh"&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;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;pix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_pixmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matrix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pymupdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Matrix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# 150 DPI
&lt;/span&gt;    &lt;span class="n"&gt;pix&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;02&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.png&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;29 pages/s, no subprocess overhead, and you control the DPI with a zoom matrix&lt;br&gt;
(150/72 ≈ 2.08× — a 612×792 pt page becomes 1275×1650 px, ~187 KB per page here).&lt;br&gt;
If you're doing anything programmatic — thumbnails, OCR preprocessing, page previews —&lt;br&gt;
this is the one to pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A web converter — when you can't install anything
&lt;/h2&gt;

&lt;p&gt;Locked-down work laptop, one-off task, or the PDF lives on your phone? Then a browser&lt;br&gt;
tool is the pragmatic choice; I've used&lt;br&gt;
&lt;a href="https://www.iloveconvert.com/en/convert/pdf-to-png/" rel="noopener noreferrer"&gt;iloveconvert.com's PDF to PNG converter&lt;/a&gt;&lt;br&gt;
for exactly this — it bundles multi-page output and saves you the local setup entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;For scripting: PyMuPDF, ~6× faster than the poppler default.&lt;/li&gt;
&lt;li&gt;For a server you already SSH into: the Ghostscript one-liner is already there.&lt;/li&gt;
&lt;li&gt;Save yourself the manual Preview export — anything past 2 pages isn't worth the clicks.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>pdf</category>
      <category>python</category>
      <category>automation</category>
      <category>tooling</category>
    </item>
    <item>
      <title>I Extracted the Audio From 20 Video Files in 2.2 Seconds — Here's the Batch Pipeline</title>
      <dc:creator>Denny Guide</dc:creator>
      <pubDate>Mon, 19 Oct 2026 01:23:34 +0000</pubDate>
      <link>https://dev.to/dennyguide/i-extracted-the-audio-from-20-video-files-in-22-seconds-heres-the-batch-pipeline-1poc</link>
      <guid>https://dev.to/dennyguide/i-extracted-the-audio-from-20-video-files-in-22-seconds-heres-the-batch-pipeline-1poc</guid>
      <description>&lt;p&gt;I help maintain a small archive of recorded meetings and talks. The videos are useful, but&lt;br&gt;
nobody re-watches a 40-minute recording to find one decision. What people actually want is&lt;br&gt;
the audio — to listen on the go, or to feed into a transcription tool.&lt;/p&gt;

&lt;p&gt;So I built a tiny batch pipeline that rips the audio track out of every video in a folder&lt;br&gt;
and throws the video away. Here's what surprised me: for pure audio extraction, the whole&lt;br&gt;
job is &lt;strong&gt;I/O-bound on your disk, not CPU-bound on your codec&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The one-liner that does all the work
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;.mp4&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-vn&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;:a libmp3lame &lt;span class="nt"&gt;-q&lt;/span&gt;:a 4 &lt;span class="s2"&gt;"out/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;f&lt;/span&gt;&lt;span class="p"&gt;%.mp4&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.mp3"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two flags matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-vn&lt;/code&gt; — drop the video stream entirely. FFmpeg skips decoding most of the video, which
is where the speed comes from.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-c:a libmp3lame -q:a 4&lt;/code&gt; — re-encode to MP3 at VBR quality 4 (~165 kbps, transparent
for speech). If your source audio is already AAC and you don't need MP3 specifically,
use &lt;code&gt;-c:a copy&lt;/code&gt; and it's even faster.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Real numbers from my machine
&lt;/h2&gt;

&lt;p&gt;I generated 20 test videos (30 s each, 720p H.264 + AAC, 240.8 MB total) and ran the loop&lt;br&gt;
on an M-series MacBook with FFmpeg 8.0:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;20 files, sequential loop&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.2 s total&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Throughput&lt;/td&gt;
&lt;td&gt;~9.3 files/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size before&lt;/td&gt;
&lt;td&gt;240.8 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size after (MP3 q=4)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.4 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's a &lt;strong&gt;99% size reduction&lt;/strong&gt;. The MP3s are what you actually want to keep around long&lt;br&gt;
term; the video only matters if someone asks for the slides.&lt;/p&gt;
&lt;h2&gt;
  
  
  Wrapping it in Python (for the pipeline part)
&lt;/h2&gt;

&lt;p&gt;A shell loop is fine for one folder. If this runs on a schedule — new recordings land in&lt;br&gt;
&lt;code&gt;inbox/&lt;/code&gt;, processed files move to &lt;code&gt;done/&lt;/code&gt; — a thin Python wrapper keeps the state machine&lt;br&gt;
honest:&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;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shutil&lt;/span&gt;

&lt;span class="n"&gt;INBOX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DONE&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="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mp3&lt;/span&gt;&lt;span class="sh"&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;src&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;INBOX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OUT&lt;/span&gt; &lt;span class="o"&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;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.mp3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&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;ffmpeg&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;-y&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;-i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-vn&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;-c:a&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;libmp3lame&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;-q:a&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;4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
        &lt;span class="n"&gt;check&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;capture_output&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="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;shutil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;move&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;DONE&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&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;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stat&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;st_size&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; KB)&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;&lt;code&gt;check=True&lt;/code&gt; means a corrupt file stops the batch instead of silently producing an empty&lt;br&gt;
MP3 — which matters when the next stage is a transcription API billing you by the minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I don't reach for FFmpeg at all
&lt;/h2&gt;

&lt;p&gt;FFmpeg is the right answer for anything recurring. But for one-off files — someone sends&lt;br&gt;
you a single recording and you just need the MP3 to drop into a notes app — installing a&lt;br&gt;
codec stack is overkill. For those cases I use a web converter instead, e.g.&lt;br&gt;
&lt;a href="https://www.iloveconvert.com/en/convert/mp4-to-mp3/" rel="noopener noreferrer"&gt;iloveconvert.com's MP4 to MP3 tool&lt;/a&gt;,&lt;br&gt;
which handles the common formats without any local setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;-vn&lt;/code&gt; is the speed lever. Don't decode video you're going to discard.&lt;/li&gt;
&lt;li&gt;Sequential is fine: at 9 files/s, parallelism adds complexity for no felt gain.&lt;/li&gt;
&lt;li&gt;Keep the extracted audio, archive or delete the video — 99% of the bytes were pixels
you'll never miss.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>ffmpeg</category>
      <category>python</category>
      <category>automation</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Batch-Converting 500 PNGs to JPG: I Timed My Pillow Script Against the Alternatives</title>
      <dc:creator>Denny Guide</dc:creator>
      <pubDate>Sat, 17 Oct 2026 02:56:53 +0000</pubDate>
      <link>https://dev.to/dennyguide/batch-converting-500-pngs-to-jpg-i-timed-my-pillow-script-against-the-alternatives-dm8</link>
      <guid>https://dev.to/dennyguide/batch-converting-500-pngs-to-jpg-i-timed-my-pillow-script-against-the-alternatives-dm8</guid>
      <description>&lt;p&gt;Every few months I get the same folder: a few hundred PNGs exported from a design tool, all needing to be JPG before they can go into a CMS that has a hard 25 MB per-image cap. PNGs of screenshots and photos are heavy, and nobody on the content team is going to open an image editor 500 times.&lt;/p&gt;

&lt;p&gt;So I have a script for this. But the last time I posted it, someone asked the obvious question: "is that actually faster than just using an online converter?" Fair. Let's measure it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The task
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;500 PNG files, 800×600&lt;/li&gt;
&lt;li&gt;About 20% have an alpha channel (logos/icons), the rest are opaque&lt;/li&gt;
&lt;li&gt;Target: JPG, quality 85, white background where alpha gets flattened&lt;/li&gt;
&lt;li&gt;Don't touch the source files&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The script
&lt;/h2&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;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;

&lt;span class="n"&gt;SRC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src&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;out&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;makedirs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exist_ok&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;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SRC&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;continue&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;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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SRC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="c1"&gt;# Gotcha 1: JPEG has no alpha channel. RGBA/LA/P images must be
&lt;/span&gt;    &lt;span class="c1"&gt;# flattened onto a background first, or Pillow raises
&lt;/span&gt;    &lt;span class="c1"&gt;# "cannot write mode RGBA as JPEG".
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&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;RGBA&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;LA&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;P&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;rgba&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;img&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;RGBA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;bg&lt;/span&gt; &lt;span class="o"&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;new&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;rgba&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&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="mi"&gt;255&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;bg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;paste&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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="c1"&gt;# alpha as the mask
&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;bg&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&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;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;img&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="c1"&gt;# Gotcha 2: write to a different directory, never in place.
&lt;/span&gt;    &lt;span class="c1"&gt;# A buggy quality setting shouldn't be able to destroy originals.
&lt;/span&gt;    &lt;span class="n"&gt;img&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&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="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.jpg&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;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="mi"&gt;85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;optimize&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="c1"&gt;# ~5-10% smaller, costs a bit of CPU
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The three gotchas, because they always bite someone
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Alpha flattening.&lt;/strong&gt; The naive &lt;code&gt;img.convert("RGB")&lt;/code&gt; on an RGBA image doesn't composite onto a background — it just &lt;em&gt;drops&lt;/em&gt; the alpha channel. Transparent areas turn black, and you find out after the batch finished. The &lt;code&gt;paste&lt;/code&gt; with the alpha band as mask is the fix; white is usually what you want for web, but for dark sites use a dark background.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Palette mode (&lt;code&gt;P&lt;/code&gt;).&lt;/strong&gt; GIF-style indexed PNGs show up in the wild more than you'd think, and they're in the same "can't save directly as JPEG" bucket. The mode check above catches them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Never convert in place.&lt;/strong&gt; Same input/output directory plus a glob that matches both &lt;code&gt;.png&lt;/code&gt; and &lt;code&gt;.jpg&lt;/code&gt; is how people destroy their originals. Separate &lt;code&gt;out/&lt;/code&gt; folder, always.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Ran on my M-series MacBook, Python 3.13, Pillow 12.2:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Files&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wall time&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6.7 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Throughput&lt;/td&gt;
&lt;td&gt;~75 images/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Input total&lt;/td&gt;
&lt;td&gt;744.7 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output total&lt;/td&gt;
&lt;td&gt;171.7 MB (&lt;strong&gt;23% of input&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Worst-case input, by the way: these were noise images, which PNG compresses terribly. With real screenshots and photos the ratio is usually closer to 60–70%, because PNG is even more hopeless on photographic content than on noise.&lt;/p&gt;

&lt;p&gt;CPU during the run: one core pegged, the rest idle. You could parallelize with a &lt;code&gt;ProcessPoolExecutor&lt;/code&gt; and probably hit 4–6× that, but at 6.7 seconds for 500 images, why would you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Update: per-file p50/p95, because a reader rightly pushed back
&lt;/h2&gt;

&lt;p&gt;A commenter noted my batch average answers "is the whole folder fast" but not "will &lt;em&gt;this&lt;/em&gt; file be fast," and predicted the tails would live in the alpha branch — compositing cost scales with pixel count in a way plain conversion doesn't. Half right, and the half that was wrong turned out to be the interesting part.&lt;/p&gt;

&lt;p&gt;Re-ran with per-file timing: same machine, noise images, n=60 per cell, four size buckets, alpha and opaque separated:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bucket&lt;/th&gt;
&lt;th&gt;MP&lt;/th&gt;
&lt;th&gt;Alpha?&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16×16&lt;/td&gt;
&lt;td&gt;0.0003&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;0.2 ms&lt;/td&gt;
&lt;td&gt;0.7 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16×16&lt;/td&gt;
&lt;td&gt;0.0003&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;0.15 ms&lt;/td&gt;
&lt;td&gt;0.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;240×240&lt;/td&gt;
&lt;td&gt;0.058&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;2.2 ms&lt;/td&gt;
&lt;td&gt;2.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;240×240&lt;/td&gt;
&lt;td&gt;0.058&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;1.9 ms&lt;/td&gt;
&lt;td&gt;2.2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;800×600&lt;/td&gt;
&lt;td&gt;0.48&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;14.3 ms&lt;/td&gt;
&lt;td&gt;16.0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;800×600&lt;/td&gt;
&lt;td&gt;0.48&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;12.3 ms&lt;/td&gt;
&lt;td&gt;15.9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4000×3000&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;305 ms&lt;/td&gt;
&lt;td&gt;330 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4000×3000&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;296 ms&lt;/td&gt;
&lt;td&gt;307 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What the table actually says:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Megapixels, not alpha, own the tail.&lt;/strong&gt; Per-file time spans 0.2 ms → 305 ms (~1500×) across buckets; within a bucket, alpha vs opaque differs by 3–16%. Your p95 is decided by "how big is your biggest file."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The alpha tax shrinks as files grow.&lt;/strong&gt; +15–16% at icon sizes, where the extra paste-onto-white is proportionally large, but only +3% at 12 MP, where JPEG encode dominates everything else. "Alpha files are slow" is only true for files that are fast anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Within-bucket variance is tight&lt;/strong&gt; — p95 ≈ 1.1× p50 everywhere except the 16px bucket, where the absolute numbers are sub-millisecond noise. The spread in my original 6.7 s figure came from the file mix, not flaky per-file behavior.&lt;/li&gt;
&lt;li&gt;For sizing your own batch on comparable hardware: budget &lt;strong&gt;~25–40 ms per megapixel&lt;/strong&gt;, and treat alpha as a rounding error unless everything you own is tiny icons.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Updated Oct 2026: added per-file p50/p95 by alpha channel and size bucket after feedback in the comments.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When the script is the wrong tool
&lt;/h2&gt;

&lt;p&gt;Honesty time — "I have a script" is not always the right answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You're on someone else's machine.&lt;/strong&gt; Installing Python to convert one folder is using a cannon to kill a mosquito.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's a one-off.&lt;/strong&gt; If you'll do this twice a year, writing and debugging a script is slower than the task itself, even the first time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A non-technical teammate needs it.&lt;/strong&gt; "Run my Python script" is not a deliverable. "Open this page, drop the folder" is.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For those cases I keep a browser bookmark around — recently I've been using &lt;a href="https://www.iloveconvert.com/en/convert/png-to-jpg/" rel="noopener noreferrer"&gt;iLoveConvert's PNG to JPG tool&lt;/a&gt; for exactly this shape of problem: no signup, files auto-deleted after 24 hours, and it takes a whole folder in one drop. It's obviously not beating 75 images/s on my own hardware, but it doesn't need to — it just needs to work on any machine in ten seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule of thumb
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Recurring batch + your own machine → script.&lt;/strong&gt; It's faster, free, offline, and you control the quality settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-off, foreign machine, or handing to someone else → online tool.&lt;/strong&gt; The 30 seconds you save by not fiddling matters more than raw throughput.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The script above is deliberately boring. Boring is good: it's the version that still runs in three years when Pillow has renamed something else.&lt;/p&gt;

</description>
      <category>python</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
