From 02a856ecb4e84de02e3d52d3418f0d80807427eb Mon Sep 17 00:00:00 2001 From: ed Date: Tue, 25 May 2021 06:14:25 +0200 Subject: [PATCH] create video thumbnails --- README.md | 14 ++- copyparty/__main__.py | 6 +- copyparty/mtag.py | 213 ++++++++++++++++++++++-------------------- copyparty/th_cli.py | 14 ++- copyparty/th_srv.py | 113 ++++++++++++++++++---- 5 files changed, 236 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index 0fcb0d85..520592d7 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ you may also want these, especially on servers: * ☑ media player * ✖ thumbnails * ☑ images - * ✖ videos + * ☑ videos * ✖ cache eviction * ☑ SPA (browse while uploading) * if you use the file-tree on the left only, not folders in the file list @@ -404,14 +404,20 @@ quick outline of the up2k protocol, see [uploading](#uploading) for the web-clie * `jinja2` (is built into the SFX) -**optional,** enables music tags: + +## optional dependencies + +enable music tags: * either `mutagen` (fast, pure-python, skips a few tags, makes copyparty GPL? idk) * or `FFprobe` (20x slower, more accurate, possibly dangerous depending on your distro and users) -**optional,** enables thumbnails: +enable image thumbnails: * `Pillow` (requires py2.7 or py3.5+) -**optional,** enables reading HEIF pictures: +enable video thumbnails: +* `ffmpeg` and `ffprobe` somewhere in `$PATH` + +enable reading HEIF pictures: * `pyheif-pillow-opener` (requires Linux or a C compiler) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 95f1ee78..7aa8a5f9 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -245,11 +245,15 @@ def run_argparse(argv, formatter): ap.add_argument("-nid", action="store_true", help="no info disk-usage") ap.add_argument("--dotpart", action="store_true", help="dotfile incomplete uploads") ap.add_argument("--no-zip", action="store_true", help="disable download as zip/tar") - ap.add_argument("--no-thumb", action="store_true", help="disable thumbnails") ap.add_argument("--sparse", metavar="MiB", type=int, default=4, help="up2k min.size threshold (mswin-only)") ap.add_argument("--urlform", metavar="MODE", type=str, default="print,get", help="how to handle url-forms") ap.add_argument("--salt", type=str, default="hunter2", help="up2k file-hash salt") + ap2 = ap.add_argument_group('thumbnail options') + ap.add_argument("--no-thumb", action="store_true", help="disable all thumbnails") + ap.add_argument("--no-vthumb", action="store_true", help="disable video thumbnails") + ap.add_argument("--thumbsz", metavar="WxH", default="420x420", help="thumbnail res") + ap2 = ap.add_argument_group('database options') ap2.add_argument("-e2d", action="store_true", help="enable up2k database") ap2.add_argument("-e2ds", action="store_true", help="enable up2k db-scanner, sets -e2d") diff --git a/copyparty/mtag.py b/copyparty/mtag.py index a57bea54..a6a9e51b 100644 --- a/copyparty/mtag.py +++ b/copyparty/mtag.py @@ -14,6 +14,117 @@ if not PY2: unicode = str +def have_ff(cmd): + if PY2: + cmd = (cmd + " -version").encode("ascii").split(b" ") + try: + sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.PIPE).communicate() + return True + except: + return False + else: + return bool(shutil.which(cmd)) + + +HAVE_FFMPEG = have_ff("ffmpeg") +HAVE_FFPROBE = have_ff("ffprobe") + + +def parse_ffprobe(stdout, logger): + txt = [x.rstrip("\r") for x in stdout.split("\n")] + + """ + note: + tags which contain newline will be truncated on first \n, + ffprobe emits \n and spacepads the : to align visually + note: + the Stream ln always mentions Audio: if audio + the Stream ln usually has kb/s, is more accurate + the Duration ln always has kb/s + the Metadata: after Chapter may contain BPM info, + title : Tempo: 126.0 + + Input #0, wav, + Metadata: + date : + Duration: + Chapter # + Metadata: + title : + + Input #0, mp3, + Metadata: + album : + Duration: + Stream #0:0: Audio: + Stream #0:1: Video: + Metadata: + comment : + """ + + ptn_md_beg = re.compile("^( +)Metadata:$") + ptn_md_kv = re.compile("^( +)([^:]+) *: (.*)") + ptn_dur = re.compile("^ *Duration: ([^ ]+)(, |$)") + ptn_br1 = re.compile("^ *Duration: .*, bitrate: ([0-9]+) kb/s(, |$)") + ptn_br2 = re.compile("^ *Stream.*: Audio:.* ([0-9]+) kb/s(, |$)") + ptn_audio = re.compile("^ *Stream .*: Audio: ") + ptn_au_parent = re.compile("^ *(Input #|Stream .*: Audio: )") + + ret = {} + md = {} + in_md = False + is_audio = False + au_parent = False + for ln in txt: + m = ptn_md_kv.match(ln) + if m and in_md and len(m.group(1)) == in_md: + _, k, v = [x.strip() for x in m.groups()] + if k != "" and v != "": + md[k] = [v] + continue + else: + in_md = False + + m = ptn_md_beg.match(ln) + if m and au_parent: + in_md = len(m.group(1)) + 2 + continue + + au_parent = bool(ptn_au_parent.search(ln)) + + if ptn_audio.search(ln): + is_audio = True + + m = ptn_dur.search(ln) + if m: + sec = 0 + tstr = m.group(1) + if tstr.lower() != "n/a": + try: + tf = tstr.split(",")[0].split(".")[0].split(":") + for f in tf: + sec *= 60 + sec += int(f) + except: + logger("invalid timestr from ffprobe: [{}]".format(tstr), c=3) + + ret[".dur"] = sec + m = ptn_br1.search(ln) + if m: + ret[".q"] = m.group(1) + + m = ptn_br2.search(ln) + if m: + ret[".q"] = m.group(1) + + if not is_audio: + return {}, {} + + ret = {k: [0, v] for k, v in ret.items()} + + return ret, md + + class MTag(object): def __init__(self, log_func, args): self.log_func = log_func @@ -35,15 +146,7 @@ class MTag(object): self.get = self.get_ffprobe self.prefer_mt = True # about 20x slower - if PY2: - cmd = [b"ffprobe", b"-version"] - try: - sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.PIPE) - except: - self.usable = False - else: - if not shutil.which("ffprobe"): - self.usable = False + self.usable = HAVE_FFPROBE if self.usable and WINDOWS and sys.version_info < (3, 8): self.usable = False @@ -226,97 +329,7 @@ class MTag(object): p = sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.PIPE) r = p.communicate() txt = r[1].decode("utf-8", "replace") - txt = [x.rstrip("\r") for x in txt.split("\n")] - - """ - note: - tags which contain newline will be truncated on first \n, - ffprobe emits \n and spacepads the : to align visually - note: - the Stream ln always mentions Audio: if audio - the Stream ln usually has kb/s, is more accurate - the Duration ln always has kb/s - the Metadata: after Chapter may contain BPM info, - title : Tempo: 126.0 - - Input #0, wav, - Metadata: - date : - Duration: - Chapter # - Metadata: - title : - - Input #0, mp3, - Metadata: - album : - Duration: - Stream #0:0: Audio: - Stream #0:1: Video: - Metadata: - comment : - """ - - ptn_md_beg = re.compile("^( +)Metadata:$") - ptn_md_kv = re.compile("^( +)([^:]+) *: (.*)") - ptn_dur = re.compile("^ *Duration: ([^ ]+)(, |$)") - ptn_br1 = re.compile("^ *Duration: .*, bitrate: ([0-9]+) kb/s(, |$)") - ptn_br2 = re.compile("^ *Stream.*: Audio:.* ([0-9]+) kb/s(, |$)") - ptn_audio = re.compile("^ *Stream .*: Audio: ") - ptn_au_parent = re.compile("^ *(Input #|Stream .*: Audio: )") - - ret = {} - md = {} - in_md = False - is_audio = False - au_parent = False - for ln in txt: - m = ptn_md_kv.match(ln) - if m and in_md and len(m.group(1)) == in_md: - _, k, v = [x.strip() for x in m.groups()] - if k != "" and v != "": - md[k] = [v] - continue - else: - in_md = False - - m = ptn_md_beg.match(ln) - if m and au_parent: - in_md = len(m.group(1)) + 2 - continue - - au_parent = bool(ptn_au_parent.search(ln)) - - if ptn_audio.search(ln): - is_audio = True - - m = ptn_dur.search(ln) - if m: - sec = 0 - tstr = m.group(1) - if tstr.lower() != "n/a": - try: - tf = tstr.split(",")[0].split(".")[0].split(":") - for f in tf: - sec *= 60 - sec += int(f) - except: - self.log("invalid timestr from ffprobe: [{}]".format(tstr), c=3) - - ret[".dur"] = sec - m = ptn_br1.search(ln) - if m: - ret[".q"] = m.group(1) - - m = ptn_br2.search(ln) - if m: - ret[".q"] = m.group(1) - - if not is_audio: - return {} - - ret = {k: [0, v] for k, v in ret.items()} - + ret, md = parse_ffprobe(txt, self.log) return self.normalize_tags(ret, md) def get_bin(self, parsers, abspath): diff --git a/copyparty/th_cli.py b/copyparty/th_cli.py index 1dde3ca4..942e269c 100644 --- a/copyparty/th_cli.py +++ b/copyparty/th_cli.py @@ -1,6 +1,6 @@ import os -from .th_srv import thumb_path, THUMBABLE +from .th_srv import thumb_path, THUMBABLE, FMT_FF class ThumbCli(object): @@ -13,9 +13,17 @@ class ThumbCli(object): if ext not in THUMBABLE: return None + if self.args.no_vthumb and ext in FMT_FF: + return None + tpath = thumb_path(ptop, rem, mtime) - if os.path.exists(tpath): - return tpath + try: + st = os.stat(tpath) + if st.st_size: + return tpath + return None + except: + pass x = self.broker.put(True, "thumbsrv.get", ptop, rem, mtime) return x.get() diff --git a/copyparty/th_srv.py b/copyparty/th_srv.py index 33c58c74..7d393a7a 100644 --- a/copyparty/th_srv.py +++ b/copyparty/th_srv.py @@ -1,7 +1,18 @@ import os +import sys import base64 import hashlib import threading +import subprocess as sp + +from .__init__ import PY2 +from .util import fsenc, Queue +from .mtag import HAVE_FFMPEG, HAVE_FFPROBE, parse_ffprobe + + +if not PY2: + unicode = str + try: HAVE_PIL = True @@ -17,12 +28,25 @@ try: except: HAVE_PIL = False -from .util import fsenc, Queue # https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html -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_PIL = {x: True for x in FMT_PIL.split(" ") if x} -THUMBABLE = FMT_PIL +# ffmpeg -formats +FMT_PIL, FMT_FF = [ + {x: True for x in y.split(" ") if x} + for y in [ + "bmp dib gif icns ico jpg jpeg jp2 jpx pcx png pbm pgm ppm pnm sgi tga tif tiff webp xbm dds xpm", + "av1 asf avi flv m4v mkv mjpeg mjpg mpg mpeg mpg2 mpeg2 mov 3gp mp4 ts mpegts nut ogv ogm rm vob wmv", + ] +] + + +THUMBABLE = {} + +if HAVE_PIL: + THUMBABLE.update(FMT_PIL) + +if HAVE_FFMPEG and HAVE_FFPROBE: + THUMBABLE.update(FMT_FF) def thumb_path(ptop, rem, mtime): @@ -52,8 +76,11 @@ def thumb_path(ptop, rem, mtime): class ThumbSrv(object): def __init__(self, hub): self.hub = hub + self.args = hub.args self.log_func = hub.log + self.res = hub.args.thumbsz.split("x") + self.mutex = threading.Lock() self.busy = {} self.stopping = False @@ -64,6 +91,22 @@ class ThumbSrv(object): t.daemon = True t.start() + if not HAVE_PIL: + msg = "need Pillow to create thumbnails so please run this:\n {} -m pip install --user Pillow" + self.log(msg.format(os.path.basename(sys.executable)), c=1) + + if not self.args.no_vthumb and (not HAVE_FFMPEG or not HAVE_FFPROBE): + missing = [] + if not HAVE_FFMPEG: + missing.append("ffmpeg") + + if not HAVE_FFPROBE: + missing.append("ffprobe") + + msg = "cannot create video thumbnails since some of the required programs are not available: " + msg += ", ".join(missing) + self.log(msg, c=1) + def log(self, msg, c=0): self.log_func("thumb", msg, c) @@ -108,10 +151,14 @@ class ThumbSrv(object): with cond: cond.wait() - if not os.path.exists(tpath): - return None + try: + st = os.stat(tpath) + if st.st_size: + return tpath + except: + pass - return tpath + return None def worker(self): while not self.stopping: @@ -125,9 +172,16 @@ class ThumbSrv(object): if not os.path.exists(tpath): if ext in FMT_PIL: fun = self.conv_pil + elif ext in FMT_FF: + fun = self.conv_ffmpeg if fun: - fun(abspath, tpath) + try: + fun(abspath, tpath) + except: + self.log("{} failed on {}".format(fun.__name__, abspath), 3) + with open(tpath, "wb") as _: + pass with self.mutex: subs = self.busy[tpath] @@ -141,12 +195,39 @@ class ThumbSrv(object): self.nthr -= 1 def conv_pil(self, abspath, tpath): - try: - with Image.open(abspath) as im: - if im.mode in ("RGBA", "P"): - im = im.convert("RGB") + with Image.open(abspath) as im: + if im.mode in ("RGBA", "P"): + im = im.convert("RGB") - im.thumbnail((256, 256)) - im.save(tpath) - except: - pass \ No newline at end of file + im.thumbnail(self.res) + im.save(tpath) + + def conv_ffmpeg(self, abspath, tpath): + cmd = [b"ffprobe", b"-hide_banner", b"--", fsenc(abspath)] + p = sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.PIPE) + r = p.communicate() + txt = r[1].decode("utf-8", "replace") + ret, _ = parse_ffprobe(txt, self.log) + + dur = ret[".dur"][1] + seek = "{:.0f}".format(dur / 3) + scale = "scale=w={}:h={}:force_original_aspect_ratio=decrease" + scale = scale.format(*list(self.res)).encode("utf-8") + cmd = [ + b"ffmpeg", + b"-nostdin", + b"-hide_banner", + b"-ss", + seek, + b"-i", + fsenc(abspath), + b"-vf", + scale, + b"-vframes", + b"1", + b"-q:v", + b"5", + fsenc(tpath), + ] + p = sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.PIPE) + r = p.communicate()