When an animation plays half a beat late, the player is usually innocent
Six interface screenshots become one short animation: you type 137 ms into the frame delay box, download the file and measure it on a timeline, and every frame sits there for 140 ms. Those three milliseconds were not lost, the format collected them, because the delay field inside a GIF is counted in hundredths of a second and no finer grid exists. Two other things on the same page follow something besides your input: the canvas follows whichever image happens to be first, and part of the downloaded size is pure zero padding. Those three facts together explain the late playback, the file that changes when you reorder the thumbnails, and the tiny images whose output looks absurdly large.
How 137 ms becomes 14 ticks
On the Images to GIF page the delay value passes through several stations before anything reaches the file. Line 21 of the template declares the input with min=50, max=3000 and step=50. Lines 278–282 clamp the number first. Line 184 then runs parseInt(value) || 300. Line 228 hands a whole number of milliseconds to gif.js, and inside the worker Math.round(137 / 10) turns it into ticks, which are serialised as a little-endian 2-byte delay field.
I pushed 22 values through that chain and read the tick count straight from bytes 4 and 5 of the Graphics Control Extension in the produced file. Typing 137 gives tick 14, so the frame really holds 140 ms, three milliseconds late; typing 333 gives tick 33 and lands on 330 ms, three short. The largest absolute error measured anywhere was 5 ms, and it is exactly 0 whenever the value you typed is already a multiple of 10. step=50 is only an attribute: the code never asks whether the element currently passes validity, so a value of 137 still enters the encoder while the element reports :invalid.
Clamp truth table: 49 and abc both land on 50, but 1e3 survives
The copy on the page advertises the range as 100ms ~ 3000ms, yet line 21 sets min=50 and the lower clamp written on line 280, if (v < 50), also uses 50, so the whole band between 50 and 99 is reachable although nothing on screen mentions it. Deliberately dirty values fed into that same box produced the rows below, each one read back from the finished file.
| Value typed | Delay in effect | Ticks stored | Hold per frame |
|---|---|---|---|
49 / 0 / -5 | 50 ms | 5 | 50 ms |
12.5 / empty string / abc | 50 ms | 5 | 50 ms |
3001 / 100000 | 3000 ms | 300 | 3000 ms |
1e3 | 1 ms | 0 | 0, and it never falls back to 300 |
The empty string row looks like a fallback case and is not one. "" < 50 coerces the empty value to a number, the test succeeds, and the value is clamped straight to 50, so the || 300 safety net never gets a turn. The single spelling that slips past the clamp entirely is 1e3: both comparisons evaluate to NaN, neither is true, the string survives every guard, and parseInt("1e3") stops at the exponent and reads 1. The tick becomes 0, the delay recorded in the file really is 0, and no warning says so.
The frame rate shown in the preview is not the delay stored in the file
Line 167 computes the readout from the milliseconds the user typed, through (1000 / delay).toFixed(1), so entering 333 shows 3.0 frames per second while the exported file is built from tick×10 ms; the two agree only on values that are already a multiple of 10. The preview is not playing the encoded result either: lines 153–171 keep the original image array, scale every frame separately using the rule on line 159 and rewrite the canvas dimensions as they go, whereas the finished file is normalised on line 183 to the size of the first image, letterboxed in black and centred.
The canvas follows the first image, so reordering rewrites everything
Line 183 reads imgList[0].w and imgList[0].h, the first entry in the list and never the largest. With a 400×400 image in front, a 200×120 image that joins the set is stretched by a factor of 2.0 to cover the frame through the argument list [0,80,400,240]; swap the opener for that 200×120 image and the 400×400 one is squeezed into [40,0,120,120], leaving 40 px of black on each side.
The thumbnails can be dragged into a new order, and the whole file is rebuilt around the new leader: move the 200×120 image that had been last into position 1 and the canvas changes from 400×400 into 200×120, while the blob drops from 26267 bytes to 14660 bytes. To control the canvas, put the image that should define it first. Splitting an animation into frames is a separate job, covered under exporting frame by frame; photo selection and layout belong to the collage line.
In the same drawing pass, lines 217–219 clear the canvas for every frame and paint a #000000 base underneath, so a source with an alpha channel is written into the buffer as [0,0,0,255]. The transparent option gif.js supports is never used here, so the output has no transparent index, only black backing.
Download size: 4096-byte page padding and a palette that tracks the frame count
Two lengths must be kept apart. blob.size is what you download, realBytes is the file that actually exists up to the trailer byte 0x3B, and the difference is entirely 0x00 padding, produced by the ByteArray inside gif.js whose pageSize=4096. A 1×1 single frame measured real 826 bytes against a blob of 4096 bytes, so 3270 bytes of padding, or 80.0% of the download. Add frames or pixels and that share melts below 2%: a 10-frame gradient measured real 206833 and blob 210728, leaving 1.8%. The 80.0% figure belongs to the 1×1 single frame alone and is not a general ratio.
Two other bills track the frame count rather than the picture. The header is always 781 B: the GIF89a signature contributes 6 bytes, the logical screen descriptor 7 and the global palette 768, and none of it grows with frames. Frame 1 reuses the global palette, but from frame 2 on every frame carries its own 768 B local palette, so localPaletteTotal equals (N-1)×768 exactly, and at 40 frames that is 29952 B of nothing but palette. Same canvas, same frame count, only the content swapped: the LZW payload per frame is 678 B for a flat colour, 24829 B for a photograph and 218304 B for noise, a spread of 322 times.
The large-canvas claim needs its counter-evidence. That 6000×4000 JPEG encoded as one frame produced an LZW payload of 18791 B, but in the same run the buffer gif.js received through getImageData reported distinct=1 across 4000 sample points, all of them [0,0,0,255]: this machine had decoded the 24 Mpx photograph as uniform black. So 18791 B is only the floor for a monochrome frame, about 0.00078 B per pixel, and cannot stand as the size of a large photograph. For photographs use 0.155 B per frame per pixel, taken from photo_400.jpg.
The encoder step needs a network connection
On a cold start window.GIF is undefined, so line 262 injects gif.js 0.2.0 from cdnjs, which measured 13451 B by decodedBodySize. If that request throws onerror the page switches to the unpkg source named on line 269, and only when both fail does the promise reject with CDN 全部不可用, meaning every CDN source was unreachable. The companion gif.worker.js measured 16636 B and is fetched on line 196 before the Worker object is built. The pixels themselves do stay on the machine: line 84 loads each image from a dataURL, and line 196 is the only fetch anywhere in the template.
Three questions that come up a lot
Q: Can the frame delay only be a multiple of 50?
No. step=50 is only an attribute and the code never queries validity, so both 137 and 333 reach the encoder as typed; the price is that Math.round(ms / 10) snaps them onto the ladder, with a worst-case deviation of 5 ms. For an exact match type a multiple of 10 that falls between 50 and 3000, and avoid exponent spellings such as 1e3, which read back as 1.
Q: Why are a few tiny images bigger than all the sources together?
Two extra charges: the 4096-byte page padding gif.js writes around its buffer, and the 768 B local palette every frame carries from frame 2 on. Over 40 frames (N-1)×768 comes to 29952 B, all of it palette.
Q: How do I confirm the canvas size before exporting?
Look at whatever sits in the first slot, because line 183 only reads imgList[0]. Drag the 200×120 image into position 1 and the canvas moves from 400×400 to 200×120, while the blob moves from 26267 to 14660.