Skip to content

Commit 9046e52

Browse files
committed
Add methods to make subclass extension easier
1 parent ea0beaf commit 9046e52

2 files changed

Lines changed: 157 additions & 11 deletions

File tree

src/thumbor_video_engine/engines/ffmpeg.py

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,12 @@ def load(self, buffer, extension):
186186

187187
self.probe()
188188

189+
def _route(self, label, method, src_file, *args):
190+
"""Dispatch to a gif-transcode route (``method``), labelled for
191+
observability. Override to record per-route metrics/traces. Defaults
192+
to simply calling the method."""
193+
return method(src_file, *args)
194+
189195
def has_transparency(self):
190196
if self.image:
191197
return has_transparency(self.image)
@@ -311,7 +317,7 @@ def transcode_to_gif(self, src_file):
311317
if (self.context.config.FFMPEG_GIF_PIPELINE == 'gifski'
312318
and self._gifski_path() is not None):
313319
return self._transcode_to_gif_gifski(src_file)
314-
return self._gif_legacy(src_file)
320+
return self._route('legacy', self._gif_legacy, src_file)
315321

316322
def _gifski_path(self):
317323
configured = self.context.config.GIFSKI_PATH
@@ -329,27 +335,37 @@ def _transcode_to_gif_gifski(self, src_file):
329335
width, height = self.image_size
330336
max_target = self.context.config.GIFSKI_MAX_TARGET_PIXELS
331337
if max_target and width * height > max_target:
332-
return self._gif_legacy(src_file)
338+
return self._gifski_oversized_target(src_file)
333339

334340
info = self.gif_info
335341
if info is None:
336342
if self.image:
337343
# Animated webp input (or an unparseable gif that PIL could
338344
# still open): per-frame timing is unknown to us, so use the
339345
# timing-exact legacy path.
340-
return self._gif_legacy(src_file)
346+
return self._route('legacy', self._gif_legacy, src_file)
341347
# Video source: constant frame rate from ffprobe
342-
return self._gifski_y4m(src_file, self._video_fps(), "0")
348+
return self._route(
349+
'y4m', self._gifski_y4m, src_file, self._video_fps(), "0")
343350

344351
if not info.is_uniform_delay:
345352
# Variable frame delays can't be represented in a constant frame
346353
# rate y4m stream; the legacy path preserves them exactly.
347-
return self._gif_legacy(src_file)
354+
return self._route('legacy', self._gif_legacy, src_file)
348355

349356
repeat = "-1" if info.loop_count is None else str(info.loop_count)
350357
if self._gif_visibly_transparent(info):
351-
return self._gifski_png_frames(src_file, info.uniform_fps, repeat)
352-
return self._gifski_y4m(src_file, info.uniform_fps, repeat)
358+
return self._route(
359+
'png', self._gifski_png_frames, src_file, info.uniform_fps, repeat)
360+
return self._route(
361+
'y4m', self._gifski_y4m, src_file, info.uniform_fps, repeat)
362+
363+
def _gifski_oversized_target(self, src_file):
364+
"""Chosen when the target output exceeds ``GIFSKI_MAX_TARGET_PIXELS``
365+
(gifski's quantizer memory grows with output size). Override to serve
366+
something faster (e.g. a quick low-quality pass with a background
367+
re-encode). Defaults to the bounded-memory legacy path."""
368+
return self._route('legacy_large', self._gif_legacy, src_file)
353369

354370
def _gif_visibly_transparent(self, info):
355371
"""GCE transparency flags over-approximate: optimized opaque GIFs
@@ -375,13 +391,30 @@ def _video_fps(self):
375391
fps = DEFAULT_VIDEO_GIF_FPS
376392
return min(fps, MAX_VIDEO_GIF_FPS)
377393

394+
def _gifski_quality(self):
395+
"""Quality (1-100) passed to gifski. Override to vary it per request
396+
(e.g. a low-quality fast pass)."""
397+
return self.context.config.GIFSKI_QUALITY
398+
399+
def _gifski_extra_args(self):
400+
"""Extra flags for the gifski command (e.g. ``['--fast']``). Override
401+
to add them; defaults to none."""
402+
return []
403+
404+
def _gifski_gifsicle_pass(self):
405+
"""Whether to run a final geometry-free ``gifsicle -O3`` pass over
406+
gifski's output. Override to vary it per request (e.g. skip it for a
407+
quick low-quality pass). Defaults to the ``GIFSKI_GIFSICLE_PASS``
408+
config."""
409+
return self.context.config.GIFSKI_GIFSICLE_PASS
410+
378411
def _gifski_cmd(self, out_file, fps):
379-
config = self.context.config
380412
width, height = self.image_size
381413
return [
382414
self._gifski_path(),
383415
"--quiet",
384-
"--quality", "%s" % config.GIFSKI_QUALITY,
416+
"--quality", "%s" % self._gifski_quality(),
417+
] + self._gifski_extra_args() + [
385418
"--fps", "%g" % float(fps),
386419
# gifski caps output at ~800x600 unless explicitly sized; frames
387420
# are already scaled to exactly this size by ffmpeg
@@ -407,7 +440,7 @@ def _gifski_y4m(self, src_file, fps, repeat):
407440
with named_tmp_file(suffix=".gif") as out_file:
408441
gifski_cmd = self._gifski_cmd(out_file, fps) + ["--repeat", repeat, "-"]
409442
self._run_pipeline(ffmpeg_cmd, gifski_cmd)
410-
if self.context.config.GIFSKI_GIFSICLE_PASS:
443+
if self._gifski_gifsicle_pass():
411444
return self._gifsicle_optimize_file(out_file)
412445
with open(out_file, mode="rb") as f:
413446
return f.read()
@@ -441,7 +474,7 @@ def _gifski_png_frames(self, src_file, fps, repeat):
441474
+ frame_files
442475
)
443476
self.run_cmd(gifski_cmd)
444-
if self.context.config.GIFSKI_GIFSICLE_PASS:
477+
if self._gifski_gifsicle_pass():
445478
return self._gifsicle_optimize_file(out_file)
446479
with open(out_file, mode="rb") as f:
447480
return f.read()
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import os
2+
3+
from thumbor_video_engine.engines.ffmpeg import Engine as FFmpegEngine
4+
5+
6+
def load_gif(context, storage_path, name="hotdog.gif", **config):
7+
for k, v in config.items():
8+
setattr(context.config, k, v)
9+
with open(os.path.join(storage_path, name), mode="rb") as f:
10+
buf = f.read()
11+
engine = FFmpegEngine(context)
12+
engine.load(buf, ".gif")
13+
return engine, buf
14+
15+
16+
def test_route_hook_labels_each_gifski_route(context, storage_path, mocker):
17+
routes = []
18+
19+
class RecordingEngine(FFmpegEngine):
20+
def _route(self, label, method, src_file, *args):
21+
routes.append(label)
22+
return super()._route(label, method, src_file, *args)
23+
24+
context.config.FFMPEG_GIF_PIPELINE = "gifski"
25+
engine = RecordingEngine(context)
26+
with open(os.path.join(storage_path, "hotdog.gif"), mode="rb") as f:
27+
engine.load(f.read(), ".gif")
28+
# don't actually shell out; just confirm the route is labelled
29+
mocker.patch.object(engine, "_gifski_y4m", return_value=b"gif")
30+
31+
assert engine._transcode_to_gif_gifski("/tmp/x.gif") == b"gif"
32+
assert routes == ["y4m"]
33+
34+
35+
def test_route_hook_used_for_legacy(context, storage_path, mocker):
36+
routes = []
37+
38+
class RecordingEngine(FFmpegEngine):
39+
def _route(self, label, method, src_file, *args):
40+
routes.append(label)
41+
return b"gif"
42+
43+
engine = RecordingEngine(context) # legacy is the default pipeline
44+
engine.extension = ".gif"
45+
assert engine.transcode_to_gif("/tmp/x.gif") == b"gif"
46+
assert routes == ["legacy"]
47+
48+
49+
def test_oversized_target_hook(context, storage_path, mocker):
50+
class OversizedEngine(FFmpegEngine):
51+
def _gifski_oversized_target(self, src_file):
52+
return b"fast-first"
53+
54+
context.config.FFMPEG_GIF_PIPELINE = "gifski"
55+
context.config.GIFSKI_MAX_TARGET_PIXELS = 1 # everything is "oversized"
56+
with open(os.path.join(storage_path, "hotdog.gif"), mode="rb") as f:
57+
engine = OversizedEngine(context)
58+
engine.load(f.read(), ".gif")
59+
60+
assert engine._transcode_to_gif_gifski("/tmp/x.gif") == b"fast-first"
61+
62+
63+
def test_gifski_quality_and_extra_args_hooks(context):
64+
class FastEngine(FFmpegEngine):
65+
def _gifski_path(self):
66+
return "/usr/bin/gifski"
67+
68+
def _gifski_quality(self):
69+
return 40
70+
71+
def _gifski_extra_args(self):
72+
return ["--fast"]
73+
74+
engine = FastEngine(context)
75+
engine.image_size = (100, 75)
76+
cmd = engine._gifski_cmd("/tmp/out.gif", 25)
77+
78+
assert "--fast" in cmd
79+
assert cmd[cmd.index("--quality") + 1] == "40"
80+
assert cmd[cmd.index("--width") + 1] == "100"
81+
82+
83+
def test_gifski_quality_default(context):
84+
context.config.GIFSKI_QUALITY = 90
85+
engine = FFmpegEngine(context)
86+
assert engine._gifski_quality() == 90
87+
assert engine._gifski_extra_args() == []
88+
89+
90+
def test_gifski_gifsicle_pass_hook_gates_the_pass(context, storage_path, mocker):
91+
# a subclass can suppress the gifsicle -O3 pass per request even when
92+
# GIFSKI_GIFSICLE_PASS is configured on
93+
class NoPassEngine(FFmpegEngine):
94+
def _gifski_gifsicle_pass(self):
95+
return False
96+
97+
context.config.FFMPEG_GIF_PIPELINE = "gifski"
98+
context.config.GIFSKI_GIFSICLE_PASS = True
99+
with open(os.path.join(storage_path, "hotdog.gif"), mode="rb") as f:
100+
engine = NoPassEngine(context)
101+
engine.load(f.read(), ".gif")
102+
engine.resize(100, 75)
103+
gifsicle_spy = mocker.spy(NoPassEngine, "_gifsicle_optimize_file")
104+
105+
engine.read(".gif", quality=80)
106+
assert gifsicle_spy.call_count == 0
107+
108+
109+
def test_gifski_gifsicle_pass_default_follows_config(context):
110+
context.config.GIFSKI_GIFSICLE_PASS = True
111+
assert FFmpegEngine(context)._gifski_gifsicle_pass() is True
112+
context.config.GIFSKI_GIFSICLE_PASS = False
113+
assert FFmpegEngine(context)._gifski_gifsicle_pass() is False

0 commit comments

Comments
 (0)