From 98690cf52d84949524f91c5da1fe62a2b62e66f2 Mon Sep 17 00:00:00 2001 From: Urban Wallasch Date: Sun, 2 May 2021 18:06:11 +0200 Subject: [PATCH] * Initial commit. --- ffpreview.py | 312 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100755 ffpreview.py diff --git a/ffpreview.py b/ffpreview.py new file mode 100755 index 0000000..9cb3ec1 --- /dev/null +++ b/ffpreview.py @@ -0,0 +1,312 @@ +#!/usr/bin/python3 + +""" +ffpreview.py + +Copyright 2021 Urban Wallasch + +BSD 3-Clause License + +Copyright (c) 2018, Urban Wallasch +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +""" + +""" +TODO: + +* support more ffmpeg select filters? +* make player configurable? +* reuse existing index / thumbnail files? + +""" + + +import io +import glob +import os +import sys +import getopt +import signal +import time +import random +import tempfile +import argparse +from subprocess import PIPE, Popen +from tkinter import * +from inspect import currentframe + + +def eprint(*args, **kwargs): + print("LINE %d: " % currentframe().f_back.f_lineno, file=sys.stderr, end = '') + print(*args, file=sys.stderr, **kwargs) + +def die(): + global proc + if proc is not None: + eprint("killing subprocess: %s" % proc.args) + proc.kill() + time.sleep(2) + exit() + +def die_ev(event): + die() + + +############################################################ +# configuration + +class Config: + pass +cfg = Config() +cfg.vid = '' +cfg.tmpdir = None +cfg.grid_columns = 5 +cfg.thumb_width = 128 +cfg.scene_thresh = 0.2 #0.15 +#cfg.startts = '0' + + +# parse command line arguments +parser = argparse.ArgumentParser(description='Generate clickable video thumbnail preview.') +parser.add_argument('filename', help='input video file') +parser.add_argument('-c', '--grid_cols', type=int, metavar='INT', help='number of columns in thumbnail preview ') +parser.add_argument('-s', '--scene_thresh', type=float, metavar='FLOAT', help='scene change detection threshold') +parser.add_argument('-t', '--tmpdir', metavar='PATH', help='path to thumbnail parent directory') +parser.add_argument('-w', '--width', type=int, metavar='INT', help='thumbnail image width in pixel') +args = parser.parse_args() +cfg.vid = args.filename +cfg.tmpdir = args.tmpdir +if args.grid_cols: + cfg.grid_columns = args.grid_cols +if args.width: + cfg.thumb_width = args.width +if args.scene_thresh: + cfg.scene_thresh = args.scene_thresh + +# prepare thumbnail directory +if cfg.tmpdir is None: + cfg.tmpdir = tempfile.gettempdir() +cfg.tmpdir += '/ffpreview_thumbs/' + cfg.vid +try: + os.makedirs(cfg.tmpdir, exist_ok=True) +except Exception as e: + eprint(str(e)) + exit(1) +cfg.idxfile = cfg.tmpdir + '/ffpreview.idx' + + +############################################################ +# try to get video container duration + +def get_duration(vidfile): + duration = '(unknown)' + global proc + try: + cmd = 'ffprobe -v error -show_entries' + cmd += ' format=duration -of default=noprint_wrappers=1:nokey=1' + cmd += ' "' + vidfile + '"' + proc = Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE) + stdout, stderr = proc.communicate() + retval = proc.wait() + proc = None + if retval == 0: + duration = stdout.decode().split('.')[0] + else: + eprint('ffprobe:') + eprint(stderr.decode()) + except Exception as e: + eprint(str(e)) + return duration + +""" +def check_present(): + # TODO: check existing index & thumbs? + return '0' +""" + +############################################################ +# extract thumbnails from video and collect timestamps + +def make_thumbs(vidfile, ilabel): + global proc + pic = cfg.tmpdir + '/%08d.ppm' + cmd = 'ffmpeg -loglevel info -hide_banner -y -i "' + vidfile + '"' + cmd += ' -vf "select=gt(scene\,' + str(cfg.scene_thresh) + ')' + cmd += ',showinfo,scale=' + str(cfg.thumb_width) + ':-1"' + cmd += ' -vsync vfr "' + pic + '"' + #eprint(cmd);exit() + ebuf = '' + i = 1 + try: + with open(cfg.idxfile, 'w') as fidx: + proc = Popen(cmd, shell=True, stderr=PIPE) + while proc.poll() is None: + line = proc.stderr.readline() + if line: + line = line.decode() + ebuf += line + x = re.search("pts_time:\d*\.?\d*", line) + if x is not None: + t = x.group().split(':')[1] + print("%d %08d.ppm %s" % (i, i, t), file=fidx) + i += 1 + ilabel.config(text=t.split('.')[0]) + root.update() + retval = proc.wait() + proc = None + if retval != 0: + eprint(ebuf) + eprint("ffmpeg exit code: %d" % retval) + exit(retval) + except Exception as e: + eprint(str(e)) + exit(1) + + +############################################################ +# initialize window + +root = Tk() +root.wm_title(cfg.vid) +root.bind('', die_ev) +root.bind('', die_ev) +root.bind('', die_ev) + +container = Frame(root) +container.pack(fill="both", expand=True) + +canvas = Canvas(container) +canvas.pack(side="left", fill="both", expand=True) + +scrollbar = Scrollbar(container, orient="vertical", command=canvas.yview) +scrollbar.pack(side="right", fill="y") + +scrollframe = Frame(canvas) +scrollframe.bind( + "", + lambda e: canvas.configure( + scrollregion=canvas.bbox("all") + ) +) + +canvas.create_window((0, 0), window=scrollframe, anchor="nw") +canvas.configure(yscrollcommand=scrollbar.set) + +def mouse_wheel(event): + eprint(event) + direction = 0 + if event.num == 5 or event.delta == -120 or event.keysym == 'Down': + direction = 1 + if event.num == 4 or event.delta == 120 or event.keysym == 'Up': + direction = -1 + canvas.yview_scroll(direction, "units") + +def bind_mousewheel(event): + canvas.bind_all("", mouse_wheel) # Windows mouse wheel event + canvas.bind_all("", mouse_wheel) # Linux mouse wheel event (Up) + canvas.bind_all("", mouse_wheel) # Linux mouse wheel event (Down) + canvas.bind_all("", mouse_wheel) # Cursor up key + canvas.bind_all("", mouse_wheel) # Cursor down key + +def unbind_mousewheel(event): + canvas.unbind_all("") + canvas.unbind_all("") + canvas.unbind_all("") + canvas.unbind_all("") + canvas.unbind_all("") + +scrollframe.bind('', bind_mousewheel) +scrollframe.bind('', unbind_mousewheel) + +info1 = Label(scrollframe, text="Processed:", width=10, height=5, anchor="e") +info2 = Label(scrollframe, text="0", width=10, height=5, anchor="e") +info3 = Label(scrollframe, text="", width=12, height=5, anchor="w") +info1.pack(side=LEFT) +info2.pack(side=LEFT) +info3.pack(side=LEFT) + +# do the heavy lifting +proc = None +info3.config(text = 'of ' + get_duration(cfg.vid) + ' s') +root.update() +#check_present() +make_thumbs(cfg.vid, info2) + + +############################################################ +# generate clickable thumbnail labels + +info1.destroy() +info2.destroy() +info3.destroy() +root.update() + +def s2hms(ts): + s, ms = divmod(float(ts), 1.0) + m, s = divmod(s, 60) + h, m = divmod(m, 60) + res = "%d:%02d:%02d%s" % (h, m, s, ("%.3f" % ms).lstrip('0')) + return res + +def click_thumb(event): + num = event.widget.grid_info()["row"] * cfg.grid_columns \ + + event.widget.grid_info()["column"] + 1 + cmd = 'mpv --start ' + event.widget.cget("text") + ' --pause "' + cfg.vid + '"' + Popen(cmd, shell=True) + +try: + with open(cfg.idxfile, 'r') as fidx: + thumbs=[] + idx = fidx.readlines() + x = 0; y = 0 + for line in idx: + l = line.strip().split(' ') + i = int(l[0]) + t = l[2] + thumb=PhotoImage(file=cfg.tmpdir + '/' + l[1]) + thumbs.append(thumb) + tlabel = Label(scrollframe, text=s2hms(t), image=thumb, compound='top', relief="solid") + tlabel.grid(column=x, row=y) + tlabel.bind("", click_thumb) + x += 1 + if x == cfg.grid_columns: + x = 0; y += 1 + root.update() +except Exception as e: + eprint(str(e)) + exit(2) + + +############################################################ +# fix window geometry, start main loop + +root.update() +root.geometry("%dx%d" % (scrollframe.winfo_width() + scrollbar.winfo_width(), 600) ) +root.mainloop() + +# EOF -- 2.30.2