I did not start this pipeline because I wanted to experiment with codecs. I started it because animated images were becoming an expensive way to deliver what was, in practice, short silent video.
My workload consists mostly of short animated WebP, GIF and APNG-style assets: often only a few seconds long, usually dozens of displayed frames, with a lot of temporal redundancy. The site is mobile-heavy, the same files may be requested repeatedly, and encoding time is far less important than the bytes delivered afterward.
The obvious idea is “convert the animation to MP4.” The production version of that sentence is much harder. An animated image is not necessarily a neat sequence of full-size frames at one clean frame rate. It may contain partial rectangles, disposal rules, alpha blending, irregular delays, ambiguous zero-duration frames, mixed orientations, and timing metadata that generic media tools summarize badly.
The pipeline I ended up with is therefore not “run FFmpeg on a WebP.” It is a sequence of invariants:
animated WebP / GIF / APNG
↓
decode and reconstruct displayed canvas states
↓
recover the source timeline
↓
select a per-source CFR that represents that timeline
↓
normalize geometry without upscaling
↓
encode one conservative H.264 segment
↓
repeat independently for every source
↓
concatenate compatible segments with stream copy
↓
normalize the final packet timeline
↓
probe + packet-check + full-decode validation
↓
atomic publish
The codec is only one part of this. The difficult part is preserving what the animation actually did while changing the representation underneath it.
The first rule: extract displayed frames, not stored rectangles
The most dangerous simplification is to assume that every frame inside an animated image is a complete replacement image.
That is not how these formats have to work. Animated WebP frames have a position, rectangle size, blend mode and disposal mode. APNG frames have their own offsets, dimensions, disposal operation and blend operation. GIF has a graphic control extension that can tell a decoder to leave the previous image in place, restore the affected region to the background, or restore the previous canvas.
That means a stored frame can be only a small patch that depends on what was already on the canvas. If I encode those patches directly as if they were complete pictures, I do not get a smaller version of the animation. I get the wrong animation.
So my extraction boundary is the displayed canvas state. For each animation step I want the fully composited pixels a correct viewer would show after applying the previous frame's disposal rule and the current frame's blending rule.
This sounds like a decoding detail, but it is actually the first correctness guarantee of the whole video pipeline. Once a wrong partial frame has been flattened into H.264, no later encoder setting can repair it.
The official format specifications make this explicit. WebP describes canvas assembly from ANMF frame rectangles, blending and disposal; the PNG specification defines APNG's fcTL frame offsets, delays, disposal and blend operations; GIF89a defines disposal behavior in its Graphic Control Extension.
Frame timing is source data, not a number to guess
The second mistake I had to eliminate was treating “FPS” reported by a generic probe as the truth about an animated image.
Animation formats store timing differently:
- Animated WebP stores a per-frame duration in 1 ms units.
- GIF stores delay time in hundredths of a second.
- APNG stores a numerator and denominator for each frame delay; if the denominator is zero, the PNG specification says it is treated as 100.
Those per-frame delays are the timeline. A convenient average FPS field is only a summary, and sometimes not a useful one.
One real WebP that forced this point home was 1264×720 with 49 displayed frames. Its frame delays alternated between 62 and 63 ms, for a total duration of 3.063 seconds. That is effectively a 16 fps cadence: 62.5 ms per frame.
A probe reported 25 fps for that source.
If I had trusted that number and converted the file to 25 or 30 fps without looking at the frame durations, I would have changed the timing of the source or inserted unnecessary duplicate pictures. The source frame delays were the authoritative data; the reported headline FPS was not.
This distinction is especially important for old GIFs, hand-authored APNGs and generated WebP files, because perfectly regular timing cannot be assumed.
I normalize bad timing before I quantize it
Some timing values are not useful as literal playback instructions.
The WebP container specification explicitly notes that a frame duration of zero, and often very small values such as 10 ms or less, are interpreted by implementations rather than having one universally useful playback behavior. GIF also permits zero delay. APNG says a zero numerator means the decoder should render the next frame as quickly as possible, while viewers may still impose a lower bound.
A production pipeline therefore needs a policy for malformed, missing or implausibly tiny delays. Mine keeps timing in millisecond precision, applies a small minimum frame delay of 10 ms, and uses 100 ms only as a fallback when useful timing is unavailable.
The important point is not that those two numbers are universal. They are my normalization policy. The important point is that timing correction happens before frame-rate selection, and that valid source timing is preserved instead of being replaced by a convenient default.
Why I do not force every animation to 30 fps
Once I have the displayed frames and their durations, I still need to turn an image-animation timeline into a video timeline.
I could encode everything at 30 fps. That is simple, but it is wasteful for the type of material I process. If an animation naturally changes state around 16 times per second, storing it as 30 fps mostly creates repeated temporal samples. H.264 can compress duplicates efficiently, but “compressible waste” is still waste, and the extra frame cadence also complicates exact duration mapping.
Instead I choose a CFR separately for every source from this set:
10, 12, 15, 16, 18, 20, 24, 25, 30 fps
The selector uses the source timeline and chooses the lowest candidate that represents the displayed changes cleanly. If no lower candidate is acceptable, 30 fps is the fallback.
I am deliberately not publishing one magic timing-error threshold here because that part should be validated against the content class. The general rule is more valuable: start with the real display times, then choose the cheapest CFR that still represents them well.
For the 62/63 ms example, 16 fps is a natural fit because one 16 fps frame is exactly 62.5 ms.
Each source gets its own CFR; the final file does not need one global FPS
This was an architectural point I initially had to think through carefully.
My final MP4 can be assembled from several independently processed animations. One source may naturally map to 12 fps, another to 16, another to 24, and another to 30.
I do not re-encode all of them to one collection-wide frame rate merely to make concatenation easier.
Instead:
source A → 12 fps CFR H.264 segment
source B → 16 fps CFR H.264 segment
source C → 24 fps CFR H.264 segment
source D → 30 fps CFR H.264 segment
segments → stream-copy concat → one MP4
Each individual segment is clean CFR. The combined file can therefore be thought of as piecewise-CFR and variable-rate overall.
The trick is to make all segments share a compatible stream description and a time base capable of representing every allowed cadence exactly.
Why I use a 90 kHz video time base
I use a 90,000 Hz track time scale.
That is not an arbitrary large number. Every frame-rate candidate in my selector divides 90,000 exactly:
| FPS | Exact frame duration at 90 kHz |
|---|---|
| 10 | 9000 ticks |
| 12 | 7500 ticks |
| 15 | 6000 ticks |
| 16 | 5625 ticks |
| 18 | 5000 ticks |
| 20 | 4500 ticks |
| 24 | 3750 ticks |
| 25 | 3600 ticks |
| 30 | 3000 ticks |
That property is extremely useful. A 24 fps frame is not “about” 41.667 ms in the encoded timeline; it is exactly 3750 ticks. A 16 fps frame is exactly 5625 ticks. Segment boundaries can be described without fractional video ticks.
I set both the video track time scale and movie time scale to 90,000 in the generated MP4s. More importantly, my validator treats the expected tick grid as an invariant instead of trusting rounded decimal durations.
Geometry is normalized before encoding, and I never upscale
Animated-image collections are messy geometrically too. Sources can be landscape, portrait, square, or unusually small.
My delivery ceiling is approximately:
| Source class | Maximum working envelope |
|---|---|
| Landscape | 1280×720 |
| Portrait | 720×1280 |
| Mixed-orientation final sequence | roughly 960×960 canvas |
The non-negotiable rule is no upscale.
If a source is already smaller than its target envelope, I keep its useful pixel dimensions instead of enlarging it and asking the encoder to spend bits on interpolated pixels.
When several segments with different aspect ratios must live in one final MP4, the encoded streams still need compatible geometry. I solve that by fitting the source inside a common canvas and padding the unused area rather than stretching the picture. In my pipeline the padding/background is black.
That also gives me an explicit answer for alpha. Ordinary H.264/yuv420p does not carry the source animation's alpha channel, so transparency must be flattened against a chosen background. Black is my policy; another product might choose a different background, but it should be an intentional conversion rule rather than an accidental decoder side effect.
Why H.264 MP4 is usually much cheaper to deliver than the animated-image source
The largest structural advantage is temporal compression.
GIF can store sub-images and disposal behavior, APNG can avoid redrawing the entire canvas, and animated WebP is substantially more sophisticated than GIF. So it would be wrong to claim that animated-image formats always store every complete frame independently.
But H.264 is designed as a video codec. It can represent pictures using inter prediction from reference pictures, motion information, P-frames and B-frames. That matches my content unusually well: short illustrated clips often contain static backgrounds with only a face, hand, hair, camera crop, or small region changing from one displayed state to the next.
For GIF and APNG, the difference can be dramatic. Apple currently recommends H.264 MP4 for static web video and explicitly advises using MP4 instead of animated GIFs; its Safari documentation says GIFs can be up to 12 times as expensive in bandwidth and twice as expensive in energy use compared with a modern video codec.
I do not use that “12×” figure as a promise for my files. It is an upper-bound example from Apple's documentation, not my benchmark.
Animated WebP requires more nuance. A lossy animated WebP is already compressed, sometimes very well. Converting it to H.264 is a second lossy generation. The result can still be much smaller for highly redundant motion, but that trade has to be validated visually. “MP4 is smaller” is an observed workload property, not a law of nature.
The H.264 segment profile is intentionally boring
Once the source has been reconstructed, timed and normalized, the actual H.264 settings are comparatively straightforward.
My current baseline is:
| Setting | Value |
|---|---|
| Container | MP4 |
| Codec | H.264 / libx264 |
| Sample entry | avc1 |
| Profile / level | Main @ 3.1 |
| Pixel format | 8-bit yuv420p |
| Rate control | CRF 28 |
| Preset | veryslow |
| Tune | animation |
| Reference frames | 4 |
| Maximum B-frames | 5 |
| Open GOP | off |
| B-pyramid | normal |
| Maximum GOP | about 5 seconds |
| VBV | 4M maxrate / 8M bufsize |
| Color | BT.709, limited range |
| Track/movie time scale | 90,000 |
| Audio/subtitles/data | none |
I use Main@3.1 as a conservative single-file compatibility envelope, not because newer phones are unable to decode more demanding H.264. Android's current media documentation still requires Main Profile decoding from Android 6.0 onward and lists 1280×720 at 30 fps as an H.264 HD recommendation. Apple's current Safari guidance recommends H.264-encoded MP4 for static web files.
The purpose of this profile is to make decoding boring while allowing the encoder to work very hard.
I spend CPU once so I do not spend bandwidth forever
The preset is fixed at:
-preset veryslow
This makes sense because my bottleneck is not encoding latency. A source is processed once; the resulting MP4 may be served many times.
The important distinction is that an expensive encoder search does not mean the browser repeats that work. I let x264 spend CPU finding a better representation while separately bounding decoder complexity through profile, level, references and B-frame structure.
CRF 28 is also workload-specific. The source material is usually illustrated and already lossy, and the business goal is strongly bandwidth-first while still looking normal. I would not use the number as a universal recommendation for film, grain-heavy camera footage, archival masters or quality-critical editing sources.
Each animation becomes an independently encoded segment
At this point the pipeline has a sequence of fully displayed frames, a selected CFR, target geometry and an expected duration.
A simplified version of the codec part of my segment command looks like this:
ffmpeg -framerate "$SELECTED_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
This is intentionally only the central encoding section. In my real pipeline the frame sequence is already timeline-aware, and the resize/pad dimensions are calculated from the source before this command. Blindly copying the command without reproducing those earlier steps would miss the hardest part.
The GOP is derived from the selected frame rate, approximately five seconds:
gopFrames = selectedFps * 5
So 16 fps uses 80, 24 fps uses 120, and 30 fps uses 150.
Concatenation works only because the segment contract is strict
FFmpeg's concat demuxer can join files as if their packets had been muxed together, but its documentation is clear: the files need the same streams, including compatible codecs and time bases. It also uses each file's duration to position the next file, so bad duration metadata can create timeline artifacts.
That is why concatenation is not the place where I “make things compatible.” Compatibility is established before every segment is accepted.
The segments share the same codec family, profile/level envelope, pixel format, stream layout, canvas geometry for a given final sequence, color signaling and 90 kHz time base. They contain no surprise audio or subtitle stream. I also use x264's stitchable=1 in this specific segment workflow.
Once those invariants hold, the final join can remain a stream copy:
ffmpeg -f concat -safe 0 -i segments.ffconcat \
-c:v copy \
-an -sn -dn \
-movflags +faststart \
final.mp4
The key part is -c:v copy.
The H.264 segments are not decoded and compressed again merely because I need one file. That avoids another generation of loss and avoids paying a second enormous encoding cost.
Stream copy preserved the video, but I still found a one-tick timing bug
This was one of the most useful failures in the entire pipeline.
At a 90 kHz time base, a 24 fps frame should last exactly:
90000 / 24 = 3750 ticks
My validator eventually found a real concatenated output containing a 3751-tick duration where the expected grid was 3750.
The file could still look fine. A permissive validator could have said “one tick is nothing” and moved on.
I did the opposite. The whole reason for choosing the 90 kHz grid and the candidate frame rates was that the durations were supposed to be exact. If one packet was off-grid, I wanted to understand why rather than weaken the invariant.
The important discovery was that stream-copying the encoded H.264 payload does not guarantee that the muxed packet timestamps will land on the exact grid I designed. Concatenation adjusts timestamps at segment boundaries, and timestamp rescaling can expose tiny rounding errors even though the compressed picture data itself is unchanged.
My final concat stage now uses FFmpeg's setts bitstream filter to normalize PTS, DTS and packet duration onto the intended 90 kHz grid while the video remains -c:v copy. FFmpeg documents setts as a packet-level bitstream filter that can rewrite timestamps and duration without decoding the stream.
I am intentionally not publishing a context-free setts expression here. In my implementation the correction is generated from the known segment timeline and selected frame rate; copying an expression without the same metadata would be more dangerous than useful.
The rule is the useful part: do not relax a timestamp invariant merely because the visual error is hard to notice.
Validation is not a final check; it is part of the encoder
I do not publish an MP4 merely because FFmpeg exited with code 0.
The pipeline validates the object before it replaces the production asset.
At minimum I check:
- there is exactly the expected video stream and no unwanted audio/subtitle/data stream;
- the codec is H.264 and the expected profile, level, pixel format and dimensions are present;
- the MP4 uses the intended time base;
- frame and packet counts agree with the generated timeline;
- packet durations lie on the exact expected tick grid;
- PTS/DTS ordering is sane and timestamps are monotonic where required;
- segment and final durations match the timeline within the deliberately defined rules;
- the entire file can be decoded, not merely probed.
ffprobe is useful here because it can expose streams, frames and individual packets. For example, packet-level duration inspection is far more informative for this pipeline than trusting a single avg_frame_rate field.
A typical inspection looks conceptually like:
ffprobe -v error \
-select_streams v:0 \
-show_streams \
-show_packets \
-of json \
final.mp4
And I still perform a complete decode pass, for example:
ffmpeg -v error -xerror -err_detect explode \
-i final.mp4 -f null -
Only after the file passes the contract is it published atomically.
The mistakes this pipeline is designed to prevent
Most of the bugs I care about are not “x264 encoded the wrong macroblock.” They happen earlier or later:
- Encoding raw subframes instead of displayed canvas states. Disposal/blending errors become permanent.
- Trusting a guessed FPS. A 62/63 ms source can be silently turned into the wrong cadence.
- Forcing everything to 30 fps. It wastes temporal samples and hides the real timeline.
- Upscaling small sources. More pixels are encoded without more source detail.
- Stretching mixed aspect ratios. Geometry is damaged just to satisfy concat requirements.
- Re-encoding after concat. The source may already be lossy, the segments are lossy H.264, and a second H.264 encode adds another avoidable generation.
- Assuming
-c copyproves timing correctness. The one-tick bug showed why packet timestamps still need validation. - Checking only the container header. A file can probe successfully and still contain a decode failure later in the stream.
Thinking in terms of invariants makes these failures easier to reason about than collecting more encoder flags.
What I deliberately do not preserve
Conversion is not lossless in the semantic sense of “every source feature survives.” I make several explicit sacrifices.
Alpha is flattened. H.264/yuv420p is an opaque video delivery format in this pipeline.
Arbitrary VFR is approximated by a selected CFR per source. I preserve the perceived timing closely enough for this content class; I do not claim to preserve every source timestamp as an arbitrary video duration.
Very high source resolution is reduced. The delivery target is bandwidth-first mobile playback, not archival preservation.
Already-lossy WebP receives another lossy encode. That makes quality validation important and is one reason I do not claim that every animated WebP should automatically become MP4.
Audio is outside the problem. These sources are silent animations, so the final files intentionally contain no audio stream.
Those are not hidden limitations. They are part of the design contract.
When I would not use this approach
I would not use this exact pipeline when transparency must remain compositable over arbitrary page backgrounds, when exact irregular per-frame timing is itself part of the content, when the asset is an archival master, or when the application already has a multi-codec adaptive-video delivery stack that makes a different codec decision sensible.
I would also benchmark rather than assume savings for an already tiny and highly optimized animated WebP. Video usually wins for my short, redundant animation workload, but the source format, frame complexity and duration all matter.
This is a production delivery transformation, not a universal replacement for animated image formats.
The practical pipeline I use now
- Detect whether the source is animated and read its real frame-control metadata.
- Decode the animation into complete displayed canvas states, respecting blend/disposal semantics.
- Recover and sanitize each frame's duration.
- Build the source timeline in millisecond precision.
- Select the lowest acceptable CFR from 10/12/15/16/18/20/24/25/30 for that source.
- Map displayed states onto that CFR timeline.
- Compute target dimensions without ever upscaling.
- Fit/pad into the common canvas needed by the final sequence; flatten alpha intentionally.
- Encode one H.264 Main@3.1, yuv420p, avc1 segment with
veryslow, CRF 28 and bounded decoder complexity. - Use a 90 kHz time base so every allowed frame cadence has an integer tick duration.
- Repeat independently for every animated source.
- Reject segments that violate the stream contract.
- Concatenate accepted segments with
-c:v copy, not another lossy encode. - Normalize the final packet timeline onto the exact tick grid.
- Inspect streams/packets and fully decode the final MP4.
- Publish only the validated file.
Most of the engineering is before and after x264. That is the part I did not appreciate when I first thought about “turning animations into video.”
The deeper lesson: the timeline is the asset
An animated WebP, GIF or APNG is not fundamentally “a bunch of pictures.” It is a timed sequence of canvas states.
Once I started treating that sequence as the source of truth, the rest of the architecture became much clearer. I could choose a cheaper delivery cadence without inventing motion, resize without inventing pixels, encode each source independently without forcing one global FPS, and concatenate without sacrificing another generation of quality.
H.264 made the files small. MP4 made them easy to deliver. But neither of those choices would have mattered if I had already damaged the animation while extracting frames or guessing its timing.
The codec was the easy part.
The real work was preserving the animation's meaning while changing its representation.
Primary documentation
- Google WebP Container Specification — animation canvas assembly, frame duration, blending and disposal.
- W3C PNG Specification, Third Edition — APNG
fcTLtiming, offsets, blend and disposal rules. - GIF89a specification — GIF frame delay and disposal behavior.
- FFmpeg Formats Documentation — concat demuxer requirements and timestamp behavior.
- FFmpeg Bitstream Filters Documentation —
setts. - ffprobe Documentation — stream, frame and packet inspection.
- Apple: Delivering Video Content for Safari — H.264 MP4 for static web video and guidance on replacing animated GIFs.
- Android Supported Media Formats — H.264 profile/container support and playback recommendations.