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
* ✖ 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)

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("--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")

View file

@ -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 : <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):
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 : <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()}
ret, md = parse_ffprobe(txt, self.log)
return self.normalize_tags(ret, md)
def get_bin(self, parsers, abspath):

View file

@ -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()

View file

@ -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
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()