create video thumbnails

This commit is contained in:
ed 2021-05-25 06:14:25 +02:00
parent 4dff726310
commit 02a856ecb4
5 changed files with 236 additions and 124 deletions

View file

@ -94,7 +94,7 @@ you may also want these, especially on servers:
* ☑ media player * ☑ media player
* ✖ thumbnails * ✖ thumbnails
* ☑ images * ☑ images
* videos * videos
* ✖ cache eviction * ✖ cache eviction
* ☑ SPA (browse while uploading) * ☑ SPA (browse while uploading)
* if you use the file-tree on the left only, not folders in the file list * 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) * `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) * 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) * 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+) * `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) * `pyheif-pillow-opener` (requires Linux or a C compiler)

View file

@ -245,11 +245,15 @@ def run_argparse(argv, formatter):
ap.add_argument("-nid", action="store_true", help="no info disk-usage") 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("--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-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("--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("--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") 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 = ap.add_argument_group('database options')
ap2.add_argument("-e2d", action="store_true", help="enable up2k database") 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") ap2.add_argument("-e2ds", action="store_true", help="enable up2k db-scanner, sets -e2d")

View file

@ -14,6 +14,117 @@ if not PY2:
unicode = str 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 : <OK>
Duration:
Chapter #
Metadata:
title : <NG>
Input #0, mp3,
Metadata:
album : <OK>
Duration:
Stream #0:0: Audio:
Stream #0:1: Video:
Metadata:
comment : <NG>
"""
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): class MTag(object):
def __init__(self, log_func, args): def __init__(self, log_func, args):
self.log_func = log_func self.log_func = log_func
@ -35,15 +146,7 @@ class MTag(object):
self.get = self.get_ffprobe self.get = self.get_ffprobe
self.prefer_mt = True self.prefer_mt = True
# about 20x slower # about 20x slower
if PY2: self.usable = HAVE_FFPROBE
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
if self.usable and WINDOWS and sys.version_info < (3, 8): if self.usable and WINDOWS and sys.version_info < (3, 8):
self.usable = False self.usable = False
@ -226,97 +329,7 @@ class MTag(object):
p = sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.PIPE) p = sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.PIPE)
r = p.communicate() r = p.communicate()
txt = r[1].decode("utf-8", "replace") txt = r[1].decode("utf-8", "replace")
txt = [x.rstrip("\r") for x in txt.split("\n")] ret, md = parse_ffprobe(txt, self.log)
"""
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 : <OK>
Duration:
Chapter #
Metadata:
title : <NG>
Input #0, mp3,
Metadata:
album : <OK>
Duration:
Stream #0:0: Audio:
Stream #0:1: Video:
Metadata:
comment : <NG>
"""
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()}
return self.normalize_tags(ret, md) return self.normalize_tags(ret, md)
def get_bin(self, parsers, abspath): def get_bin(self, parsers, abspath):

View file

@ -1,6 +1,6 @@
import os import os
from .th_srv import thumb_path, THUMBABLE from .th_srv import thumb_path, THUMBABLE, FMT_FF
class ThumbCli(object): class ThumbCli(object):
@ -13,9 +13,17 @@ class ThumbCli(object):
if ext not in THUMBABLE: if ext not in THUMBABLE:
return None return None
if self.args.no_vthumb and ext in FMT_FF:
return None
tpath = thumb_path(ptop, rem, mtime) tpath = thumb_path(ptop, rem, mtime)
if os.path.exists(tpath): try:
return tpath st = os.stat(tpath)
if st.st_size:
return tpath
return None
except:
pass
x = self.broker.put(True, "thumbsrv.get", ptop, rem, mtime) x = self.broker.put(True, "thumbsrv.get", ptop, rem, mtime)
return x.get() return x.get()

View file

@ -1,7 +1,18 @@
import os import os
import sys
import base64 import base64
import hashlib import hashlib
import threading 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: try:
HAVE_PIL = True HAVE_PIL = True
@ -17,12 +28,25 @@ try:
except: except:
HAVE_PIL = False HAVE_PIL = False
from .util import fsenc, Queue
# https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html # 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" # ffmpeg -formats
FMT_PIL = {x: True for x in FMT_PIL.split(" ") if x} FMT_PIL, FMT_FF = [
THUMBABLE = FMT_PIL {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): def thumb_path(ptop, rem, mtime):
@ -52,8 +76,11 @@ def thumb_path(ptop, rem, mtime):
class ThumbSrv(object): class ThumbSrv(object):
def __init__(self, hub): def __init__(self, hub):
self.hub = hub self.hub = hub
self.args = hub.args
self.log_func = hub.log self.log_func = hub.log
self.res = hub.args.thumbsz.split("x")
self.mutex = threading.Lock() self.mutex = threading.Lock()
self.busy = {} self.busy = {}
self.stopping = False self.stopping = False
@ -64,6 +91,22 @@ class ThumbSrv(object):
t.daemon = True t.daemon = True
t.start() 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): def log(self, msg, c=0):
self.log_func("thumb", msg, c) self.log_func("thumb", msg, c)
@ -108,10 +151,14 @@ class ThumbSrv(object):
with cond: with cond:
cond.wait() cond.wait()
if not os.path.exists(tpath): try:
return None st = os.stat(tpath)
if st.st_size:
return tpath
except:
pass
return tpath return None
def worker(self): def worker(self):
while not self.stopping: while not self.stopping:
@ -125,9 +172,16 @@ class ThumbSrv(object):
if not os.path.exists(tpath): if not os.path.exists(tpath):
if ext in FMT_PIL: if ext in FMT_PIL:
fun = self.conv_pil fun = self.conv_pil
elif ext in FMT_FF:
fun = self.conv_ffmpeg
if fun: 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: with self.mutex:
subs = self.busy[tpath] subs = self.busy[tpath]
@ -141,12 +195,39 @@ class ThumbSrv(object):
self.nthr -= 1 self.nthr -= 1
def conv_pil(self, abspath, tpath): def conv_pil(self, abspath, tpath):
try: with Image.open(abspath) as im:
with Image.open(abspath) as im: if im.mode in ("RGBA", "P"):
if im.mode in ("RGBA", "P"): im = im.convert("RGB")
im = im.convert("RGB")
im.thumbnail((256, 256)) im.thumbnail(self.res)
im.save(tpath) im.save(tpath)
except:
pass 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()