240 lines
9.4 KiB
Python
240 lines
9.4 KiB
Python
# Reticulum License
|
|
#
|
|
# Copyright (c) 2016-2026 Mark Qvist
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
# in the Software without restriction, including without limitation the rights
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
#
|
|
# - The Software shall not be used in any kind of system which includes amongst
|
|
# its functions the ability to purposefully do harm to human beings.
|
|
#
|
|
# - The Software shall not be used, directly or indirectly, in the creation of
|
|
# an artificial intelligence, machine learning or language model training
|
|
# dataset, including but not limited to any use that contributes to the
|
|
# training or development of such a model or algorithm.
|
|
#
|
|
# - The above copyright notice and this permission notice shall be included in
|
|
# all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
|
|
import os
|
|
import time
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import RNS
|
|
|
|
CONVERSION_TIMEOUT = 8
|
|
|
|
# WebP encoding backends, in preference order. All supported
|
|
# encoder families handle the common still formats (PNG, JPEG,
|
|
# TIFF, BMP, GIF, WebP).
|
|
#
|
|
# Environment overrides:
|
|
# RNGIT_MEDIA_BACKEND force a specific backend
|
|
|
|
BACKENDS = ( ("magick", ["magick", "-", "webp:-"]),
|
|
("convert", ["convert", "-", "webp:-"]),
|
|
("gm", ["gm", "convert", "-", "webp:-"]),
|
|
("ffmpeg", ["ffmpeg", "-y", "-loglevel", "error", "-i", "-", "-f", "webp", "pipe:1"]),
|
|
("avconv", ["avconv", "-y", "-loglevel", "error", "-i", "-", "-f", "webp", "pipe:1"]) )
|
|
|
|
_ENV_BACKEND = os.environ.get("RNGIT_MEDIA_BACKEND")
|
|
_winner = None
|
|
_no_backend_logged = False
|
|
|
|
def available_backends():
|
|
out = []
|
|
for name, argv in BACKENDS: out.append((name, shutil.which(argv[0]) is not None))
|
|
return out
|
|
|
|
def _selected_backend():
|
|
global _winner
|
|
if _ENV_BACKEND:
|
|
for name, argv in BACKENDS:
|
|
if name == _ENV_BACKEND and shutil.which(argv[0]) is not None:
|
|
return (name, argv)
|
|
return None
|
|
|
|
order = list(BACKENDS)
|
|
if _winner is not None: order.sort(key=lambda backend: backend[0] != _winner)
|
|
|
|
for name, argv in order:
|
|
if shutil.which(argv[0]) is not None:
|
|
_winner = name
|
|
return (name, argv)
|
|
|
|
return None
|
|
|
|
def _terminate(proc):
|
|
try: proc.kill()
|
|
except Exception: pass
|
|
try: proc.wait()
|
|
except Exception: pass
|
|
|
|
def _stderr_tail(proc, limit=1024):
|
|
try:
|
|
data = proc.stderr.read(limit)
|
|
return data.decode("utf-8", "replace").strip()
|
|
except Exception: return ""
|
|
|
|
def _webp_info(data):
|
|
if len(data) < 30 or data[:4] != b"RIFF" or data[8:12] != b"WEBP": return None
|
|
fourcc = data[12:16]
|
|
if fourcc == b"VP8X":
|
|
width = int.from_bytes(data[24:27], "little") + 1
|
|
height = int.from_bytes(data[27:30], "little") + 1
|
|
elif fourcc == b"VP8 ":
|
|
width = int.from_bytes(data[26:28], "little") & 0x3FFF
|
|
height = int.from_bytes(data[28:30], "little") & 0x3FFF
|
|
elif fourcc == b"VP8L":
|
|
bits = int.from_bytes(data[21:25], "little")
|
|
width = (bits & 0x3FFF) + 1
|
|
height = ((bits >> 14) & 0x3FFF) + 1
|
|
else: return None
|
|
if width > 0 and height > 0: return (width, height)
|
|
return None
|
|
|
|
def _valid_webp(path):
|
|
try:
|
|
with open(path, "rb") as fh: return _webp_info(fh.read(30)) is not None
|
|
except Exception: return False
|
|
|
|
def _unlink_output(path):
|
|
try: os.unlink(path)
|
|
except Exception: pass
|
|
|
|
def _configured_backend(quality=None, max_dimension=None):
|
|
global _no_backend_logged
|
|
backend = _selected_backend()
|
|
if backend is None:
|
|
if not _no_backend_logged:
|
|
_no_backend_logged = True
|
|
RNS.log("No WebP encoding backend available for media conversion. You can Install ImageMagick or ffmpeg to enable image conversion.", RNS.LOG_WARNING)
|
|
return None
|
|
|
|
backend_name, encoder_argv = backend
|
|
_no_backend_logged = False
|
|
|
|
quality_arg = None
|
|
dimension_arg = None
|
|
if quality is not None:
|
|
try: quality_arg = max(1, min(100, int(quality)))
|
|
except (ValueError, TypeError): pass
|
|
if max_dimension is not None:
|
|
try: dimension_arg = int(max_dimension)
|
|
except (ValueError, TypeError): pass
|
|
if dimension_arg is not None and dimension_arg < 1: dimension_arg = None
|
|
|
|
encoder_argv = list(encoder_argv)
|
|
if backend_name in ("magick", "convert", "gm"):
|
|
options = []
|
|
if quality_arg is not None: options += ["-quality", str(quality_arg)]
|
|
if dimension_arg is not None: options += ["-resize", f"{dimension_arg}x{dimension_arg}>"]
|
|
encoder_argv = encoder_argv[:-1] + options + encoder_argv[-1:]
|
|
elif backend_name in ("ffmpeg", "avconv"):
|
|
options = []
|
|
if quality_arg is not None: options += ["-quality", str(quality_arg)]
|
|
if dimension_arg is not None: options += ["-vf", f"scale='min(iw,{dimension_arg})':'min(ih,{dimension_arg})':force_original_aspect_ratio=decrease"]
|
|
try: format_index = encoder_argv.index("-f")
|
|
except ValueError: format_index = len(encoder_argv)
|
|
encoder_argv = encoder_argv[:format_index] + options + encoder_argv[format_index:]
|
|
|
|
return backend_name, encoder_argv
|
|
|
|
def _await(backend_name, encoder_proc, input_proc=None, timeout=None):
|
|
if timeout is None: timeout = CONVERSION_TIMEOUT
|
|
|
|
deadline = time.time() + timeout
|
|
try:
|
|
encoder_proc.wait(timeout=max(0.0, deadline - time.time()))
|
|
if input_proc is not None: input_proc.wait(timeout=max(0.0, deadline - time.time()))
|
|
except subprocess.TimeoutExpired:
|
|
_terminate(input_proc)
|
|
_terminate(encoder_proc)
|
|
RNS.log(f"Media conversion via {backend_name} timed out after {timeout} seconds", RNS.LOG_WARNING)
|
|
return False
|
|
|
|
if encoder_proc.returncode != 0:
|
|
detail = ""
|
|
encoder_tail = _stderr_tail(encoder_proc)
|
|
input_tail = _stderr_tail(input_proc) if input_proc is not None else ""
|
|
if encoder_tail: detail += f" Encoder: {encoder_tail}"
|
|
if input_tail: detail += f" Input: {input_tail}"
|
|
RNS.log(f"Media conversion via {backend_name} failed.{detail}", RNS.LOG_WARNING)
|
|
return False
|
|
|
|
return True
|
|
|
|
def convert_to_webp(input_argv, output_path, cwd=None, timeout=None, quality=None, max_dimension=None):
|
|
configured = _configured_backend(quality=quality, max_dimension=max_dimension)
|
|
if configured is None: return False
|
|
|
|
backend_name, encoder_argv = configured
|
|
|
|
try:
|
|
with open(output_path, "wb") as output_fh:
|
|
input_proc = subprocess.Popen(input_argv, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
encoder_proc = subprocess.Popen(encoder_argv, stdin=input_proc.stdout, stdout=output_fh, stderr=subprocess.PIPE)
|
|
|
|
# The parent must not retain a copy of the input pipe's write
|
|
# end, or the input process would never see EOF.
|
|
input_proc.stdout.close()
|
|
if not _await(backend_name, encoder_proc, input_proc=input_proc, timeout=timeout): return False
|
|
|
|
if not _valid_webp(output_path):
|
|
RNS.log(f"Media conversion via {backend_name} produced invalid WebP output", RNS.LOG_WARNING)
|
|
return False
|
|
|
|
RNS.log(f"Media converted to WebP with {backend_name}", RNS.LOG_DEBUG)
|
|
return True
|
|
|
|
except Exception as e:
|
|
RNS.log(f"Error during media conversion: {e}", RNS.LOG_WARNING)
|
|
return False
|
|
|
|
def convert_file_to_webp(source_path, quality=85, max_dimension=None, timeout=None):
|
|
configured = _configured_backend(quality=quality, max_dimension=max_dimension)
|
|
if configured is None: return False
|
|
backend_name, encoder_argv = configured
|
|
|
|
tmp_path = None
|
|
success = False
|
|
try:
|
|
if not os.path.isfile(source_path):
|
|
RNS.log(f"Cannot convert media: source file does not exist: {source_path}", RNS.LOG_WARNING)
|
|
return False
|
|
|
|
fd, tmp_path = tempfile.mkstemp(prefix="rns_media_", suffix=".webp")
|
|
os.close(fd)
|
|
|
|
with open(source_path, "rb") as input_fh, open(tmp_path, "wb") as output_fh:
|
|
encoder_proc = subprocess.Popen(encoder_argv, stdin=input_fh, stdout=output_fh, stderr=subprocess.PIPE)
|
|
if not _await(backend_name, encoder_proc, timeout=timeout): return False
|
|
|
|
if not _valid_webp(tmp_path):
|
|
RNS.log(f"Invalid media conversion output from {backend_name}", RNS.LOG_WARNING)
|
|
return False
|
|
|
|
success = True
|
|
RNS.log(f"Media converted to WebP with {backend_name}", RNS.LOG_DEBUG)
|
|
return tmp_path
|
|
|
|
except Exception as e:
|
|
RNS.log(f"Error during media conversion: {e}", RNS.LOG_WARNING)
|
|
return False
|
|
|
|
finally:
|
|
if not success and tmp_path is not None: _unlink_output(tmp_path)
|