Skip to content

Commit 4b0a6b4

Browse files
committed
feat: gif->gif memory usage optimizations; add gifski option
1 parent 286be4f commit 4b0a6b4

16 files changed

Lines changed: 1701 additions & 61 deletions

.github/workflows/test.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ jobs:
4141
sudo make install
4242
rm -rf gifsicle-${{ env.GIFSICLE_VER }}
4343
44+
- name: Install gifski
45+
run: |
46+
cargo install gifski
47+
4448
- name: Install system dependencies
4549
run: |
4650
sudo apt-get update

CHANGELOG.rst

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,35 @@ Changelog
33

44
**Unreleased**
55

6+
* Fixed: animated-gif transcodes no longer exhaust memory on large inputs.
7+
``probe()`` now reads gif size and duration from a single-pass header parser
8+
(``thumbor_video_engine.utils.parse_gif``) instead of decoding every frame
9+
with Pillow, and ``transcode_to_gif()`` applies all geometry in ffmpeg at the
10+
target size with on-disk intermediates. When ``FFMPEG_USE_GIFSICLE_ENGINE`` is
11+
enabled, gifsicle now runs as a geometry-free ``-O3`` optimization pass
12+
file-to-file rather than loading the full-resolution animation into thumbor's
13+
gif engine. Python heap usage is now bounded regardless of source resolution
14+
or frame count.
15+
* **Behavior change:** for animated gifs, the ffmpeg engine's gifsicle
16+
optimization pass now invokes ``gifsicle`` directly (still honoring
17+
``GIFSICLE_PATH`` and ``GIFSICLE_ARGS``) on the already-resized output,
18+
instead of routing through ``GIF_ENGINE``. ``GIF_ENGINE`` is still used for
19+
non-animated gifs. If you set ``GIF_ENGINE`` to a subclass of
20+
``thumbor_video_engine.engines.gif.Engine`` in order to customize *animated*
21+
gif handling, those overrides no longer take effect there; move them into a
22+
subclass of the ffmpeg engine (``FFMPEG_ENGINE``) that overrides
23+
``_gif_legacy`` or ``_gifsicle_optimize_file``.
24+
* Feature: optional streaming ``gifski`` pipeline, enabled with
25+
``FFMPEG_GIF_PIPELINE = 'gifski'`` (plus ``GIFSKI_PATH``, ``GIFSKI_QUALITY``,
26+
``GIFSKI_MAX_TARGET_PIXELS``, ``GIFSKI_GIFSICLE_PASS``). Streams frames from
27+
ffmpeg into gifski at the target size for faster, higher-quality gifs, falling
28+
back to the legacy pipeline for variable-delay gifs, oversized targets, or when
29+
the gifski binary is absent. gifski is AGPL-3.0 and is never a hard dependency;
30+
it is invoked as an unmodified subprocess only when configured.
31+
* Feature: ``MAX_ANIMATED_GIF_PIXELS`` rejects an animated-GIF source whose
32+
``width * height * frame_count`` exceeds the limit with a ``400`` response,
33+
but only when the output is also gif (conversions to video/webp/avif are
34+
memory-bounded and unaffected). Disabled by default.
635
* Drop support for python 2.7 and thumbor 6
736

837
**1.2.5 (Jul 8, 2024)**

docs/configuration.rst

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,23 +43,36 @@ The engine to use for video files. It defaults to
4343
GIFSICLE\_PATH
4444
~~~~~~~~~~~~~~
4545

46-
The path to the gifsicle binary. It defaults to ``None``, in which case it
47-
looks for gifsicle in ``PATH``. This is only used if ``GIF_ENGINE`` is set to
48-
``'thumbor_video_engines.engines.gif'``. As of version 6.7.0, thumbor does not
49-
support configuring this value.
46+
The path to the gifsicle binary. It defaults to ``None``, in which case gifsicle
47+
is looked up on ``PATH``. It is honored both by the FFmpeg engine's animated-gif
48+
optimization pass (when ``FFMPEG_USE_GIFSICLE_ENGINE`` is enabled) and by the gif
49+
engine for non-animated gifs (when ``GIF_ENGINE`` is set to
50+
``'thumbor_video_engine.engines.gif'``).
5051

5152
GIFSICLE\_ARGS
5253
~~~~~~~~~~~~~~
5354

54-
A list of additional args to pass to gifsicle. This is only used if
55-
``GIF_ENGINE`` is set to ``'thumbor_video_engines.engines.gif'``.
55+
A list of additional args to pass to gifsicle (e.g. ``['--lossy=80']``). Honored
56+
by the FFmpeg engine's animated-gif optimization pass (when
57+
``FFMPEG_USE_GIFSICLE_ENGINE`` is enabled), by the gifski pipeline's optional
58+
``GIFSKI_GIFSICLE_PASS``, and by the gif engine for non-animated gifs (when
59+
``GIF_ENGINE`` is ``'thumbor_video_engine.engines.gif'``).
5660

5761
FFMPEG\_USE\_GIFSICLE\_ENGINE
5862
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
5963

6064
Equivalent to USE\_GIFSICLE\_ENGINE, but for the FFmpeg engine. It defaults to
61-
``False``. If ``True``, it will perform any image operations on animated gifs
62-
(e.g. cropping and resizing) using gifsicle (by way of ``GIF_ENGINE``).
65+
``False``. If ``True``, gifsicle runs as a final ``-O3`` optimization pass
66+
(plus ``GIFSICLE_ARGS``) over the gif produced by ffmpeg, reducing file size.
67+
All geometry (cropping, resizing) is applied by ffmpeg at the target size, so
68+
the gifsicle pass performs no resizing of its own and runs file-to-file on
69+
disk — the full animation is never buffered in the Python heap.
70+
71+
This pass invokes ``gifsicle`` directly rather than routing through
72+
``GIF_ENGINE``, so a custom ``GIF_ENGINE`` does not participate in animated-gif
73+
transcodes (it is still used for non-animated gifs). To customize the
74+
animated-gif optimization step, subclass the FFmpeg engine (``FFMPEG_ENGINE``)
75+
and override ``_gif_legacy`` or ``_gifsicle_optimize_file``.
6376

6477
FFMPEG\_HANDLE\_ANIMATED\_GIF
6578
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -88,6 +101,86 @@ Specifies whether H265 format should be used automatically if the
88101
source image is an animated gif and the request accepts it (via
89102
``Accept: video/*``). It defaults to ``False``.
90103

104+
FFMPEG\_GIF\_PIPELINE
105+
~~~~~~~~~~~~~~~~~~~~~
106+
107+
Selects the gif-to-gif transcode pipeline. It defaults to ``'legacy'``.
108+
109+
``'legacy'``
110+
The ``palettegen``/``paletteuse`` pipeline. ffmpeg applies all geometry
111+
(crop, resize) at the **target** size, writes intermediates to disk, and
112+
— when ``FFMPEG_USE_GIFSICLE_ENGINE`` is enabled — runs a final
113+
geometry-free ``gifsicle -O3`` optimization pass. Only the final output
114+
bytes ever enter the Python heap, so memory stays bounded regardless of
115+
the source animation's resolution or frame count.
116+
117+
``'gifski'``
118+
Streams frames from ffmpeg directly into the `gifski`__ encoder at the
119+
target size, producing noticeably higher-quality gifs much faster. Inputs
120+
that gifski cannot represent are routed back to the ``legacy`` path
121+
automatically:
122+
123+
- **variable per-frame delays** (gifski emits a constant frame rate),
124+
- **target sizes above** ``GIFSKI_MAX_TARGET_PIXELS``,
125+
- and any request when the ``gifski`` binary is not available.
126+
127+
Visibly-transparent gifs are decoded to PNG frames first (gifski
128+
preserves alpha from PNG input); opaque gifs and video sources stream
129+
through a ``yuv4mpegpipe``.
130+
131+
.. note::
132+
gifski is licensed under the `AGPL-3.0`__. thumbor-video-engine
133+
invokes it as an unmodified subprocess (aggregation, not linking) and
134+
never declares it as a dependency. You must install the ``gifski``
135+
binary yourself to use this pipeline, and your deployment is
136+
responsible for AGPL compliance.
137+
138+
.. __: https://gif.ski/
139+
.. __: https://www.gnu.org/licenses/agpl-3.0.html
140+
141+
GIFSKI\_PATH
142+
~~~~~~~~~~~~
143+
144+
Path to the gifski binary. It defaults to ``None``, in which case gifski is
145+
looked up on ``PATH``. Only used when ``FFMPEG_GIF_PIPELINE`` is ``'gifski'``.
146+
147+
GIFSKI\_QUALITY
148+
~~~~~~~~~~~~~~~
149+
150+
Quality (1–100) passed to gifski (``--quality``). Defaults to ``90``.
151+
152+
GIFSKI\_MAX\_TARGET\_PIXELS
153+
~~~~~~~~~~~~~~~~~~~~~~~~~~~
154+
155+
Above this output size (target ``width * height``), the gifski pipeline routes
156+
through the legacy path instead. gifski's quantizer working set grows with
157+
output dimensions, while the legacy path's memory stays bounded — this trades
158+
a bit of wall time for bounded subprocess memory on large outputs. Defaults to
159+
``1440000`` (1600×900). A value of ``0`` disables the switch.
160+
161+
GIFSKI\_GIFSICLE\_PASS
162+
~~~~~~~~~~~~~~~~~~~~~~
163+
164+
If ``True``, run a final geometry-free ``gifsicle -O3`` pass (plus
165+
``GIFSICLE_ARGS``, e.g. ``--lossy``) over gifski's output to further reduce
166+
file size. Defaults to ``False``.
167+
168+
MAX\_ANIMATED\_GIF\_PIXELS
169+
~~~~~~~~~~~~~~~~~~~~~~~~~~
170+
171+
Maximum total pixels (``width * height * frame_count``) for an animated **gif
172+
source that is being transcoded to gif**. Sources over this limit fail with a
173+
``400`` response. thumbor's ``MAX_PIXELS`` is per-frame and does not bound frame
174+
count, so a high-frame-count gif can still be expensive; this gate, evaluated
175+
cheaply at load time from a single-pass header parse (no frame decoding), bounds
176+
the total.
177+
178+
Only the gif→gif path is gated. Converting a GIF source to video/webp/avif
179+
(including the automatic conversions from ``FFMPEG_GIF_AUTO_H264`` /
180+
``FFMPEG_GIF_AUTO_H265`` / ``FFMPEG_GIF_AUTO_WEBP``) streams through ffmpeg with
181+
bounded memory and is the efficient way to serve a large animated gif, so those
182+
are not affected. Defaults to ``0``, which disables the check.
183+
91184
FFPROBE\_PATH
92185
~~~~~~~~~~~~~
93186

src/thumbor_video_engine/__init__.py

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,62 @@
3434
'Equivalent to USE_GIFSICLE_ENGINE, but for the ffmpeg engine',
3535
'Video')
3636

37+
Config.define(
38+
'FFMPEG_GIF_PIPELINE',
39+
'legacy',
40+
"Which GIF->GIF transcode pipeline to use. 'legacy' uses the "
41+
"palettegen/paletteuse pipeline (scaling to the target size in ffmpeg, "
42+
"with on-disk intermediates and, when FFMPEG_USE_GIFSICLE_ENGINE is on, "
43+
"a final geometry-free gifsicle -O3 optimization pass). 'gifski' streams "
44+
"frames from ffmpeg into the gifski encoder at the target size, falling "
45+
"back to the legacy path for inputs gifski cannot represent (variable "
46+
"frame delays) or when the gifski binary is not available.",
47+
'Video')
48+
49+
Config.define(
50+
'GIFSKI_PATH',
51+
None,
52+
'Path to the gifski binary. If None, gifski is looked up on PATH. Only '
53+
'used when FFMPEG_GIF_PIPELINE is "gifski". Note: gifski is licensed '
54+
'under the AGPL-3.0; it is invoked as an unmodified subprocess and is '
55+
'never a hard dependency of this package.',
56+
'Video')
57+
58+
Config.define(
59+
'GIFSKI_QUALITY',
60+
90,
61+
'Quality (1-100) passed to gifski (--quality) when encoding GIFs',
62+
'Video')
63+
64+
Config.define(
65+
'GIFSKI_MAX_TARGET_PIXELS',
66+
1440000,
67+
'Above this output size (target width * height), the gifski pipeline '
68+
'routes through the legacy palettegen/paletteuse + gifsicle path '
69+
"instead. gifski's quantizer working set grows with output dimensions "
70+
"while the legacy path's memory stays bounded, at the cost of wall "
71+
'time. A value of 0 disables the switch. Defaults to 1440000 (1600x900).',
72+
'Video')
73+
74+
Config.define(
75+
'GIFSKI_GIFSICLE_PASS',
76+
False,
77+
'If True, run a final geometry-free gifsicle -O3 pass (plus '
78+
'GIFSICLE_ARGS, e.g. --lossy) over gifski output to reduce file size',
79+
'Video')
80+
81+
Config.define(
82+
'MAX_ANIMATED_GIF_PIXELS',
83+
0,
84+
'Maximum total pixels (width * height * frame_count) for an animated GIF '
85+
'that is being transcoded to GIF. Sources over this limit fail with a 400 '
86+
"response (thumbor's MAX_PIXELS is per-frame and does not bound frame "
87+
'count). Only the gif->gif path is gated; conversions of a GIF source to '
88+
'video/webp/avif are memory-bounded and are the efficient way to serve '
89+
'large animated gifs, so they are not affected. A value of 0 (the default) '
90+
'disables the check.',
91+
'Video')
92+
3793
Config.define(
3894
'FFMPEG_HANDLE_ANIMATED_GIF',
3995
True,
@@ -60,12 +116,6 @@
60116
'Whether to use two-pass encoding for h264 in ffmpeg',
61117
'Video')
62118

63-
Config.define(
64-
'FFMPEG_H264_TWO_PASS',
65-
False,
66-
'Whether to use two-pass encoding for h264 in ffmpeg',
67-
'Video')
68-
69119
Config.define(
70120
'FFMPEG_H264_PRESET',
71121
None,

0 commit comments

Comments
 (0)