Notes › Tech Notes › Resizing a GIF Keeps Only the First Frame: a PNG File Named .gif

Resizing a GIF Keeps Only the First Frame: a PNG File Named .gif

You spend half a day on an animated GIF, narrow the width from 200 to 100 so that it fits into a chat box, download the result, and find that it no longer moves. That is not a case of "shrunk so small that the motion is merely hard to see": open the file as binary and the first 8 bytes are 89 50 4E 47 0D 0A 1A 0A, the PNG signature, while a GIF is supposed to begin with GIF8. The file extension, though, is still .gif. I walked this path from its beginning, and the explanation sits further down the stack than I had expected.

Let me first lay out the facts that hold:

  • The source anim_2frame.gif weighs 597 B. A byte-level walk reads 2 graphic-control extensions and 2 image descriptors, with frame delays of 30 and 30. Inside the 1028 B output both of those block counts are 0, and the APNG acTL count is 0 as well.
  • The 5-frame fiveframe_rg.gif at 5223 B and the single-frame oneframe_rg.gif at 1071 B, fed the very same output size, return byte counts that are identical: 1028 B each.
  • Pulling the quality slider from 10 to 100 leaves all three GIF outputs at 1028 B; the same slider on a JPEG sample gives 2038 B and 54687 B.

Frame counts come neither from a decoder nor from counting raw bytes

In the template the words frame, delay, loop and fps appear 0 times, and drawImage occurs exactly once in the whole file (line 224): the act of picking a frame simply does not exist. There is only one repaint, drawing the entire <img> onto the canvas.

But "not written in the code" is not the same thing as "absent from the output". The frame count has to be measured. ImageDecoder cannot read frames out of this batch of samples in the build on my machine, so it is no evidence at all. Falling back to counting the GIF image descriptor marker 0x2C also breaks: a naive scan counts 9 of them on the 2912 B PNG output and 7 on the 3068 B one, because those bytes show up naturally inside zlib-compressed data. So the frame count always goes through a structured walk, and the chunk chain of the output (IHDR IDAT IDAT IEND) together with acTL=0 confirms that the file is neither an animated GIF nor an APNG.

The pixel side is more direct still: for twoframe_rg.gif the first frame is red and the second is pure blue, and after drawing, the canvas reports meanR=179.52 / meanB=0 with the blue channel pinned at 0; the samples taken at t0, t700 and t2200 are identical down to the last digit. What was drawn onto the canvas is the first frame.

Output size is fully decoupled from the source frame count

Source vs output bytes Source Output 597 1028 5223 1028 1071 1028 2 frames 5 frames 1 frame

Fig. 1 | Three animated GIFs differing in frame count and in source size; at one and the same output size every result stops at 1028 B. Grey bars are source bytes, green bars are output bytes, and the single-frame file is the control.

The size of the output depends only on the pixels sitting on the canvas, not on how many layers the source file had stacked up.

Request, file name and magic number each tell a different story

LevelMeasured
The mime line 227 hands to the encoderimage/gif
The download name line 246 assemblesanim_2frame_resized.gif
The first 8 bytes of the output89 50 4E 47 0D 0A 1A 0A
An explicit image/png request on the same canvas384 B, hash cf6a3a06, byte for byte identical to the row above

The fallback is not in the code. The format rewrite on line 228 covers only image/bmp, whereas GIF passes originalFile.type through unconditionally on line 227. A browser ships no GIF encoder at all, so toBlob quietly switches to PNG instead. The last row of the table is the proof: change the request to image/png and the length and the hash match digit for digit.

For GIF the quality slider is an empty slider

With the slider at 10, 90 and 100, line 229 parseInt(value) / 100 really does pass 0.1, 0.9 and 1 into toBlob, yet all three outputs are 1028 B with the same hash 5808201c, and the lowest setting is byte for byte the highest one. The same slider on the JPEG sample yields 2038 B and 54687 B. The slider is not broken; the PNG encoder never reads that argument.

An encoder in the room still drops the frames

"A browser has no GIF encoder" explains the format but not where the frames went. Use animated WebP as the control: the 338 B file carries 2 ANMF blocks, and its 620 B result is still genuine WebP (magic number 52 49 46 46) while counting 0 ANMF; the 582 B file carries 3 ANMF blocks and outputs 622 B with ANMF at 0 again. The encoder is demonstrably working and the animation control blocks still vanish completely, so the frames are lost on the canvas side. These two files are control samples only, not output formats a reader could choose.

Transparency outlives the animation

twoframe_rg.gif at 200x120 has 7104 pixels whose alpha falls below 255 (out of 24000); shrunk to 100x60 the figure is 1776 (out of 6000), and enlarged to 400x240 it is 29500 (out of 96000), where 2176 of them are semi-transparent pixels that resampling created along the transparency edge. The header of the output PNG reads depth=8 ctype=6 and the alpha channel stays intact: what went missing is the time dimension, while colour survives better than it did before.

The ceiling judges one side only, and a smaller size is not a smaller file

Line 216 reads w > 10000 || h > 10000, which checks a single edge, and there is no total-pixel ceiling anywhere in the code. Measured: 2000x2000 (4000000 pixels) gives an 84599 B output, and 4000x4000 (16000000 pixels) gives 316985 B; both of them requested image/gif and both magic numbers read PNG. File size behaves the same way: the 597 B file, emitted at its original 200x120, comes back as 1028 B, which is 431 B larger than the source; only after dropping to 100x60 does a result first fall below 597 B, at 384 B.

The extension counts for nothing, and failures stay silent

Admission depends solely on the regular expression against file.type on line 129; neither the extension nor the magic number takes part. A 5928 B PNG renamed to .gif is sniffed as image/gif, and once it decodes at 640x640 it walks the very same GIF export branch. Feedback for a broken file is zero: the template hooks only onload on line 141, onerror appears 0 times in the whole file, and both the 72 B corrupt.png and a 0 B GIF produced 0 toasts when measured. The size gate is strictly greater than 20971520 B, so it takes 20971521 B before the message on line 134 fires.

The page copy gets the first half right

The introduction on the tool page reads 本工具仅调整GIF第一帧的尺寸,不改变动画播放, rendered by line 66 of layout/tool.html, so readers really do see it. The first half agrees with the measurements, while the output refutes the second: not a single animation control block exists, which is not "left unchanged" but "absent". Nor should anyone assume that GIF receives special treatment, because the string gif shows up only 4 times in the whole file, all of them in the copy and the whitelist on lines 11, 12, 129 and 130. If you want frame by frame work, the post on splitting a GIF into single frames runs in the opposite direction and I will not repeat it here; if all you want is a new size for the first frame, resizing a GIF handles that step without trouble, you simply receive a PNG. This template is shared by five tools, and for a side by side comparison the generic image resize tool is the broader route.

Q: Can renaming the extension afterwards bring the animation back?

No. Renaming changes nothing but the name. The file never held a second frame, nor frame delays or a loop count, so walking the output returns an empty array.

Q: How should this output then be used?

Treat it as a static PNG: the alpha channel is there, the transparency edge of the first frame is there too, and it is easier to control than the original multi-frame GIF.

Q: Beyond width, height and quality, does any other setting keep the animation?

None. Width, height and quality are the only controls on the page, and for GIF the last one does not even influence the file size.

📎 Related articles

Factorial page says 0 to 1000 cannot overflow: Number loses exactness at 23! and only overflows at 171! 2026-10-27 A Single CJK Character Is Worth 5 bits: When Crack Time Turns Into Age of the Universe 2026-10-27 The ASCII in text-to-ASCII is really UTF-8: one Han character prints three numbers, and 100% cannot be reversed 2026-10-27 The GCD page prints the zero-remainder row too: step counts, fold order, and the 29-step Fibonacci worst case 2026-10-27 A BMP Goes In, a PNG Comes Out: 12342 Bytes Become 281, but the File Is Still Named .bmp 2026-10-27