From a1cbac02520aca83491d22ced3e5dd6979abf3cd Mon Sep 17 00:00:00 2001 From: ed Date: Fri, 12 Dec 2025 07:51:01 +0000 Subject: [PATCH] option to set thumbnail quality (#1092); plus these fixes: * adds a previously missed libvips optimization, giving much smaller files at the same quality * try to align the quality-scale of each backend (pillow, libvips, ffmpeg) by filesize --- copyparty/__main__.py | 1 + copyparty/authsrv.py | 2 +- copyparty/cfg.py | 2 ++ copyparty/th_srv.py | 80 +++++++++++++++++++++++++++++++++++++------ tests/util.py | 2 +- 5 files changed, 75 insertions(+), 12 deletions(-) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index af282f0c..137bfd07 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1646,6 +1646,7 @@ def add_thumbnail(ap): ap2.add_argument("--th-ram-max", metavar="GB", type=float, default=th_ram, help="max memory usage (GiB) permitted by thumbnailer; not very accurate") ap2.add_argument("--th-crop", metavar="TXT", type=u, default="y", help="crop thumbnails to 4:3 or keep dynamic height; client can override in UI unless force. [\033[32my\033[0m]=crop, [\033[32mn\033[0m]=nocrop, [\033[32mfy\033[0m]=force-y, [\033[32mfn\033[0m]=force-n (volflag=crop)") ap2.add_argument("--th-x3", metavar="TXT", type=u, default="n", help="show thumbs at 3x resolution; client can override in UI unless force. [\033[32my\033[0m]=yes, [\033[32mn\033[0m]=no, [\033[32mfy\033[0m]=force-yes, [\033[32mfn\033[0m]=force-no (volflag=th3x)") + ap2.add_argument("--th-qv", metavar="N", type=int, default=40, help="thumbnail quality (10~90); higher is larger filesize and better quality (volflag=th_qv)") ap2.add_argument("--th-dec", metavar="LIBS", default="vips,pil,raw,ff", help="image 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") diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index de6dc18b..1fd5af99 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -2384,7 +2384,7 @@ class AuthSrv(object): if vf not in vol.flags: vol.flags[vf] = getattr(self.args, ga) - zs = "forget_ip gid nrand tail_who th_spec_p u2abort u2ow uid unp_who ups_who zip_who" + zs = "forget_ip gid nrand tail_who th_qv th_spec_p u2abort u2ow uid unp_who ups_who zip_who" for k in zs.split(): if k in vol.flags: vol.flags[k] = int(vol.flags[k]) diff --git a/copyparty/cfg.py b/copyparty/cfg.py index 588d7bb9..799ab9df 100644 --- a/copyparty/cfg.py +++ b/copyparty/cfg.py @@ -133,6 +133,7 @@ def vf_vmap() -> dict[str, str]: "tail_tmax", "tail_who", "tcolor", + "th_qv", "th_spec_p", "txt_eol", "unlist", @@ -289,6 +290,7 @@ flagcats = { "thsize": "thumbnail res; WxH", "crop": "center-cropping (y/n/fy/fn)", "th3x": "3x resolution (y/n/fy/fn)", + "th_qv=40": "thumbnail quality (10~90)", "convt": "convert-to-image timeout in seconds", "aconvt": "convert-to-audio timeout in seconds", "th_spec_p=1": "make spectrograms? 0=never 1=fallback 2=always", diff --git a/copyparty/th_srv.py b/copyparty/th_srv.py index 684367c5..4aa5746c 100644 --- a/copyparty/th_srv.py +++ b/copyparty/th_srv.py @@ -14,7 +14,7 @@ import time from queue import Queue -from .__init__ import ANYWIN, PY2, TYPE_CHECKING +from .__init__ import ANYWIN, PY2, TYPE_CHECKING, unicode from .authsrv import VFS from .bos import bos from .mtag import HAVE_FFMPEG, HAVE_FFPROBE, au_unpk, ffprobe @@ -56,6 +56,56 @@ EXTS_SPEC_SAFE = set("aif aiff flac mp3 opus wav".split()) PTN_TS = re.compile("^-?[0-9a-f]{8,10}$") +# for n in {1..100}; do rm -rf /home/ed/Pictures/wp/.hist/th/ ; python3 -m copyparty -qv /home/ed/Pictures/wp/::r --th-no-webp --th-qv $n --th-dec pil >/dev/null 2>&1 & p=$!; printf '\033[A\033[J%3d ' $n; while true; do sleep 0.1; curl -s 127.1:3923 >/dev/null && break; done; curl -s '127.1:3923/?tar=j' >/dev/null ; cat /home/ed/Pictures/wp/.hist/th/1n/bs/1nBsjDetfie1iDq3y2D4YzF5/*.* | wc -c; kill $p; wait >/dev/null 2>&1; done +# filesize-equivalent, not quality (ff looks much shittier) +FF_JPG_Q = { + 0: b"30", # 0 + 1: b"30", # 5 + 2: b"30", # 10 + 3: b"30", # 15 + 4: b"28", # 20 + 5: b"21", # 25 + 6: b"17", # 30 + 7: b"15", # 35 + 8: b"13", # 40 + 9: b"12", # 45 + 10: b"11", # 50 + 11: b"10", # 55 + 12: b"9", # 60 + 13: b"8", # 65 + 14: b"7", # 70 + 15: b"6", # 75 + 16: b"5", # 80 + 17: b"4", # 85 + 18: b"3", # 90 + 19: b"2", # 95 + 20: b"2", # 100 +} +# FF_JPG_Q = {xn: ("%d" % (xn,)).encode("ascii") for xn in range(2, 33)} +VIPS_JPG_Q = { + 0: 4, # 0 + 1: 7, # 5 + 2: 12, # 10 + 3: 17, # 15 + 4: 22, # 20 + 5: 27, # 25 + 6: 32, # 30 + 7: 37, # 35 + 8: 42, # 40 + 9: 47, # 45 + 10: 52, # 50 + 11: 56, # 55 + 12: 61, # 60 + 13: 66, # 65 + 14: 71, # 70 + 15: 75, # 75 + 16: 80, # 80 + 17: 85, # 85 + 18: 89, # 90 (vips explodes past this point) + 19: 91, # 95 + 20: 97, # 100 +} + try: if os.environ.get("PRTY_NO_PIL"): @@ -529,7 +579,7 @@ class ThumbSrv(object): im.thumbnail(self.getres(vn, fmt)) fmts = ["RGB", "L"] - args = {"quality": 40} + args = {"quality": vn.flags["th_qv"]} if tpath.endswith(".webp"): # quality 80 = pillow-default @@ -573,7 +623,12 @@ class ThumbSrv(object): raise assert img # type: ignore # !rm - img.write_to_file(tpath, Q=40) + args = {} + qv = vn.flags["th_qv"] + if tpath.endswith("jpg"): + qv = VIPS_JPG_Q[qv // 5] + args["optimize_coding"] = True + img.write_to_file(tpath, Q=qv, strip=True, **args) def conv_raw(self, abspath: str, tpath: str, fmt: str, vn: VFS) -> None: self.wait4ram(0.2, tpath) @@ -607,7 +662,12 @@ class ThumbSrv(object): raise assert img # type: ignore # !rm - img.write_to_file(tpath, Q=40) + args = {} + qv = vn.flags["th_qv"] + if tpath.endswith("jpg"): + qv = VIPS_JPG_Q[qv // 5] + args["optimize_coding"] = True + img.write_to_file(tpath, Q=qv, strip=True, **args) elif HAVE_PIL: if thumb.format == rawpy.ThumbFormat.BITMAP: im = Image.fromarray(thumb.data, "RGB") @@ -671,12 +731,12 @@ class ThumbSrv(object): if tpath.endswith(".jpg"): cmd += [ b"-q:v", - b"6", # default=?? + FF_JPG_Q[vn.flags["th_qv"] // 5], # default=?? ] else: cmd += [ b"-q:v", - b"50", # default=75 + unicode(vn.flags["th_qv"]).encode("ascii"), # default=75 b"-compression_level:v", b"6", # default=4, 0=fast, 6=max ] @@ -722,7 +782,7 @@ class ThumbSrv(object): if len(lines) > 50: lines = lines[:25] + ["[...]"] + lines[-25:] - txt = "\n".join(["ff: " + str(x) for x in lines]) + txt = "\n".join(["ff: " + unicode(x) for x in lines]) if len(txt) > 5000: txt = txt[:2500] + "...\nff: [...]\nff: ..." + txt[-2500:] @@ -880,12 +940,12 @@ class ThumbSrv(object): if tpath.endswith(".jpg"): cmd += [ b"-q:v", - b"6", # default=?? + FF_JPG_Q[vn.flags["th_qv"] // 5], # default=?? ] else: cmd += [ b"-q:v", - b"50", # default=75 + unicode(vn.flags["th_qv"]).encode("ascii"), # default=75 b"-compression_level:v", b"6", # default=4, 0=fast, 6=max ] @@ -1143,7 +1203,7 @@ class ThumbSrv(object): ret = [] for k, vs in raw_tags.items(): for v in vs: - if len(str(v)) >= 1024: + if len(unicode(v)) >= 1024: bv = k.encode("utf-8", "replace") ret += [b"-metadata", bv + b"="] break diff --git a/tests/util.py b/tests/util.py index 8ba5e8c4..91be4e0b 100644 --- a/tests/util.py +++ b/tests/util.py @@ -158,7 +158,7 @@ class Cfg(Namespace): ex = "hash_mt hsortn qdel safe_dedup scan_pr_r scan_pr_s scan_st_r srch_time tail_fd tail_rate th_spec_p u2abort u2j u2sz unp_who" ka.update(**{k: 1 for k in ex.split()}) - ex = "ac_convt au_vol dl_list du_iwho mtab_age reg_cap s_thead s_tbody tail_tmax tail_who th_convt ups_who ver_iwho zip_who" + ex = "ac_convt au_vol dl_list du_iwho mtab_age reg_cap s_thead s_tbody tail_tmax tail_who th_convt th_qv ups_who ver_iwho zip_who" ka.update(**{k: 9 for k in ex.split()}) ex = "ctl_re db_act forget_ip idp_cookie idp_store k304 loris no304 nosubtle qr_pin qr_wait re_maxage rproxy rsp_jtr rsp_slp s_wr_slp snap_wri theme themes turbo u2ow zipmaxn zipmaxs"