I did not build this pipeline to experiment with codecs. I built it because animated images had become an expensive way to deliver what was effectively short silent video.
My workload is mostly short animated WebP, GIF and APNG material: usually a few seconds long, often only a few dozen displayed frames, and full of temporal redundancy. Most viewing is on mobile, the same files can be requested repeatedly, and one-time encoding cost matters much less than the bytes served afterward.
The difficult part is not calling FFmpeg. An animated image is not guaranteed to be a clean stack of full-size pictures at one regular frame rate. It can contain partial rectangles, blend and disposal rules, alpha, irregular delays, zero-duration frames, mixed orientations, and timing metadata that a generic probe can summarize badly.
My pipeline therefore treats the conversion as a set of invariants rather than a single command:
animated WebP / GIF / APNG
↓
reconstruct complete displayed canvas states
↓
recover and sanitize source timing
↓
analyze all sources in the final sequence
↓
choose one CFR for that final MP4
↓
compute a tight common canvas without upscaling
↓
encode compatible H.264 segments
↓
verify the stream contract
↓
concatenate with stream copy
↓
normalize and validate the packet timeline
↓
validate HTTP delivery
↓
atomic publish
The codec matters, but preserving what the animation actually displayed matters more.
A measured production result: 217 animated WebP files became one 78.49 MB MP4
The input was not one 1.49 GB video. It was 217 separate animated WebP files containing 10,633 displayed frames. Together, the source animations occupied about 1.49 GB.
INPUT
217 animated WebP files
1.49 GB total
10,633 displayed frames
OUTPUT
1 H.264 MP4
78.49 MB
0.98 Mbps
1264×720
30 fps CFR
Main@3.1 / CRF 28 / veryslow
The resulting H.264 file was 78.49 MB at about 0.98 Mbps. Compared with the combined source-animation bytes, that is roughly 19× smaller, or about 94.7% less data.
This is an end-to-end pipeline result, not a clean “old H.264 versus new H.264” A/B test. The representation changed from hundreds of animated-image files to one temporally compressed video, so I do not attribute the whole 19× reduction to CRF 28, veryslow, or any single encoder option.
First, reconstruct the pictures the viewer actually sees
The most dangerous shortcut is to assume that every stored animation frame is a complete replacement image.
Animated WebP frames can describe a positioned rectangle plus blend and disposal behavior. APNG frames have offsets, dimensions, delay, disposal and blend operations. GIF can also preserve the previous canvas, clear a region, or restore an earlier state.
A stored frame may therefore be only a patch that depends on the canvas produced by earlier frames. Encoding those patches as if they were complete pictures produces the wrong animation, not a smaller copy of the right one.
My extraction boundary is the complete displayed canvas state: the fully composited pixels a correct viewer would show after applying the previous frame’s disposal rule and the current frame’s blend rule.
This is the first correctness guarantee of the pipeline. Once a wrong partial frame has been flattened into H.264, no later CRF, preset or muxing option can repair it.
Frame timing is source data, not an FPS value to guess
Animation formats store time differently. Animated WebP uses per-frame durations in 1 ms units. GIF stores delays in hundredths of a second. APNG stores a numerator and denominator for each frame delay; if the denominator is zero, the PNG specification treats it as 100.
Those delays are the timeline. A headline FPS reported by a generic probe is only a summary and can be misleading.
One real WebP in my pipeline was 1264×720 with 49 displayed frames. Its delays alternated between 62 and 63 ms, for a total of 3.063 seconds. That is effectively a 16 fps cadence because a 16 fps frame lasts 62.5 ms.
A generic probe reported 25 fps for that source. Trusting that number would have changed the source timing or created unnecessary repeated pictures.
I also need a policy for malformed or ambiguous delays. WebP explicitly leaves the interpretation of a zero frame duration, and often very small durations, to implementations. GIF can contain zero delay. APNG allows a zero numerator, meaning the next frame should be rendered as quickly as possible, while viewers may still impose a practical lower bound.
My normalization policy keeps timing at millisecond precision, uses a small 10 ms minimum for zero or implausibly tiny frame durations, and falls back to 100 ms only when useful timing is genuinely unavailable. Those numbers are policy, not universal standards.
I choose one CFR for the whole final MP4, not 30 fps by default
After reconstructing displayed states and their durations, I map the source timeline onto a video timeline. I do not blindly encode everything at 30 fps.
For every source that will appear in the same final MP4, I evaluate a small candidate set:
10, 12, 15, 16, 18, 20, 24, 25, 30 fps
The selector chooses the lowest CFR that represents the complete final sequence acceptably. Different final MP4 files can choose different rates, but every independently encoded segment inside one final MP4 uses the same chosen CFR.
The 3.063-second example makes the savings easy to see. At 16 fps it needs about 49 output frames; at 30 fps it needs about 92. If another source in the same final MP4 genuinely requires 30 fps, then the whole collection uses 30. I do not mix segment frame rates inside one final stream.
I use a 90,000 Hz video-track time scale because every allowed CFR maps to an integer frame duration:
10 fps → 9000 ticks
12 fps → 7500 ticks
15 fps → 6000 ticks
16 fps → 5625 ticks
18 fps → 5000 ticks
20 fps → 4500 ticks
24 fps → 3750 ticks
25 fps → 3600 ticks
30 fps → 3000 ticks
The video-track time scale is what gives me this exact frame grid. I also set the MP4 movie time scale to 90,000 for consistency, but that is a separate container clock. The validator checks the video packets against the exact integer grid rather than trusting rounded decimal durations.
Resolution limits are ceilings, not mandatory canvases
My delivery envelope is roughly 1280×720 for landscape, 720×1280 for portrait, and width/height no greater than 960 for mixed-orientation material.
The non-negotiable rule is never upscale. A 900×600 source does not become better at 1280×720; it only creates more interpolated pixels for the encoder to describe.
The second rule is less obvious: 960×960 is an envelope, not a required square canvas.
I first calculate shrink-only active dimensions for every source. Then the final sequence gets the smallest common even canvas that can contain all of those already-scaled active rectangles.
For example, if the final sequence needs a 960×540 landscape picture and a 500×900 portrait picture, the common canvas can be 960×900, not 960×960. Every segment still has identical coded dimensions, so stream-copy concatenation remains possible, but I avoid coding black area that serves no purpose.
I preserve aspect ratio and pad unused space instead of stretching. In this pipeline the padding is black. Because ordinary H.264/yuv420p does not preserve the source alpha channel, transparency is flattened intentionally against that background rather than being lost accidentally.
Why H.264 MP4 fits this delivery problem
GIF, APNG and animated WebP are not primitive formats. They can avoid redrawing unchanged areas, so “video is always smaller” would be false.
But H.264 is designed for temporal prediction across pictures. Short illustrated loops with static backgrounds and small moving regions are a favorable workload for reference pictures, inter prediction, P-frames and B-frames.
Apple’s current Safari guidance recommends H.264-encoded MP4 for static video and says animated GIFs can be up to 12 times as expensive in bandwidth and twice as expensive in energy as a modern video codec. That 12× figure is Apple’s example, not my benchmark.
My own measured 19× result is therefore useful as a production observation, but I still benchmark rather than assume that every already-small animated WebP will lose to MP4.
Each animation is encoded as a segment under one stream contract
By the time the encoder runs, the pipeline already knows the displayed frames, selected collection-wide CFR, final canvas and expected duration.
The central part of my segment command is approximately:
ffmpeg -framerate "$COLLECTION_FPS" -i frame-%06d.png \
-c:v libx264 \
-preset veryslow \
-tune animation \
-crf 28 \
-profile:v main \
-level:v 3.1 \
-pix_fmt yuv420p \
-tag:v avc1 \
-refs 4 \
-bf 5 \
-g "$GOP_FRAMES" \
-maxrate:v 4M \
-bufsize:v 8M \
-x264-params "stitchable=1:open-gop=0:b-pyramid=normal:nal-hrd=none" \
-color_range tv \
-color_primaries bt709 \
-color_trc bt709 \
-colorspace bt709 \
-fps_mode passthrough \
-map_metadata -1 \
-map_chapters -1 \
-an -sn -dn \
-video_track_timescale 90000 \
-movie_timescale 90000 \
-t "$EXPECTED_DURATION" \
segment.mp4
The GOP limit is about five seconds, derived from the selected CFR: 80 frames at 16 fps, 120 at 24 fps and 150 at 30 fps.
The -t "$EXPECTED_DURATION" guard is not decorative. In my frame-list workflow the final image is repeated as a sentinel so the preceding frame duration is honored. Without the explicit duration limit, that sentinel can become an extra endpoint sample.
I reproduced this on the 49-frame, 3.063-second case. Without -t, the conversion produced 50 frames at 16 fps and 94 at 30 fps. With -t 3.063, it produced the intended 49 frames at 16 fps and 92 at 30 fps.
Stream-copy concatenation is safe only after strict compatibility checks
FFmpeg’s concat demuxer expects the files to have the same streams, including codec and time base, and it uses each file’s duration to position the next one. Bad duration metadata can therefore create timeline artifacts.
I do not use concatenation to make incompatible files compatible. A segment must already satisfy the contract before it is accepted:
collection CFR = identical
stream time base = identical
MP4 track timescale = identical
canvas dimensions / SAR = identical
profile / level / pixel format = identical
color signaling = identical
avcC / AVC extradata = byte-identical
I use x264’s stitchable=1 because this workflow encodes segments independently, but I do not treat that switch as proof that the AVC configuration matches. I still compare the actual configuration bytes before concatenation.
Once the contract holds, the final join can stay lossless at the video-bitstream level:
ffmpeg -f concat -safe 0 -i segments.ffconcat \
-c:v copy \
-an -sn -dn \
-movflags +faststart \
final.mp4
-c:v copy avoids decoding and compressing the already-encoded H.264 segments a second time.
Validation covers both the MP4 file and the way it is delivered
I do not publish a file merely because FFmpeg exited with code 0.
The validator has caught real deterministic timing failures: at 16 fps I saw 5580 ticks where the contract required 5625, and later a 24 fps output contained 3751 where the exact grid required 3750. That deeper one-tick investigation is a separate problem; the lesson here is simply that retries do not repair a deterministic timeline error.
For the media object itself I verify the expected stream count, H.264 profile/level, pixel format, exact planned dimensions, SAR, color signaling, 90 kHz video-track time scale, packet durations, frame/packet counts, duration, PTS/DTS relationships, identical AVC configuration across segments, moov before mdat, and a complete decode with hard error handling.
ffprobe -v error \
-select_streams v:0 \
-show_streams \
-show_packets \
-of json \
final.mp4
ffmpeg -v error -xerror -err_detect explode \
-i final.mp4 -f null -
But a correct local MP4 can still be delivered incorrectly. I therefore also verify the web-serving path: the expected Content-Type, correct Content-Length, byte-range support, a valid 206 Partial Content response and correct Content-Range.
When I change the encoder contract, I also run a small real-device/browser canary rather than assuming ffprobe proves hardware compatibility. I test start, seek, loop, background/resume and range playback on a current iPhone/Safari path, a modest Android device, and mainstream desktop browsers.
Only after the file and its delivery path pass the contract do I replace the production asset atomically.
Where this pipeline deliberately loses information
This is a delivery transformation, not an archival master. Alpha is flattened. Irregular source timing is quantized onto one CFR for the final MP4. Large sources may be reduced. A lossy animated WebP receives another lossy generation. Audio is intentionally absent.
I would not use this exact pipeline when arbitrary-background transparency must survive, when exact irregular per-frame timing is itself meaningful, when I am creating an archival source, or when the application already has an adaptive multi-codec video stack that solves the delivery problem differently.
I also benchmark tiny, already highly optimized animated WebP files instead of assuming MP4 must win.
The production sequence I use now
- Detect the animated format and read the real frame-control metadata.
- Reconstruct complete displayed canvas states with the format’s blend/disposal semantics.
- Recover and sanitize every frame duration.
- Build the authoritative source timeline at millisecond precision.
- Analyze every source that will appear in the same final MP4.
- Select one collection-wide CFR from 10/12/15/16/18/20/24/25/30.
- Map displayed states onto that CFR timeline.
- Calculate shrink-only active dimensions; never upscale.
- Build the smallest common even canvas required by the final sequence.
- Pad without stretching and flatten alpha intentionally.
- Encode each source as H.264 Main@3.1 / yuv420p / avc1 under the same stream contract.
- Bound each segment to its expected duration.
- Reject any segment whose actual AVC configuration or timing violates the contract.
- Concatenate accepted segments with
-c:v copy. - Normalize and validate the final packet timeline.
- Fully decode the result.
- Verify HTTP headers, byte ranges and partial-content behavior.
- Run a device/browser canary after encoder-profile changes.
- Publish atomically only after every check succeeds.
The timeline, not the file extension, is the source of truth
An animated WebP, GIF or APNG is a timed sequence of displayed canvas states, not merely a folder of pictures with an image extension.
H.264 can exploit the temporal redundancy extremely well, but it cannot repair bad compositing, invented timing or incompatible segment metadata. Most of the engineering that made this pipeline reliable happens before and after x264.
That is the lesson I kept: change the representation only after you can describe exactly what must remain invariant.
Primary documentation
- Google WebP Container Specification — frame rectangles, duration, blending and disposal.
- W3C PNG Specification, Third Edition — APNG frame timing, offsets, blend and disposal operations.
- GIF89a specification — GIF delay and disposal behavior.
- FFmpeg Formats Documentation — concat demuxer requirements and MP4 muxing behavior.
- FFmpeg Bitstream Filters Documentation —
setts. - ffprobe Documentation — stream and packet inspection.
- Apple: Delivering Video Content for Safari — H.264 MP4 for static video and animated-GIF replacement guidance.
- Android Supported Media Formats — H.264 support and HTTP-streaming requirements.