diff --git a/README.md b/README.md index 4457c594..3ec6c1d7 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ feature summary * ☑ image gallery with webm player * ☑ textfile browser with syntax hilighting * ☑ [thumbnails](#thumbnails) - * ☑ ...of images using Pillow + * ☑ ...of images using Pillow and/or pyvips * ☑ ...of videos using FFmpeg * ☑ ...of audio (spectrograms) using FFmpeg * ☑ cache eviction (max-age; maybe max-size eventually) @@ -403,7 +403,7 @@ press `g` to toggle grid-view instead of the file listing, and `t` toggles icon ![copyparty-thumbs-fs8](https://user-images.githubusercontent.com/241032/129636211-abd20fa2-a953-4366-9423-1c88ebb96ba9.png) -it does static images with Pillow and uses FFmpeg for video files, so you may want to `--no-thumb` or maybe just `--no-vthumb` depending on how dangerous your users are +it does static images with Pillow and/or pyvips, and uses FFmpeg for video files, so you may want to `--no-thumb` or maybe just `--no-vthumb` depending on how dangerous your users are audio files are covnerted into spectrograms using FFmpeg unless you `--no-athumb` (and some FFmpeg builds may need `--th-ff-swr`) @@ -1096,10 +1096,13 @@ enable music tags: * or `ffprobe` (20x slower, more accurate, possibly dangerous depending on your distro and users) enable [thumbnails](#thumbnails) of... -* **images:** `Pillow` (requires py2.7 or py3.5+) +* **images:** `Pillow` and/or `pyvips` (requires py2.7 or py3.5+) * **videos/audio:** `ffmpeg` and `ffprobe` somewhere in `$PATH` -* **HEIF pictures:** `pyheif-pillow-opener` (requires Linux or a C compiler) -* **AVIF pictures:** `pillow-avif-plugin` +* **HEIF pictures:** `pyvips` or `pyheif-pillow-opener` (requires Linux or a C compiler) +* **AVIF pictures:** `pyvips` or `pillow-avif-plugin` +* **JPEG XL pictures:** `pyvips` + +`pyvips` gives higher quality thumbnails than `Pillow` and is 320% faster, using 270% more ram: `sudo apt install libvips42 && python3 -m pip install --user -U pyvips` ## install recommended deps diff --git a/copyparty/__main__.py b/copyparty/__main__.py index ac49dd9c..22a9bea0 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -500,6 +500,7 @@ def run_argparse(argv, formatter): ap2.add_argument("--th-mt", metavar="CORES", type=int, default=cores, help="num cpu cores to use for generating thumbnails") ap2.add_argument("--th-convt", metavar="SEC", type=int, default=60, help="conversion timeout in seconds") ap2.add_argument("--th-no-crop", action="store_true", help="dynamic height; show full image") + ap2.add_argument("--th-dec", metavar="LIBS", default="vips,pil,ff", help="decoders, in order of preference") ap2.add_argument("--th-no-jpg", action="store_true", help="disable jpg output") ap2.add_argument("--th-no-webp", action="store_true", help="disable webp output") ap2.add_argument("--th-ff-jpg", action="store_true", help="force jpg for video thumbs") diff --git a/copyparty/httpconn.py b/copyparty/httpconn.py index bc44011d..ae25070c 100644 --- a/copyparty/httpconn.py +++ b/copyparty/httpconn.py @@ -17,7 +17,7 @@ from .util import Unrecv from .httpcli import HttpCli from .u2idx import U2idx from .th_cli import ThumbCli -from .th_srv import HAVE_PIL +from .th_srv import HAVE_PIL, HAVE_VIPS from .ico import Ico @@ -38,7 +38,7 @@ class HttpConn(object): self.cert_path = hsrv.cert_path self.u2fh = hsrv.u2fh - enth = HAVE_PIL and not self.args.no_thumb + enth = (HAVE_PIL or HAVE_VIPS) and not self.args.no_thumb self.thumbcli = ThumbCli(hsrv) if enth else None self.ico = Ico(self.args) diff --git a/copyparty/svchub.py b/copyparty/svchub.py index 7d189472..344d1a99 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -17,7 +17,7 @@ from .util import mp, start_log_thrs, start_stackmon, min_ex, ansi_re from .authsrv import AuthSrv from .tcpsrv import TcpSrv from .up2k import Up2k -from .th_srv import ThumbSrv, HAVE_PIL, HAVE_WEBP +from .th_srv import ThumbSrv, HAVE_PIL, HAVE_VIPS, HAVE_WEBP from .mtag import HAVE_FFMPEG, HAVE_FFPROBE @@ -78,20 +78,32 @@ class SvcHub(object): self.tcpsrv = TcpSrv(self) self.up2k = Up2k(self) + decs = {k: 1 for k in self.args.th_dec.split(",")} + if not HAVE_VIPS: + decs.pop("vips", None) + if not HAVE_PIL: + decs.pop("pil", None) + if not HAVE_FFMPEG or not HAVE_FFPROBE: + decs.pop("ff", None) + + self.args.th_dec = list(decs.keys()) self.thumbsrv = None if not args.no_thumb: - if HAVE_PIL: + m = "decoder preference: {}".format(", ".join(self.args.th_dec)) + self.log("thumb", m) + if "vips" in self.args.th_dec: + self.thumbsrv = ThumbSrv(self) + elif "pil" in self.args.th_dec: if not HAVE_WEBP: - args.th_no_webp = True - msg = "setting --th-no-webp because either libwebp is not available or your Pillow is too old" + msg = "disabling webp thumbnails because either libwebp is not available or your Pillow is too old" self.log("thumb", msg, c=3) + self.log("thumb", "using pillow") self.thumbsrv = ThumbSrv(self) else: - msg = "need Pillow to create thumbnails; for example:\n{}{} -m pip install --user Pillow\n" - self.log( - "thumb", msg.format(" " * 37, os.path.basename(sys.executable)), c=3 - ) + msg = "need Pillow and/or pyvips to create thumbnails; for example:\n{0}{1} -m pip install --user Pillow\n{0}{1} -m pip install --user pyvips\n" + msg = msg.format(" " * 37, os.path.basename(sys.executable)) + self.log("thumb", msg, c=3) if not args.no_acode and args.no_thumb: msg = "setting --no-acode because --no-thumb (sorry)" diff --git a/copyparty/th_cli.py b/copyparty/th_cli.py index 86736e4a..431f2f3c 100644 --- a/copyparty/th_cli.py +++ b/copyparty/th_cli.py @@ -4,7 +4,7 @@ from __future__ import print_function, unicode_literals import os from .util import Cooldown -from .th_srv import thumb_path, THUMBABLE, FMT_FFV, FMT_FFA +from .th_srv import thumb_path, THUMBABLE, FMT_FFV, FMT_FFA, HAVE_WEBP from .bos import bos @@ -18,6 +18,9 @@ class ThumbCli(object): # cache on both sides for less broker spam self.cooldown = Cooldown(self.args.th_poke) + d = next((x for x in self.args.th_dec if x in ("vips", "pil")), None) + self.can_webp = HAVE_WEBP or d == "vips" + def log(self, msg, c=0): self.log_func("thumbcli", msg, c) @@ -42,6 +45,8 @@ class ThumbCli(object): elif want_opus: return None + is_img = not is_vid and not is_au + if rem.startswith(".hist/th/") and rem.split(".")[-1] in ["webp", "jpg"]: return os.path.join(ptop, rem) @@ -49,7 +54,11 @@ class ThumbCli(object): fmt = "w" if fmt == "w": - if self.args.th_no_webp or ((is_vid or is_au) and self.args.th_ff_jpg): + if ( + self.args.th_no_webp + or (is_img and not self.can_webp) + or (not is_img and self.args.th_ff_jpg) + ): fmt = "j" histpath = self.asrv.vfs.histtab.get(ptop) diff --git a/copyparty/th_srv.py b/copyparty/th_srv.py index 6d7e471e..53872490 100644 --- a/copyparty/th_srv.py +++ b/copyparty/th_srv.py @@ -47,9 +47,18 @@ try: except: pass +try: + import pyvips + + HAVE_VIPS = True +except: + HAVE_VIPS = False + # https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html +# https://github.com/libvips/libvips # ffmpeg -formats FMT_PIL = "bmp dib gif icns ico jpg jpeg jp2 jpx pcx png pbm pgm ppm pnm sgi tga tif tiff webp xbm dds xpm" +FMT_VIPS = "jpg jpeg jp2 jpx jxl tif tiff png webp heic avif fit fits fts exr pdf svg hdr ppm pgm pfm gif nii dzi" FMT_FFV = "av1 asf avi flv m4v mkv mjpeg mjpg mpg mpeg mpg2 mpeg2 h264 avc mts h265 hevc mov 3gp mp4 ts mpegts nut ogv ogm rm vob webm wmv" FMT_FFA = "aac m4a ogg opus flac alac mp3 mp2 ac3 dts wma ra wav aif aiff au alaw ulaw mulaw amr gsm ape tak tta wv mpc" @@ -59,8 +68,8 @@ if HAVE_HEIF: if HAVE_AVIF: FMT_PIL += " avif avifs" -FMT_PIL, FMT_FFV, FMT_FFA = [ - {x: True for x in y.split(" ") if x} for y in [FMT_PIL, FMT_FFV, FMT_FFA] +FMT_PIL, FMT_VIPS, FMT_FFV, FMT_FFA = [ + {x: True for x in y.split(" ") if x} for y in [FMT_PIL, FMT_VIPS, FMT_FFV, FMT_FFA] ] @@ -73,6 +82,9 @@ if HAVE_FFMPEG and HAVE_FFPROBE: THUMBABLE.update(FMT_FFV) THUMBABLE.update(FMT_FFA) +if HAVE_VIPS: + THUMBABLE.update(FMT_VIPS) + def thumb_path(histpath, rem, mtime, fmt): # base16 = 16 = 256 @@ -101,6 +113,8 @@ def thumb_path(histpath, rem, mtime, fmt): class ThumbSrv(object): def __init__(self, hub): + global THUMBABLE + self.hub = hub self.asrv = hub.asrv self.args = hub.args @@ -141,6 +155,18 @@ class ThumbSrv(object): t.daemon = True t.start() + THUMBABLE = {} + + if "pil" in self.args.th_dec: + THUMBABLE.update(FMT_PIL) + + if "vips" in self.args.th_dec: + THUMBABLE.update(FMT_VIPS) + + if "ff" in self.args.th_dec: + THUMBABLE.update(FMT_FFV) + THUMBABLE.update(FMT_FFA) + def log(self, msg, c=0): self.log_func("thumb", msg, c) @@ -211,15 +237,20 @@ class ThumbSrv(object): ext = abspath.split(".")[-1].lower() fun = None if not bos.path.exists(tpath): - if ext in FMT_PIL: - fun = self.conv_pil - elif ext in FMT_FFV: - fun = self.conv_ffmpeg - elif ext in FMT_FFA: - if tpath.endswith(".opus") or tpath.endswith(".caf"): - fun = self.conv_opus - else: - fun = self.conv_spec + for lib in self.args.th_dec: + if fun: + break + elif lib == "pil" and ext in FMT_PIL: + fun = self.conv_pil + elif lib == "vips" and ext in FMT_VIPS: + fun = self.conv_vips + elif lib == "ff" and ext in FMT_FFV: + fun = self.conv_ffmpeg + elif lib == "ff" and ext in FMT_FFA: + if tpath.endswith(".opus") or tpath.endswith(".caf"): + fun = self.conv_opus + else: + fun = self.conv_spec if fun: try: @@ -296,6 +327,24 @@ class ThumbSrv(object): im.save(tpath, **args) + def conv_vips(self, abspath, tpath): + crops = ["centre", "none"] + if self.args.th_no_crop: + crops = ["none"] + + w, h = self.res + kw = {"height": h, "size": "down", "intent": "relative"} + + for c in crops: + try: + kw["crop"] = c + img = pyvips.Image.thumbnail(abspath, w, **kw) + break + except: + pass + + img.write_to_file(tpath, Q=40) + def conv_ffmpeg(self, abspath, tpath): ret, _ = ffprobe(abspath) @@ -350,11 +399,24 @@ class ThumbSrv(object): def _run_ff(self, cmd): # self.log((b" ".join(cmd)).decode("utf-8")) ret, sout, serr = runcmd(cmd, timeout=self.args.th_convt) - if ret != 0: - m = "FFmpeg failed (probably a corrupt video file):\n" - m += "\n".join(["ff: {}".format(x) for x in serr.split("\n")]) - self.log(m, c="1;30") - raise sp.CalledProcessError(ret, (cmd[0], b"...", cmd[-1])) + if not ret: + return + + c = "1;30" + m = "FFmpeg failed (probably a corrupt video file):\n" + if cmd[-1].endswith(b".webp") and ( + "Error selecting an encoder" in serr + or "Automatic encoder selection failed" in serr + or "Default encoder for format webp" in serr + or "Please choose an encoder manually" in serr + ): + self.args.th_ff_jpg = True + m = "FFmpeg failed because it was compiled without libwebp; enabling --th_ff_jpg to force jpeg output:\n" + c = 1 + + m += "\n".join(["ff: {}".format(x) for x in serr.split("\n")]) + self.log(m, c=c) + raise sp.CalledProcessError(ret, (cmd[0], b"...", cmd[-1])) def conv_spec(self, abspath, tpath): ret, _ = ffprobe(abspath) diff --git a/setup.py b/setup.py index 37b828b9..2a953d52 100755 --- a/setup.py +++ b/setup.py @@ -114,9 +114,10 @@ args = { "install_requires": ["jinja2"], "extras_require": { "thumbnails": ["Pillow"], + "thumbnails2": ["pyvips"], "audiotags": ["mutagen"], "ftpd": ["pyftpdlib"], - "ftps": ["pyopenssl"], + "ftps": ["pyftpdlib", "pyopenssl"], }, "entry_points": {"console_scripts": ["copyparty = copyparty.__main__:main"]}, "scripts": ["bin/copyparty-fuse.py", "bin/up2k.py"],