* Initial commit.
authorUrban Wallasch <urban.wallasch@freenet.de>
Sun, 2 May 2021 16:06:11 +0000 (18:06 +0200)
committerUrban Wallasch <urban.wallasch@freenet.de>
Sun, 2 May 2021 16:06:11 +0000 (18:06 +0200)
ffpreview.py [new file with mode: 0755]

diff --git a/ffpreview.py b/ffpreview.py
new file mode 100755 (executable)
index 0000000..9cb3ec1
--- /dev/null
@@ -0,0 +1,312 @@
+#!/usr/bin/python3
+
+"""
+ffpreview.py
+
+Copyright 2021 Urban Wallasch <irrwahn35@freenet.de>
+
+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('<Escape>', die_ev)
+root.bind('<Control-w>', die_ev)
+root.bind('<Control-q>', 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(
+    "<Configure>",
+    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("<MouseWheel>", mouse_wheel) # Windows mouse wheel event
+    canvas.bind_all("<Button-4>", mouse_wheel) # Linux mouse wheel event (Up)
+    canvas.bind_all("<Button-5>", mouse_wheel) # Linux mouse wheel event (Down)
+    canvas.bind_all("<Up>", mouse_wheel) # Cursor up key
+    canvas.bind_all("<Down>", mouse_wheel) # Cursor down key
+
+def unbind_mousewheel(event):
+    canvas.unbind_all("<MouseWheel>")
+    canvas.unbind_all("<Button-4>")
+    canvas.unbind_all("<Button-5>")
+    canvas.unbind_all("<Up>")
+    canvas.unbind_all("<Down>")
+
+scrollframe.bind('<Enter>', bind_mousewheel)
+scrollframe.bind('<Leave>', 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("<Button-1>", 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