Start here
For the included original GIF, run python3 export_sheet.py partial-disposal --columns 3. The recorded export produced a 24×12 PNG sheet containing six complete 8×6 display frames, plus timing.json with raw GIF timing and separate decoder observations.
Every saved cell matched its independent literal RGBA reference. The PNG alone has no playback timing: keep the GIF and JSON beside it, especially when a delay is zero or a timing extension is absent.
What you need
Download the source project and its original six-frame GIF. This is a controlled recipe for ten named, byte-pinned originals, not a general GIF upload converter. It accepts no arbitrary input path.
Use an existing Python environment with Pillow 12.3.0. The measured run used Python 3.14.8 on macOS ARM64. The package contains source and inputs, no Python executable, dependency installer or native output cache.
Tested with: Python 3.14.8 and Pillow 12.3.0, native CLI on macOS ARM64; independent Node 24.22.0 PNG/literal verification. Retained execution, no new browser decoder or player-timing test.
1. Export full canvases into declared cells
Extract the package into your own directory. The GIFs and contract are already included. From its root, the executed export command was:
python3 export_sheet.py partial-disposal --columns 3It writes outputs/sheets/partial-disposal/sheet.png and timing.json. Use --out for an empty directory you own. Existing identical files are preserved; differing, extra or nonregular destinations are rejected before output writes.
The six cells are row-major: (0,0), (8,0), (16,0), (0,6), (8,6), (16,6). Each stores a complete 8×6 canvas, including pixels carried over from earlier partial rectangles.

2. Retain source timing without inventing FPS
Inspect the actual timing sidecar. Each frame records its cell, source rectangle, disposal, gcePresent, delayCs and transparency declaration. Raw centisecond delays are [4, 0, 7, 13, 2, 1]; the separately observed Pillow durations are [40, 0, 70, 130, 20, 10] milliseconds.
Zero is a source value, not an instruction to assign a uniform frame rate. The missing-GCE control instead records gcePresent: false and delayCs: null; its observed duration is also null. Absence stays distinct from an explicit zero delay.
loopExtensions keeps raw application IDs and counts. The original six-frame input declares NETSCAPE count 0, while another control declares 2 and the absent-loop control has []. The sidecar does not reinterpret these as a player repeat API. It is this recipe’s local JSON, not an application-wide animation manifest.
3. Check alpha and failure boundaries
The transparent-disposal original produced four complete 6×5 frames in a three-column sheet, with two empty cells. Independent checks compared all four channels, including transparent areas, against the declared references.

The recipe admits only columns 1–8 and no more columns than frames. The recorded native unit tests rejected invalid columns and unknown case IDs without output writes. A cropped GIF image rectangle is not a complete display frame; the disposal diagnostic shows the visible failure from ignoring that distinction.
node generate.mjs
python3 native.test.py
node verify.mjs outputsThe Node verification command expects all ten sheet exports and three diagnostics under outputs, as described in the package README; one sheet alone is incomplete. The retained run used columns min(3, frameCount) for each case.
The retained native tests and independent Node verification covered ten original GIFs and 24 full canvases: partial disposal, transparent and nonzero backgrounds, local palettes, interlaced rows, timing/loop absence and tiny black/white controls. These results do not establish arbitrary GIF validity, large-file memory limits, cancellation, player timing, or preservation of unspecified hidden RGB. No browser GIF converter was executed for this recipe.