<?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: Zengzihe</title>
    <description>The latest articles on DEV Community by Zengzihe (@fatfish516).</description>
    <link>https://dev.to/fatfish516</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%2F4239047%2F86470633-d81b-43bf-8d0a-7a7b5715921d.png</url>
      <title>DEV Community: Zengzihe</title>
      <link>https://dev.to/fatfish516</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fatfish516"/>
    <language>en</language>
    <item>
      <title>Extracting GIF frames in the browser without uploading the file</title>
      <dc:creator>Zengzihe</dc:creator>
      <pubDate>Wed, 11 Nov 2026 13:09:18 +0000</pubDate>
      <link>https://dev.to/fatfish516/extracting-gif-frames-in-the-browser-without-uploading-the-file-43aj</link>
      <guid>https://dev.to/fatfish516/extracting-gif-frames-in-the-browser-without-uploading-the-file-43aj</guid>
      <description>&lt;p&gt;Most online GIF tools ask you to upload the file. For a quick crop or a frame export, that is a strange trade: the file is often small, the task is simple, and it may contain something you would rather not send to a stranger's server.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://gifkit.net" rel="noopener noreferrer"&gt;gifkit&lt;/a&gt; as a browser-only alternative. The compression and cropping parts are straightforward. Frame extraction was not. This post is about why.&lt;/p&gt;

&lt;h2&gt;
  
  
  GIF is a compositing format, not a frame sequence
&lt;/h2&gt;

&lt;p&gt;It is tempting to treat a GIF as a list of images. Real GIFs do not work that way.&lt;/p&gt;

&lt;p&gt;A GIF has a logical screen (the canvas), and each frame is a rectangle placed somewhere on that canvas. A frame can be smaller than the canvas, can have an offset, and can use transparency. The previous frame is not necessarily replaced: the graphic control extension carries a &lt;em&gt;disposal method&lt;/em&gt; that tells the decoder what to do with the area before drawing the next frame.&lt;/p&gt;

&lt;p&gt;The four disposal values matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;0&lt;/code&gt; — unspecified&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;1&lt;/code&gt; — do not dispose: leave the frame in place&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;2&lt;/code&gt; — restore to background: clear the frame's rectangle&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;3&lt;/code&gt; — restore to previous: put back what was there before the frame&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add interlacing (four passes over the rows), local versus global color tables, and frames that only contain the changed pixels, and a naive "decode each frame as an image" approach starts producing garbage on real files.&lt;/p&gt;

&lt;h2&gt;
  
  
  A streaming compositor
&lt;/h2&gt;

&lt;p&gt;The extractor keeps one canvas for the logical screen and processes frames in order. For each frame:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Apply the previous frame's disposal to the canvas.&lt;/li&gt;
&lt;li&gt;Draw the current frame's pixels at its offset, skipping transparent pixels.&lt;/li&gt;
&lt;li&gt;Snapshot the canvas as a PNG.&lt;/li&gt;
&lt;li&gt;Save only what the next frame needs (the previous canvas for disposal 3, not every decoded frame).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In pseudocode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;applyDisposal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previousFrame&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;drawFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// respects offset + transparency&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;png&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'image/png'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;png&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;previousFrame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is what is &lt;em&gt;not&lt;/em&gt; kept: the decoded pixels of every frame. A 25 MB GIF can expand to hundreds of megabytes if you decode everything at once. Streaming keeps memory bounded by the canvas plus the current frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ZIP is the second memory trap
&lt;/h2&gt;

&lt;p&gt;"Download all frames as a ZIP" is the feature that breaks the streaming model: a ZIP needs every PNG, and every PNG needs memory.&lt;/p&gt;

&lt;p&gt;The approach I used is a store-mode ZIP with a hard budget. Frames are appended as they are produced; if the retained PNGs cross the budget, the ZIP is disabled and the page offers single frames instead, re-encoding each one on demand. The user gets a clear failure mode instead of a tab that dies.&lt;/p&gt;

&lt;p&gt;Guards around the decoder do the same job: a maximum input size, a maximum canvas dimension, and a maximum frame count. Unsupported or truncated files are refused rather than half-decoded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compression is the easy part (until you promise a size)
&lt;/h2&gt;

&lt;p&gt;The compressor reduces the palette and searches for a setting that meets a target such as Discord's 256 KB. It is lossy, and it does not resize. Both facts are on the page, because the honest failure mode — "the smallest size I could reach" — is more useful than a promise the tool cannot keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Correctness before features: disposal and transparency are the whole problem; a fast decoder that ignores them is useless.&lt;/li&gt;
&lt;li&gt;Memory is a product constraint, not an implementation detail: the ZIP budget and the input guards are part of the UX.&lt;/li&gt;
&lt;li&gt;Say what the tool cannot do. "Lossy, no resize, common GIF structures only" prevents more support requests than any FAQ.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is &lt;a href="https://gifkit.net" rel="noopener noreferrer"&gt;gifkit&lt;/a&gt;: five small browser-based tools — compress, crop, speed, frame extraction, typing text — with no signup and no upload.&lt;/p&gt;

&lt;p&gt;If you work with GIFs in docs, Discord/Slack, or design handoffs, I would like to know where it fails. That is the most useful feedback I can get.&lt;/p&gt;

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