Coverage for src/lektor_ng/videotools.py: 53%
154 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-08 16:45 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-08 16:45 +0000
1import decimal
2import json
3import os
4import subprocess
5from collections import namedtuple
6from datetime import timedelta
8from lektor_ng.imagetools import Thumbnail, ThumbnailMode
9from lektor_ng.reporter import reporter
10from lektor_ng.utils import get_dependent_url, locate_executable, portable_popen
12THUMBNAIL_FORMATS = frozenset(["jpg", "jpeg", "png"])
15def _imround(x):
16 """Round float pixel values like imagemagick does it."""
17 return decimal.Decimal(x).to_integral(decimal.ROUND_HALF_UP)
20Rescaling = namedtuple("Rescaling", ["rescale", "crop"])
23class Dimensions(namedtuple("Dimensions", ["width", "height"])):
24 __slots__ = ()
26 def __new__(cls, width, height):
27 width = int(width)
28 height = int(height)
30 if width < 1 or height < 1:
31 raise ValueError("Invalid dimensions")
33 return super().__new__(cls, width, height)
35 @property
36 def aspect_ratio(self):
37 return float(self.width) / float(self.height)
39 def _infer_dimensions(self, width, height):
40 """Calculate dimensions based on aspect ratio if height, width or both
41 are missing.
42 """
43 if width is None and height is None:
44 return self
46 if width is None:
47 width = _imround(height * self.aspect_ratio)
48 elif height is None:
49 height = _imround(width / self.aspect_ratio)
51 return Dimensions(width, height)
53 def contains(self, other):
54 """Return True if the given Dimensions can be completely enclosed by
55 this Dimensions.
56 """
57 return self.width >= other.width and self.height >= other.height
59 def fit_within(self, max_width=None, max_height=None, upscale=None):
60 """Calculate resizing required to make these dimensions fit within the
61 given dimensions.
63 Note that resizing only occurs if upscale is enabled.
65 >>> source = Dimensions(640, 480)
66 >>> source.fit_within(max_width=320).rescale == Dimensions(320, 240)
67 True
69 :param max_width: Maximum width for the new rescaled dimensions.
70 :param max_height: Maximum height for the new rescaled dimensions.
71 :param upscale: Allow making the dimensions larger (default False).
72 :return: Rescaling operations
73 :rtype: Rescaling
74 """
75 if upscale is None:
76 upscale = False
78 max_dim = self._infer_dimensions(max_width, max_height)
80 # Check if we should rescale at all
81 if max_dim == self or (not upscale and max_dim.contains(self)):
82 return Rescaling(self, self)
84 ar = self.aspect_ratio
85 rescale_dim = Dimensions(
86 width=_imround(min(max_dim.width, max_dim.height * ar)),
87 height=_imround(min(max_dim.height, max_dim.width / ar)),
88 )
90 return Rescaling(rescale=rescale_dim, crop=rescale_dim)
92 def cover(self, min_width=None, min_height=None, upscale=None):
93 """Calculate resizing required to make these dimensions cover the given
94 dimensions.
96 Note that resizing only occurs if upscale is enabled.
98 >>> source = Dimensions(640, 480)
99 >>> target = source.cover(240, 240)
100 >>> target.rescale == Dimensions(320, 240)
101 True
102 >>> target.crop == Dimensions(240, 240)
103 True
105 :param min_width: Minimum width for the new rescaled dimensions.
106 :param min_height: Minimum height for the new rescaled dimensions.
107 :param upscale: Allow making the dimensions larger (default True).
108 :return: Rescaling operations
109 :rtype: Rescaling
110 """
111 if upscale is None:
112 upscale = True
114 min_dim = self._infer_dimensions(min_width, min_height)
116 # Check if we should rescale at all
117 if min_dim == self or (not upscale and min_dim.contains(self)):
118 return Rescaling(self, self)
120 ar = self.aspect_ratio
121 rescale_dim = Dimensions(
122 width=_imround(max(min_dim.width, min_dim.height * ar)),
123 height=_imround(max(min_dim.height, min_dim.width / ar)),
124 )
126 return Rescaling(rescale=rescale_dim, crop=min_dim)
128 def stretch(self, width=None, height=None, upscale=None):
129 """Calculate resizing required to the given dimensions without
130 considering aspect ratio.
132 Note that resizing only occurs if upscale is enabled.
134 >>> source = Dimensions(640, 480)
135 >>> source.cover(240, 240).rescale == Dimensions(240, 240)
136 True
138 :param min_width: Minimum width for the new rescaled dimensions.
139 :param min_height: Minimum height for the new rescaled dimensions.
140 :param upscale: Allow making the dimensions larger (default True).
141 :return: Rescaling operations
142 :rtype: Rescaling
143 """
144 if upscale is None:
145 upscale = True
147 dim = self._infer_dimensions(width, height)
149 # Check if we should rescale at all
150 if dim == self or (not upscale and dim.contains(self)):
151 return Rescaling(self, self)
153 return Rescaling(rescale=dim, crop=dim)
155 def resize(self, width=None, height=None, mode=ThumbnailMode.DEFAULT, upscale=None):
156 if mode == ThumbnailMode.FIT:
157 return self.fit_within(width, height, upscale)
158 if mode == ThumbnailMode.CROP:
159 return self.cover(width, height, upscale)
160 if mode == ThumbnailMode.STRETCH:
161 return self.stretch(width, height, upscale)
163 raise ValueError(f'Unexpected mode "{mode!r}"')
166def get_timecode(td):
167 """Convert a timedelta to an ffmpeg compatible string timecode.
169 A timecode has the format HH:MM:SS, with decimals if needed.
170 """
171 seconds = td.total_seconds()
173 hours = int(seconds // 3600)
174 seconds %= 3600
176 minutes = int(seconds // 60)
177 seconds %= 60
179 str_seconds, str_decimals = str(float(seconds)).split(".")
181 timecode = f"{hours:02d}:{minutes:02d}:{str_seconds.zfill(2)}"
182 if str_decimals != "0":
183 timecode += f".{str_decimals}"
185 return timecode
188def get_ffmpeg_quality(quality_percent):
189 """Convert a value between 0-100 to an ffmpeg quality value (2-31).
191 Note that this is only applicable to the mjpeg encoder (which is used for
192 jpeg images). mjpeg values works in reverse, i.e. lower is better.
193 """
194 if not 0 <= quality_percent <= 100:
195 raise ValueError("Video quality must be between 0 and 100")
197 low, high = 2, 31
198 span = high - low
199 factor = float(quality_percent) / 100.0
200 return int(low + round(span * (1 - factor)))
203def get_suffix(seek, width, height, mode, quality):
204 """Make suffix for a thumbnail that is unique to the given parameters."""
205 timecode = get_timecode(seek).replace(":", "-").replace(".", "-")
206 bits = [f"t{timecode}"]
208 if width is not None or height is not None:
209 dimension = "x".join(str(x) for x in [width, height] if x is not None)
210 bits.append(dimension)
212 if mode != ThumbnailMode.DEFAULT:
213 bits.append(mode.value)
215 if quality is not None:
216 bits.append(f"q{quality}")
218 return "_".join(bits)
221def get_video_info(filename):
222 """Read video information using ffprobe if available.
224 Returns a dict with: width, height and duration.
225 """
226 ffprobe = locate_executable("ffprobe")
227 if ffprobe is None:
228 raise RuntimeError("Failed to locate ffprobe")
230 proc = portable_popen(
231 [
232 ffprobe,
233 "-v",
234 "quiet",
235 "-print_format",
236 "json",
237 "-show_format",
238 "-show_streams",
239 filename,
240 ],
241 stdout=subprocess.PIPE,
242 )
243 stdout, _ = proc.communicate()
245 if proc.returncode != 0:
246 raise RuntimeError(f"ffprobe exited with code {proc.returncode}")
248 ffprobe_data = json.loads(stdout.decode("utf8"))
249 info = {
250 "width": None,
251 "height": None,
252 "duration": None,
253 }
255 # Try to extract total video duration
256 try:
257 info["duration"] = timedelta(seconds=float(ffprobe_data["format"]["duration"]))
258 except (KeyError, TypeError, ValueError):
259 pass
261 # Try to extract width and height from the first found video stream
262 for stream in ffprobe_data["streams"]:
263 if stream["codec_type"] != "video":
264 continue
266 info["width"] = int(stream["width"])
267 info["height"] = int(stream["height"])
269 # We currently don't bother with multiple video streams
270 break
272 return info
275def make_video_thumbnail(
276 ctx,
277 source_video,
278 source_url_path,
279 seek,
280 *,
281 width=None,
282 height=None,
283 mode=ThumbnailMode.DEFAULT,
284 upscale=None,
285 quality=None,
286 format=None,
287):
288 if mode != ThumbnailMode.FIT and (width is None or height is None):
289 msg = '"%s" mode requires both `width` and `height` to be defined.'
290 raise ValueError(msg % mode.value)
292 if upscale is None:
293 upscale = {
294 ThumbnailMode.FIT: False,
295 ThumbnailMode.CROP: True,
296 ThumbnailMode.STRETCH: True,
297 }[mode]
299 if format is None:
300 format = "jpg"
301 if format not in THUMBNAIL_FORMATS:
302 raise ValueError(f'Invalid thumbnail format "{format}"')
304 if quality is not None and format != "jpg":
305 raise ValueError("The quality parameter is only supported for jpeg images")
307 if seek < timedelta(0):
308 raise ValueError("Seek must not be negative")
310 ffmpeg = locate_executable("ffmpeg")
311 if ffmpeg is None:
312 raise RuntimeError("Failed to locate ffmpeg")
313 info = get_video_info(source_video)
315 source_dim = Dimensions(info["width"], info["height"])
316 resize_dim, crop_dim = source_dim.resize(width, height, mode, upscale)
318 # Construct a filename suffix unique to the given parameters
319 suffix = get_suffix(seek, width, height, mode=mode, quality=quality)
320 dst_url_path = get_dependent_url(source_url_path, suffix, ext=f".{format}")
322 if quality is None and format == "jpg":
323 quality = 95
325 def build_thumbnail_artifact(artifact):
326 artifact.ensure_dir()
328 vfilter = (
329 "thumbnail",
330 f"scale={resize_dim.width}:{resize_dim.height}",
331 f"crop={crop_dim.width}:{crop_dim.height}",
332 )
334 cmdline = [
335 ffmpeg,
336 "-loglevel",
337 "-8",
338 "-ss",
339 get_timecode(seek), # Input seeking since it's faster
340 "-i",
341 source_video,
342 "-vf",
343 ",".join(vfilter),
344 "-frames:v",
345 "1",
346 "-qscale:v",
347 str(get_ffmpeg_quality(quality)),
348 artifact.dst_filename,
349 ]
351 reporter.report_debug_info("ffmpeg cmd line", cmdline)
352 proc = portable_popen(cmdline)
353 if proc.wait() != 0:
354 raise RuntimeError(f"ffmpeg exited with code {proc.returncode}")
356 if not os.path.exists(artifact.dst_filename):
357 msg = (
358 f"Unable to create video thumbnail for {source_video!r}. "
359 "Maybe the seek is outside of the video duration?"
360 )
361 raise RuntimeError(msg)
363 ctx.sub_artifact(artifact_name=dst_url_path, sources=[source_video])(build_thumbnail_artifact)
365 return Thumbnail(dst_url_path, crop_dim.width, crop_dim.height)