Last active
April 6, 2022 21:12
-
-
Save hhsprings/b10dff2a3d1005ce705d5d9fbb774da6 to your computer and use it in GitHub Desktop.
to preview ffmpeg's trim
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#! py -3 | |
# -*- coding: utf-8 -*- | |
from __future__ import unicode_literals | |
import io | |
import sys | |
import subprocess | |
import pipes | |
import os | |
import re | |
import logging | |
import json | |
import threading | |
import tempfile | |
import functools | |
import itertools | |
import math | |
from glob import glob | |
from PIL import Image, ImageTk # require pillow. if you dont have it, do "pip install pillow". | |
from mako.template import Template # require mako. if you dont have it, do "pip install mako". | |
from mako.exceptions import MakoException | |
__MYNAME__, _ = os.path.splitext( | |
os.path.basename(sys.modules[__name__].__file__)) | |
#_log = logging.getLogger( | |
# os.path.basename(sys.modules[__name__].__file__)) | |
_log = logging.getLogger() | |
if hasattr("", "decode"): | |
_encode = lambda s: s.encode(sys.getfilesystemencoding()) | |
else: | |
_encode = lambda s: s | |
def _abbreviate(s, left=8, right=8): | |
if len(s) > left + right: | |
rgx = re.compile(r"^(.{%d}).*(.{%d})$" % (left, right)) | |
return rgx.sub(r"\g<1>...\g<2>", s) | |
return s | |
def parse_time(s): | |
""" | |
>>> print("%.3f" % parse_time(3.2)) | |
3.200 | |
>>> print("%.3f" % parse_time(3)) | |
3.000 | |
>>> print("%.3f" % parse_time("00:00:01")) | |
1.000 | |
>>> print("%.3f" % parse_time("00:00:01.3")) | |
1.300 | |
>>> print("%.3f" % parse_time("00:00:01.34")) | |
1.340 | |
>>> print("%.3f" % parse_time("00:00:01.034")) | |
1.034 | |
>>> print("%.3f" % parse_time("00:00:01.345")) | |
1.345 | |
>>> print("%.3f" % parse_time("00:01:01.345")) | |
61.345 | |
>>> print("%.3f" % parse_time("02:01:01.345")) | |
7261.345 | |
>>> print("%.3f" % parse_time("01:01.345")) | |
61.345 | |
""" | |
try: | |
return float(s) | |
except ValueError: | |
if "." in s: | |
n, _, ss = s.rpartition(".") | |
else: | |
n, ss = s, "0" | |
n = n.split(":") | |
if len(n) > 3: | |
raise ValueError("'{}' is not valid time.".format(s)) | |
result = sum([ | |
p * 60**(len(n) - 1 - i) | |
for i, p in enumerate(list(map(int, n)))]) | |
result += int(ss) / float((10**len(ss))) | |
return result | |
def _ts_to_tss(ts, frac=3): | |
d, _, f = (("%%.%df" % frac) % ts).partition(".") | |
d = abs(int(d)) | |
ss_h = int(d / 3600) | |
d -= ss_h * 3600 | |
ss_m = int(d / 60) | |
d -= ss_m * 60 | |
ss_s = int(d) | |
return "%s%02d:%02d:%02d.%s" % ( | |
"" if ts >= 0 else "-", | |
ss_h, ss_m, ss_s, f) | |
def _getslice_from_arg(arg, isjson): | |
try: | |
if isjson: | |
_sl = json.loads(arg) | |
else: | |
_sl = arg | |
_times = 5 | |
_t = 2.0 | |
if isinstance(_sl, (dict,)): | |
_ss, _t = _sl.get("ss", 0.0), _sl.get("step", _t) | |
_ss = parse_time(_ss) | |
_to = _sl.get("to", _ss + _t * _times) | |
elif not isinstance(_sl, (list,)) or not len(_sl): | |
raise ValueError("--from_slice: invalid") | |
elif len(_sl) == 1: | |
_ss = parse_time(_sl[0]) | |
_to = _ss + _t * _times | |
elif len(_sl) == 2: | |
_ss, _to = _sl | |
elif len(_sl) >= 2: | |
_ss, _to, _t = _sl[:3] | |
_t = float(_t) | |
_ss, _to = parse_time(_ss), parse_time(_to) | |
except Exception as exc: | |
raise ValueError("cannot generate ranges:\n {!r} ({})".format(arg, exc)) | |
if _ss >= _to or _t <= 0: | |
raise ValueError("cannot generate ranges:\n {!r}".format(arg)) | |
_t = float(_t) | |
return _ss, _to, _t | |
def _getrangelist_from_arg(arg, isjson): | |
_mar = 1.5 | |
invalid = True | |
try: | |
if isjson: | |
_aslst = json.loads(arg) | |
else: | |
_aslst = arg | |
if isinstance(_aslst, (list,)) and len(_aslst) == 1: | |
_aslst = _aslst[0] | |
if isinstance(_aslst, (list,)): | |
_aslst = list(map(parse_time, _aslst)) | |
invalid = False | |
elif isinstance(_aslst, (str, float, int)): | |
t = parse_time(_aslst) | |
if t > _mar: | |
_aslst = [t - _mar, t, t + _mar] | |
else: | |
_aslst = [t, t + _mar] | |
invalid = False | |
except (json.decoder.JSONDecodeError, ValueError): | |
pass | |
if invalid: | |
raise ValueError("invalid ranges:\n {!r}".format(arg)) | |
return _aslst | |
def _yr_g(ss, to, t): | |
while ss < to: | |
_t = min(min(ss + t, to) - ss, t) | |
if _t < (1./120): | |
_log.warning( | |
'omit {"ss": %s, "t": %g}, because it\'s too small.', | |
_ts_to_tss(ss), _t) | |
else: | |
yield ss, _t | |
ss += t | |
def _yr_r(aslst): | |
for _ss, _to in zip(aslst[:-1], aslst[1:]): | |
_t = _to - _ss | |
if _t <= 0: | |
ap.error("invalid ranges:\n {!r}".format( | |
[_ts_to_tss(_ss), _ts_to_tss(_ss + _t)])) | |
yield _ss, _t | |
def _yr(args): | |
if args.from_slice: | |
_ss, _to, _t = _getslice_from_arg(args.from_slice, True) | |
return list(_yr_g(_ss, _to, _t)) | |
elif args.from_splitpoints: | |
_aslst = _getrangelist_from_arg(args.from_splitpoints, True) | |
return list(_yr_r(_aslst)) | |
else: | |
return [] | |
def _remove_silently(fn): | |
if os.path.exists(fn): | |
try: | |
os.remove(fn) | |
except OSError as ex: | |
_log.warning(ex) | |
def _get_streamsinfo(args): | |
cmdl = [ | |
"ffprobe", | |
"-hide_banner", | |
"-show_streams", | |
args.video, | |
] | |
tfn = tempfile.mktemp() | |
try: | |
with io.open(tfn, "wb") as err: | |
#raw = subprocess.check_output( | |
# list(map(_encode, filter(None, cmdl))), stderr=subprocess.DEVNULL) | |
raw = subprocess.check_output(list(map(_encode, filter(None, cmdl))), stderr=err) | |
stderrout = io.open(tfn, "rb").read() | |
dur = re.search(br" Duration: ([\d:.]+),", stderrout).group(1).decode() | |
finally: | |
if os.path.exists(tfn): | |
os.remove(tfn) | |
result = [] | |
for line in re.split(r"\r?\n", raw.decode("utf-8").strip()): | |
if line == "[STREAM]": | |
result.append({}) | |
elif line == "[/STREAM]": | |
pass | |
else: | |
k, _, v = line.partition("=") | |
result[-1][k] = v | |
for s in result: | |
if s.get("TAG:DURATION", "N/A") == "N/A" and \ | |
s.get("duration", "N/A") == "N/A": | |
s["duration"] = dur | |
return result | |
def _update_argmediainfo(args): | |
mi = _get_streamsinfo(args) | |
hasv, hasa = False, False | |
channel_layout = "stereo" | |
for it in mi: | |
#_log.info("\n%s", json.dumps(it, indent=2)) | |
ct = it.get("codec_type", "") | |
if ct == "video": | |
hasv = True | |
elif ct == "audio": | |
hasa = True | |
if "channel_layout" in it: | |
channel_layout = it["channel_layout"] | |
durs = [ | |
s.get("TAG:DURATION", "N/A") for s in mi] + [ | |
s.get("duration", "N/A") for s in mi] | |
#_log.info("%s", durs) | |
setattr(args, "actually_hasvideo", hasv) | |
setattr(args, "actually_hasaudio", hasa) | |
# | |
duration = min([parse_time(d) for d in durs if d != "N/A"]) | |
setattr(args, "duration", duration) | |
if not hasv and not args.no_input_vstream: | |
args.no_input_vstream = True | |
if not hasa and not args.no_input_astream: | |
args.no_input_astream = True | |
setattr(args, "channel_layout", channel_layout) | |
def _build_ffmpeg_cmdline(args, _ss, _t, scale, volume, outfn): | |
isstill = not outfn.endswith(".mkv") | |
# | |
af = "" | |
vf = ["[0:v]"] | |
if not isstill: | |
vf.append("subtitles='{}'".format( | |
outfn.replace("\\", "/").replace(":", "\\:").replace(".mkv", ".srt"))) | |
hasv = not args.no_input_vstream | |
hasa = not args.no_input_astream | |
audobsdur = min(_ss, args.audio_visualize_duration_if_no_input_vstream) | |
if isstill: | |
rngctlargs = [ | |
["-ss", "{}".format(_ss)], # input's -ss (first occurence) | |
["-r", "1/1"], | |
0, # trim={} | |
["-t", "1"], | |
] | |
_t = 1 | |
else: | |
rngctlargs = [ | |
["-ss", "{}".format(_ss)], # input's -ss (first occurence) | |
[None, None], | |
0, # trim={} | |
["-t", "{}".format(_t)], | |
] | |
# | |
if hasa: | |
if args.no_input_vstream and args.audio_visualize_if_no_input_vstream: | |
exf = args.audio_visualize_if_no_input_vstream | |
if isstill: | |
vf[0] = """[0:a]{},""".format(exf) | |
else: | |
af = """[0:a]asplit[a1][a2];[a2]volume={:g}""".format(volume) | |
vf[0] = """[a1]{},""".format(exf) | |
hasv = True | |
_ss -= audobsdur | |
if isstill: | |
_t = audobsdur | |
rngctlargs[0][1] = "{}".format(_ss) # -ss | |
rngctlargs[1][1] = "1/{}".format(_t) # -r | |
rngctlargs[2] = _t # trim={} | |
rngctlargs[3][1] = "{}".format(_t) # -t | |
else: | |
rngctlargs[0][1] = "{}".format(_ss) # -ss | |
rngctlargs[2] = audobsdur # trim={} | |
rngctlargs[3][1] = "{}".format(_t) # -t | |
elif not isstill: | |
af = """[0:a]volume={:g}""".format(volume) | |
if not hasv: | |
vf[0] = "color=black:size=1280x720,loop=-1:size=2,trim=0:{},".format(_t) | |
vf.append("trim={},setpts=PTS-STARTPTS".format(rngctlargs[2])) | |
if af: | |
af = af + ",aformat=channel_layouts={},atrim={},asetpts=PTS-STARTPTS".format( | |
args.channel_layout, | |
rngctlargs[2]) | |
if not args.no_drawpts and not isstill: | |
vf.append( | |
"drawtext='fontsize=90:fontcolor=gray:text=%{{pts\:hms}}':x=80:y=80".format()) | |
vf.extend([ | |
"scale={}".format(scale), | |
"setsar=1", | |
]) | |
# | |
# | |
cf = ";".join(filter(None, [ | |
af, | |
vf[0] + ",".join(vf[1:])])) | |
# | |
cmdl = [args.ffmpeg, "-y", "-hide_banner"] | |
cmdl.extend(rngctlargs[0]) # -ss | |
cmdl.extend([ | |
"-i", args.video, | |
"-filter_complex", cf,]) | |
if isstill: | |
cmdl.extend(rngctlargs[1]) # -r | |
cmdl.extend(rngctlargs[3]) # -t | |
cmdl.append(outfn) | |
return cmdl | |
def _execute_preview_one(args, player_command, _ss, _t, scale, volume, cb=lambda msg: _log.info(msg)): | |
_ss1_s, _to1_s = _ts_to_tss(_ss), _ts_to_tss(_ss + _t) | |
_ss2_s, _to2_s = _ts_to_tss(0), _ts_to_tss(_t) | |
if _ss >= args.duration: | |
_log.warning("start time %s is out of range. (must be < %s)", _ss1_s, _ts_to_tss(args.duration)) | |
return | |
elif _t <= 0: | |
_log.warning("invalid duration: t=%g", _t) | |
return | |
# | |
r = _Renderer( | |
args, | |
args.logview_initial_template) | |
result, error = r.render(ss=_ss, to=_ss + _t, selected_ranges=[(_ss, _ss + _t)]) | |
if not error: | |
# TODO?: customizable output stream...? | |
sys.stdout.write(result + "\n") | |
sys.stdout.flush() | |
else: | |
_log.warning("%s", error) | |
# | |
tmpoutb = tempfile.mktemp() | |
tmpout, tmpoutsrt = tmpoutb + ".mkv", (tmpoutb + ".srt").replace("\\", "/") | |
atexit.register(_remove_silently, tmpout) | |
atexit.register(_remove_silently, tmpoutsrt) | |
# | |
srttxt = """\ | |
1 | |
{} --> {} | |
{} <font color=0x7F7FFF3F>~</font> {} | |
""".format( | |
_ss2_s.replace(".", ","), | |
_to2_s.replace(".", ","), | |
re.sub(r"([\d:]+\.)(\d+)", r"<font size=30>\1</font><font size=20>\2</font>", _ss1_s), | |
re.sub(r"([\d:]+\.)(\d+)", r"<font size=30>\1</font><font size=20>\2</font>", _to1_s)) | |
with io.open(tmpoutsrt, "w") as fosrt: | |
fosrt.write(srttxt) | |
# | |
cmdl = _build_ffmpeg_cmdline(args, _ss, _t, scale, volume, tmpout) | |
kwam, kwap = {}, {} | |
if "ffmpeg_stdout" not in args.verbose: | |
kwam["stdout"] = subprocess.DEVNULL | |
if "ffmpeg_stderr" not in args.verbose: | |
kwam["stderr"] = subprocess.DEVNULL | |
if "player_stdout" not in args.verbose: | |
kwap["stdout"] = subprocess.DEVNULL | |
if "player_stderr" not in args.verbose: | |
kwap["stderr"] = subprocess.DEVNULL | |
#_log.debug("%r", cmdl) | |
cmdl = list(map(_encode, filter(None, cmdl))) | |
cb('"{}" -ss "{}" -i "{}" -ss 0 -t {} ...'.format( | |
cmdl[0], _ts_to_tss(_ss), | |
_abbreviate(os.path.basename(args.video), left=12, right=16), | |
_t)) | |
subprocess.check_call(cmdl, **kwam) | |
cb("") | |
# | |
cmdl = json.loads(player_command) | |
cmdl.append(tmpout) | |
cb('"{}" "{}"'.format(cmdl[0], tmpout)) | |
subprocess.check_call(list(map(_encode, cmdl)), **kwap) | |
cb("") | |
def _create_thumbnailimage(args, ss, ressize=(320, 180)): | |
thmsize = args.guimain_thumbnail_size | |
thmlsize = args.guimain_thumbnail_size_large | |
ffmscale = "{}:{}".format(*thmlsize) | |
#_log.info("%s, %s, %s, %s", args.video, ss, args.duration, ss >= args.duration) | |
if ss >= args.duration: | |
_log.warning("start time %s is out of range. (must be < %s)", _ts_to_tss(ss), _ts_to_tss(args.duration)) | |
im_s = ImageTk.PhotoImage(Image.new("RGB", thmsize)) | |
im_l = ImageTk.PhotoImage(Image.new("RGB", thmlsize)) | |
return im_s, im_l | |
# | |
tmpout = os.path.join( | |
tempfile.gettempdir(), | |
re.sub(r"[\s:/\\.]", r"_", "{},{},{},{},{},{}".format( | |
args.video, | |
ss, | |
args.no_input_vstream, | |
args.no_input_astream, | |
args.audio_visualize_if_no_input_vstream, | |
args.audio_visualize_duration_if_no_input_vstream)) + ".png") | |
# | |
cmdl = _build_ffmpeg_cmdline(args, ss, None, ffmscale, 1.0, tmpout) | |
if not os.path.exists(tmpout): | |
atexit.register(_remove_silently, tmpout) | |
kwam = {} | |
if "ffmpeg_stdout" not in args.verbose: | |
kwam["stdout"] = subprocess.DEVNULL | |
if "ffmpeg_stderr" not in args.verbose: | |
kwam["stderr"] = subprocess.DEVNULL | |
subprocess.check_call(list(map(_encode, filter(None, cmdl))), **kwam) | |
# | |
image = Image.open(tmpout) | |
image_s = image.resize(ressize) | |
img = ImageTk.PhotoImage(image_s) | |
img_l = ImageTk.PhotoImage(image) | |
return img, img_l | |
try: | |
import Tkinter as tkinter # python 2.x, but sorry, I'm not testing it. | |
import ttk | |
from Tkinter import E, W, N, S # for sticky | |
def _askopenfilename(master, *args, **kwargs): | |
import tkFileDialog | |
return tkFileDialog.askopenfilename(*args, **kwargs) | |
from tkSimpleDialog import Dialog | |
from ScrolledText import ScrolledText | |
except ImportError: | |
import tkinter | |
from tkinter import ttk | |
from tkinter import E, W, N, S # for sticky | |
def _askopenfilename(master, *args, **kwargs): | |
from tkinter.filedialog import askopenfilenames | |
return askopenfilenames(*args, **kwargs) | |
from tkinter.simpledialog import Dialog | |
from tkinter.scrolledtext import ScrolledText | |
try: | |
import idlelib # for ToolTip, etc. | |
try: | |
from idlelib.ToolTip import ToolTip as Hovertip | |
except ImportError: | |
from idlelib.tooltip import Hovertip | |
except ImportError: | |
def Hovertip(*args): pass | |
class CmdwrapperForDynmenu(object): | |
""" | |
This is a workaround for 'No more menus can be allocated.' | |
when we are using tkinter.Menu dynamically. | |
""" | |
def __init__(self, menu, cmd): | |
self._menu = menu | |
self._cmd = cmd | |
def __call__(self, *args, **kwargs): | |
try: | |
self._cmd(args, kwargs) | |
finally: | |
#self._menu.delete(0, "end") | |
self._menu.destroy() | |
class AskOpenFilenameDialog(Dialog): | |
""" | |
A pair of a text entry for path and a browse button. | |
""" | |
def __init__(self, | |
parent, title, | |
entry_width, | |
initialpath, | |
*args, **kwargs): | |
self._parent = parent | |
self._entry_width = entry_width | |
self._result = self._initialpath = initialpath | |
Dialog.__init__(self, parent, title) | |
def body(self, master): | |
self._pathentvar = tkinter.StringVar() | |
self._pathentvar.set(self._initialpath) | |
pathent = tkinter.Entry( | |
master, textvariable=self._pathentvar, width=self._entry_width) | |
pathent.pack(side=tkinter.LEFT) | |
_HISTMNG.register_control("openvideo", pathent) | |
browsebtn = tkinter.Button(master, text="...", command=self._browse) | |
browsebtn.pack() | |
return pathent | |
def _browse(self, *args): | |
initial = self._pathentvar.get() | |
initialdir = None | |
if initial: | |
if os.path.isdir(initial): | |
initialdir = os.path.abspath(initial) | |
else: | |
initialdir = os.path.abspath(os.path.dirname(initial)) | |
f = _askopenfilename(self._parent, multiple=0, initialdir=initialdir) | |
if f: | |
self._pathentvar.set(f[0]) | |
def apply(self): | |
self._result = self._pathentvar.get() | |
@property | |
def path(self): | |
return self._result | |
def _my_askopenfilename(master, inidir=""): | |
dlg = AskOpenFilenameDialog( | |
master, "select video", 120, inidir) | |
return dlg.path | |
def _setup_listboxframe(master, *args, **kwargs): | |
"""warn: this function is not for general purposes but only for my script.""" | |
fr = tkinter.Frame(master) | |
lb = tkinter.Listbox(fr, *args, **kwargs) | |
# | |
if kwargs.get("selectmode") == tkinter.MULTIPLE: | |
# we want get features of both MULTIPLE and EXTENDED... | |
lb.configure(selectmode=tkinter.MULTIPLE) | |
class _ExtImpl(object): | |
def __init__(self, lb_): | |
self._lb = lb_ | |
self._lb.bind("<<ListboxSelect>>", self._track_lastselection, add=1) | |
self._lastsels = self._lb.curselection() | |
self._lastsel = None | |
def _track_lastselection(self, *args): | |
newsels = self._lb.curselection() | |
es = list(set(self._lastsels) ^ set(newsels)) | |
if es: | |
self._lastsel = es[0] | |
else: | |
self._lastsel = None | |
self._lastsels = newsels | |
def extend_selection(self, sign, *args): | |
if self._lastsel is None: | |
return | |
self._lastsel = min(max(0, self._lastsel + sign), self._lb.size()) | |
self._lb.activate(self._lastsel) | |
self._lb.selection_set(self._lastsel) | |
eimpl = _ExtImpl(lb) | |
lb.bind("<Shift-Up>", functools.partial(eimpl.extend_selection, -1)) | |
lb.bind("<Shift-Down>", functools.partial(eimpl.extend_selection, 1)) | |
# exportselection=False to keep selections highlighted | |
lb.configure(exportselection=False) | |
# | |
lb.pack(side=tkinter.LEFT, fill=tkinter.Y) | |
sc = tkinter.Scrollbar(fr) | |
sc.pack(side=tkinter.RIGHT, fill=tkinter.Y) | |
lb.config(yscrollcommand=sc.set) | |
sc.config(command=lb.yview, takefocus=1) | |
sc.bind("<Up>", lambda *args: lb.yview_scroll(-1, "unit")) | |
sc.bind("<Prior>", lambda *args: lb.yview_scroll(-1, "page")) | |
sc.bind("<Down>", lambda *args: lb.yview_scroll(1, "unit")) | |
sc.bind("<Next>", lambda *args: lb.yview_scroll(1, "page")) | |
return fr, sc, lb | |
class _MyEntry(tkinter.Entry): | |
def __init__( | |
self, | |
root, | |
validcharsfun, | |
clipboard_filter_fun, | |
*args, **kwargs): | |
# | |
tkinter.Entry.__init__(self, root, *args, **kwargs) | |
self._last_valid = "" | |
# | |
def _validator(*args): | |
P, d, s = args | |
if d == "0": # delete | |
if validcharsfun(s): | |
self._last_valid = s | |
elif d == "1": # insert | |
if validcharsfun(P): | |
self._last_valid = P | |
else: | |
class _revert(): | |
__name__ = "_MyEntry.__init__._validator._revert" | |
def __init__(self, e, rv): | |
self._e = e | |
self._rv = rv | |
def __call__(self): | |
self._e.delete(0, "end") | |
self._e.insert(0, self._rv) | |
self.after(10, _revert(self, self._last_valid)) | |
return True | |
# | |
# %d = Type of action (1=insert, 0=delete, -1 for others) | |
# %i = index of char string to be inserted/deleted, or -1 | |
# %P = value of the entry if the edit is allowed | |
# %s = value of entry prior to editing | |
# %S = the text string being inserted or deleted, if any | |
# %v = the type of validation that is currently set | |
# %V = the type of validation that triggered the callback | |
# (key, focusin, focusout, forced) | |
# %W = the tk name of the widget | |
self.config(validate="key") | |
self.config(validatecommand=(root.register(_validator), '%P', '%d', '%s')) | |
def _clipboard_filter(*args): | |
nc = clipboard_filter_fun(self.clipboard_get()) | |
self.clipboard_clear() | |
self.clipboard_append(nc) | |
self.bind("<Control-v>", _clipboard_filter) | |
class _MyNumEntry(_MyEntry): | |
def __init__( | |
self, root, | |
validcharsfun, | |
minval, | |
convfuns, | |
incrvals, # (Up, Control-Up, Shift-Up, Prior) | |
*args, **kwargs): | |
_MyEntry.__init__( | |
self, root, | |
validcharsfun, | |
lambda txt: re.sub(r"[^0-9.:,eE+-]", "", txt), # for float, time. | |
*args, **kwargs) | |
self._minval = minval | |
self._fromstrfun, self._tostrfun = convfuns | |
self._incrvals = incrvals | |
self.bind("<Up>", lambda *args: self._up(0, *args)) | |
self.bind("<Control-Up>", lambda *args: self._up(1, *args)) | |
self.bind("<Shift-Up>", lambda *args: self._up(2, *args)) | |
self.bind("<Prior>", lambda *args: self._up(3, *args)) | |
self.bind("<Down>", lambda *args: self._dn(0, *args)) | |
self.bind("<Control-Down>", lambda *args: self._dn(1, *args)) | |
self.bind("<Shift-Down>", lambda *args: self._dn(2, *args)) | |
self.bind("<Next>", lambda *args: self._dn(3, *args)) | |
def _up(self, m, *args): | |
delta = self._incrvals[m] | |
try: | |
cur = self.get() | |
rndi = len(("%g" % delta).partition(".")[-1]) | |
if rndi: | |
cur = round(self._fromstrfun(cur), rndi) | |
else: | |
# FIXME: we should take whether if you want to round or not explicitly. | |
cur = self._fromstrfun(cur) | |
cur += delta | |
cur = max(self._minval, cur) | |
# FIXME: idealy, i want to set via textvariable... | |
self.delete(0, "end") | |
self.insert(0, self._tostrfun(cur)) | |
except ValueError as exc: | |
pass | |
def _dn(self, m, *args): | |
delta = self._incrvals[m] | |
try: | |
cur = self.get() | |
rndi = len(("%g" % delta).partition(".")[-1]) | |
if rndi: | |
cur = round(self._fromstrfun(cur), rndi) | |
else: | |
# FIXME: we should take whether if you want to round or not explicitly. | |
cur = self._fromstrfun(cur) | |
cur -= delta | |
cur = max(self._minval, cur) | |
# FIXME: idealy, i want to set via textvariable... | |
self.delete(0, "end") | |
self.insert(0, self._tostrfun(cur)) | |
except ValueError: | |
pass | |
class _MyTSEntry(_MyNumEntry): | |
@staticmethod | |
def _vch(s): | |
return (re.match(r"^[\d:.]+$", s) is not None and | |
not re.search(r"([:.]{2,}|[:.][:.])", s) and | |
not re.search(r":\d{3,}", s) and | |
not re.search(r":[6-9]\d", s) and | |
s.count(":") <= 2 and s.count(".") <= 1 and | |
not re.search(r"\..*:", s)) | |
def __init__(self, root, *args, **kwargs): | |
_MyNumEntry.__init__( | |
self, root, | |
_MyTSEntry._vch, | |
0, | |
(parse_time, _ts_to_tss), | |
(0.1, 1, 0.01, 10), | |
*args, **kwargs) | |
self.config(justify="center") | |
class _MyTSSsToEntry(_MyTSEntry): | |
def __init__( | |
self, root, | |
history, # _last_previewed_hist | |
*args, **kwargs): | |
self._font = kwargs.get("font") | |
_MyTSEntry.__init__( | |
self, root, *args, **kwargs) | |
self._history = history | |
def _replacetxt(newtxt, *args): | |
self.delete(0, "end") | |
self.insert(0, newtxt) | |
def _complpop(*args): | |
menu = tkinter.Menu(self, tearoff=0, font=self._font) | |
for ss, t in reversed(self._history): | |
ss_s, to_s = _ts_to_tss(ss), _ts_to_tss(ss + t) | |
spc = " " * (len(ss_s)) | |
menu.add_command( | |
label=ss_s + spc, command=CmdwrapperForDynmenu( | |
menu, functools.partial(_replacetxt, ss_s))) | |
menu.add_command( | |
label=spc + to_s, command=CmdwrapperForDynmenu( | |
menu, functools.partial(_replacetxt, to_s))) | |
ppos_x = self.winfo_rootx() + 10 | |
ppos_y = self.winfo_rooty() - 20 | |
try: | |
menu.tk_popup(ppos_x, ppos_y) | |
finally: | |
menu.grab_release() | |
self.bind("<Control-space>", _complpop, add=1) | |
class _MyFloatEntry(_MyNumEntry): | |
@staticmethod | |
def _vch(s): | |
# should we allow exponential form and negative values? | |
# i dont think so, in this application. | |
return (re.match(r"^[\d.]+$", s) is not None and | |
s.count(".") <= 1) | |
def __init__( | |
self, | |
root, | |
minval, | |
fmt, | |
incrvals, | |
*args, **kwargs): | |
_MyNumEntry.__init__( | |
self, root, | |
_MyFloatEntry._vch, | |
minval, | |
(float, fmt.format), | |
incrvals, | |
*args, **kwargs) | |
class _MyIntEntry(_MyNumEntry): | |
@staticmethod | |
def _vch(s): | |
return (re.match(r"^[\d]+$", s) is not None) | |
def __init__( | |
self, | |
root, | |
minval, | |
incrvals, | |
*args, **kwargs): | |
_MyNumEntry.__init__( | |
self, root, | |
_MyIntEntry._vch, | |
minval, | |
(int, "{:d}".format), | |
incrvals, | |
*args, **kwargs) | |
class _MyStringVar(tkinter.StringVar): | |
_WRITING_TIMEOUT = 500 | |
def __init__(self, observer, paused_writing_callback, master=None, value=None, name=None): | |
tkinter.StringVar.__init__(self, master, value, name) | |
if not hasattr(self, "trace_add"): | |
def _trace(mode, callback): | |
mode = {"write": "w", "read": "r", "undefine": "u"}[mode] | |
self.trace(mode, callback) | |
self.trace_add = _trace | |
self.trace_add("write", functools.partial(self._track_writing, "trace")) | |
self._observer = observer # the target for invoking "after" | |
self._paused_writing_callback = paused_writing_callback | |
self._now_writing = None | |
def _track_writing(self, entrypoint, *args): | |
f = functools.partial(self._track_writing, "after") | |
setattr(f, "__name__", "_MyStringVar_track_writing_after") | |
if entrypoint == "trace": # now writing | |
if self._now_writing: | |
self._observer.after_cancel(self._now_writing) | |
self._now_writing = self._observer.after(self._WRITING_TIMEOUT, f) | |
else: | |
self._paused_writing_callback("paused writing") | |
self._now_writing = None | |
class HistCompletionPopupMng(object): | |
_MAXHIST = 15 | |
def __init__(self, savename): | |
self._savepath = os.path.join( | |
os.environ.get("HOME", os.environ.get("USERPROFILE")), savename) | |
# key: ident, value: a list of history items | |
self.load_history() | |
# key: ident, value: instance of control | |
self._controls = {} | |
def _append_history(self, ident, *args): | |
v = self._controls[ident].get().strip() | |
if not v: | |
return | |
if ident not in self._histories: | |
self._histories[ident] = [] | |
if v not in self._histories[ident]: | |
self._histories[ident].append(v) | |
while len(self._histories[ident]) > self._MAXHIST: | |
self._histories[ident].pop(0) | |
def _popup_history_selection(self, ident, *args): | |
entry = self._controls[ident] | |
hist = self._histories.get(ident, []) | |
if not hist: | |
return | |
def _replacetxt(ent, newtxt, *args): | |
ent.delete(0, "end") | |
ent.insert(0, newtxt) | |
menu = tkinter.Menu(entry, tearoff=0) | |
for item in hist: | |
menu.add_command( | |
label=item, | |
command=CmdwrapperForDynmenu( | |
menu, functools.partial(_replacetxt, entry, item))) | |
ppos_x = entry.winfo_rootx() | |
ppos_y = entry.winfo_rooty() | |
try: | |
menu.tk_popup(ppos_x, ppos_y) | |
finally: | |
menu.grab_release() | |
def register_control( | |
self, | |
ident, | |
entry # the instance of tkinter.Entry (etc.) | |
): | |
self._controls[ident] = entry | |
entry.bind( | |
"<FocusOut>", functools.partial(self._append_history, ident), add=1) | |
entry.bind( | |
"<Control-space>", functools.partial(self._popup_history_selection, ident), add=1) | |
def save_history(self): | |
json.dump(self._histories, io.open(self._savepath, "w", encoding="utf-8")) | |
def load_history(self): | |
# key: ident, value: a list of history items | |
self._histories = {} | |
if os.path.exists(self._savepath): | |
self._histories = json.load(io.open(self._savepath, encoding="utf-8")) | |
_HISTMNG = HistCompletionPopupMng(".{}.uihist".format(__MYNAME__)) | |
class _FCPSettings(Dialog): | |
def __init__(self, parent, settings): | |
self._settings = settings | |
Dialog.__init__(self, parent, "settings") | |
def body(self, master): | |
font = self._settings.font_main | |
font_s = self._settings.font_small | |
# ## | |
gr1 = tkinter.LabelFrame( | |
master, | |
text="[app_defaults]", | |
font=font, padx=10, pady=5) | |
gr1.pack() | |
# ## | |
lbl_sc = tkinter.Label(gr1, text="scale:", font=font_s) | |
lbl_sc.grid(row=0, column=0, sticky=E) | |
self._pdsc = ttk.Combobox(gr1, width=12, font=font) | |
sccands = [ | |
"640:-1", "-1:360", "-1:270", "-1:180", "-1:90", | |
"480:270", "640:360", "800:450", "960:540", "1280:720", | |
"1440:810", "1600:900", "1920:1080", "3840:2040" | |
] | |
if self._settings.scale not in sccands: | |
sccands.append(self._settings.scale) | |
self._pdsc["values"] = sccands | |
for i, c in enumerate(self._pdsc["values"]): | |
if c == self._settings.scale: | |
self._pdsc.current(i) | |
break | |
self._pdsc.grid(row=0, column=1, sticky=W) | |
lbl_vol = tkinter.Label(gr1, text="volume:", font=font_s) | |
lbl_vol.grid(row=1, column=0, sticky=E) | |
self._entvol = _MyFloatEntry( | |
gr1, | |
0, "{:.5f}", (0.01, 0.1, 0.001, 0.5), | |
width=10, font=font) | |
self._entvol_var = tkinter.StringVar() | |
self._entvol.config(textvariable=self._entvol_var) | |
self._entvol_var.set("%.5f" % float(self._settings.volume)) | |
self._entvol.grid(row=1, column=1, sticky=W) | |
# | |
lbl_fba2vfilter = tkinter.Label( | |
gr1, text="audio visualizing filter\n(if media has no video stream):", font=font_s) | |
lbl_fba2vfilter.grid(row=2, column=0, sticky=E) | |
self._entfba2vfilter = tkinter.Entry( | |
gr1, width=120, font=font) | |
_HISTMNG.register_control("audio visualizing filter", self._entfba2vfilter) | |
self._entfba2vfilter_var = tkinter.StringVar() | |
self._entfba2vfilter.config(textvariable=self._entfba2vfilter_var) | |
self._entfba2vfilter_var.set( | |
self._settings.audio_visualize_if_no_input_vstream) | |
self._entfba2vfilter.grid(row=2, column=1, sticky=W) | |
## | |
lbl_fba2vfilterdur = tkinter.Label( | |
gr1, text="audio visualizing filter buffer (sec)\n(if media has no video stream):", font=font_s) | |
lbl_fba2vfilterdur.grid(row=3, column=0, sticky=E) | |
self._entfba2vfilterdur = _MyIntEntry( | |
gr1, | |
1, | |
(1, 2, 1, 5), | |
width=120, font=font) | |
self._entfba2vfilterdur_var = tkinter.StringVar() | |
self._entfba2vfilterdur.config(textvariable=self._entfba2vfilterdur_var) | |
self._entfba2vfilterdur_var.set( | |
self._settings.audio_visualize_duration_if_no_input_vstream) | |
self._entfba2vfilterdur.grid(row=3, column=1, sticky=W) | |
# ## | |
gr2 = tkinter.LabelFrame( | |
master, | |
text="[env]", | |
font=font, padx=10, pady=5) | |
gr2.pack(fill=tkinter.BOTH) | |
# | |
lbl_ffmpegexe = tkinter.Label(gr2, text="ffmpeg:", font=font_s) | |
lbl_ffmpegexe.grid(row=0, column=0, sticky=E) | |
self._entffmpegexe = tkinter.Entry( | |
gr2, | |
width=120, font=font) | |
self._entffmpegexe_var = tkinter.StringVar() | |
self._entffmpegexe.config(textvariable=self._entffmpegexe_var) | |
self._entffmpegexe_var.set(self._settings.ffmpeg) | |
self._entffmpegexe.grid(row=0, column=1, sticky=W) | |
btreffm = tkinter.Button( | |
gr2, | |
text="...", | |
command=lambda *arg: self._entffmpegexe_var.set(_my_askopenfilename(gr2))) | |
btreffm.grid(row=0, column=2, sticky=E) | |
# --------- | |
# | |
self._player_sel = tkinter.IntVar() | |
self._player_sel.set(self._settings.player) | |
def _playervalbrowse(var, *args): | |
n = _my_askopenfilename(gr2) | |
if n: | |
cmdl = json.loads(var.get().strip()) | |
cmdl[0] = n | |
var.set(json.dumps(cmdl, ensure_ascii=False)) | |
lbl_player = tkinter.Radiobutton( | |
gr2, text="player:", value=1, variable=self._player_sel, font=font_s) | |
lbl_player.grid(row=1, column=0, sticky=E) | |
self._entplayer = tkinter.Entry( | |
gr2, | |
width=120, font=font) | |
_HISTMNG.register_control("player", self._entplayer) | |
self._entplayer_var = tkinter.StringVar() | |
self._entplayer.config(textvariable=self._entplayer_var) | |
self._entplayer_var.set(self._settings.player_command) | |
self._entplayer.grid(row=1, column=1, sticky=W) | |
btrefpl = tkinter.Button( | |
gr2, | |
text="...", | |
command=functools.partial(_playervalbrowse, self._entplayer_var)) | |
btrefpl.grid(row=1, column=2, sticky=E) | |
# | |
# | |
lbl_player2 = tkinter.Radiobutton( | |
gr2, text="alternative player:", value=2, variable=self._player_sel, font=font_s) | |
lbl_player2.grid(row=2, column=0, sticky=E) | |
self._entplayer2 = tkinter.Entry( | |
gr2, | |
width=120, font=font) | |
_HISTMNG.register_control("player2", self._entplayer2) | |
self._entplayer2_var = tkinter.StringVar() | |
self._entplayer2.config(textvariable=self._entplayer2_var) | |
self._entplayer2_var.set(self._settings.player2_command) | |
self._entplayer2.grid(row=2, column=1, sticky=W) | |
btrefpl2 = tkinter.Button( | |
gr2, | |
text="...", | |
command=functools.partial(_playervalbrowse, self._entplayer2_var)) | |
btrefpl2.grid(row=2, column=2, sticky=E) | |
# --------- | |
# | |
# | |
return self._pdsc | |
def apply(self): | |
self._settings.scale = self._pdsc.get().strip() | |
self._settings.volume = float(self._entvol_var.get().strip()) | |
self._settings.audio_visualize_if_no_input_vstream = self._entfba2vfilter_var.get().strip() | |
self._settings.audio_visualize_duration_if_no_input_vstream = int(self._entfba2vfilterdur_var.get().strip()) | |
# | |
self._settings.ffmpeg = self._entffmpegexe_var.get().strip() | |
self._settings.player_command = self._entplayer_var.get().strip() | |
self._settings.player2_command = self._entplayer2_var.get().strip() | |
self._settings.player = self._player_sel.get() | |
class _FCPEditTemplateDialog(Dialog): | |
def __init__(self, parent, settings): | |
self._settings = settings | |
self._tdir = self._settings.logview_template_dir | |
Dialog.__init__(self, parent, "template") | |
def body(self, master): | |
# TODO: edittable "logview_template_dir" | |
font = self._settings.font_main | |
font_s = self._settings.font_small | |
frr = tkinter.Frame(master) | |
self._lbitemsvar = tkinter.StringVar() | |
lbfr, lbscr, self._lb = _setup_listboxframe( | |
frr, | |
listvariable=self._lbitemsvar, | |
width=34, | |
font=font, | |
exportselection=False, selectmode=tkinter.BROWSE) | |
lbfr.pack(side="top") | |
self._lb.bind("<<ListboxSelect>>", self._lbselchanged, add=1) | |
btdelete = tkinter.Button(frr, text="delete", font=font, command=self._delete) | |
btdelete.pack(side="left") | |
# | |
rfr = tkinter.Frame(master) | |
rtfr = tkinter.Frame(rfr) | |
tmplnamelbl = tkinter.Label(rtfr, text="name:", font=font_s) | |
tmplnamelbl.pack(side="left") | |
self._tmplnameentvar = tkinter.StringVar() | |
self._tmplnameentvar.set("default") | |
tmplnameent = tkinter.Entry( | |
rtfr, textvariable=self._tmplnameentvar, width=34, font=font) | |
tmplnameent.pack(side="left") | |
btsave = tkinter.Button(rtfr, text="save", font=font, command=self._save) | |
btsave.pack(side="left") | |
rtfr.pack(side="top", anchor="w") | |
# | |
self._entrestmpl = ScrolledText(rfr, width=120, height=20, font=font) | |
self._entrestmpl.insert( | |
"end", self._settings.logview_initial_template) | |
self._entrestmpl.pack(side="top", fill="both") | |
# | |
rfr.pack(side="left", fill="both") | |
frr.pack(side="left", anchor="n") | |
self._refresh() | |
return self._entrestmpl | |
def apply(self): | |
self._settings.logview_initial_template = self._entrestmpl.get("1.0", "end") | |
def _enum_templates(self, pat): | |
return ( | |
os.path.basename(fn) for fn in glob(os.path.join(self._tdir, pat)) | |
if os.path.isfile(fn)) | |
def _lbselchanged(self, *args): | |
cs = self._lb.curselection() | |
if not cs: | |
return | |
item = eval(self._lbitemsvar.get())[cs[0]] | |
self._tmplnameentvar.set(item) | |
tmplfn = os.path.join(self._tdir, item) | |
with io.open(tmplfn, encoding="utf-8") as fi: | |
self._entrestmpl.delete("1.0", "end") | |
self._entrestmpl.insert("end", fi.read()) | |
def _save(self, *args): | |
tmpl = self._entrestmpl.get("1.0", "end") | |
tfn = self._tmplnameentvar.get().strip() | |
tmplfn = os.path.join(self._tdir, tfn) | |
if not os.path.exists(self._tdir): | |
os.makedirs(self._tdir) | |
with io.open(tmplfn, "w", encoding="utf-8") as fo: | |
fo.write(tmpl) | |
self._refresh() | |
def _delete(self, *args): | |
cs = self._lb.curselection() | |
if not cs: | |
return | |
item = eval(self._lbitemsvar.get())[cs[0]] | |
tmplfn = os.path.join(self._tdir, item) | |
os.remove(tmplfn) | |
self._refresh() | |
def _refresh(self, *args): | |
cur = self._entrestmpl.get("1.0", "end") | |
tmpls = list(sorted(self._enum_templates("*"))) | |
self._lbitemsvar.set(tmpls) | |
if cur in tmpls: | |
tmpls.index(cur) | |
self._lb.selection_set(cur) | |
class _MyStatusBar(tkinter.Frame): | |
def __init__(self, master): | |
tkinter.Frame.__init__(self, master) | |
self.variable = tkinter.StringVar() | |
self.label = tkinter.Label( | |
self, bd=1, relief=tkinter.SUNKEN, anchor=tkinter.W, | |
textvariable=self.variable, | |
font=('courier', 10, 'normal')) | |
self.variable.set("") | |
self.label.pack(fill=tkinter.X) | |
self.pack() | |
class _FCPMainGui(object): | |
def _update_mediainfo(self): | |
_update_argmediainfo(self._args) | |
t = "{}{}[DURATION: {}]".format( | |
self._args.video, | |
" " * 10, | |
_ts_to_tss(self._args.duration)) | |
self._root.title(t) | |
def __init__(self, root, args, values): | |
font = args.font_main | |
font_s = args.font_small | |
self._args = args | |
self._root = root | |
# ---------------- | |
self._thmupd_invo = None # for updating thumbnail | |
# ---------------- | |
self._last_previewed_hist = [] # ss, t | |
# ---------------- | |
self._update_mediainfo() | |
# ---------------- | |
menubar = tkinter.Menu(self._root) | |
filemenu = tkinter.Menu(menubar, tearoff=0) | |
def _opennewmedia(*args): | |
nn = _my_askopenfilename(self._root, self._args.video) | |
if nn: | |
self._args.video = nn | |
self._update_mediainfo() | |
self._ss_changed() | |
self._to_changed() | |
filemenu.add_command(label="Open (C-o)", command=_opennewmedia) | |
self._no_input_vstream = tkinter.BooleanVar() | |
self._no_input_vstream.set(args.no_input_vstream) | |
self._no_input_astream = tkinter.BooleanVar() | |
self._no_input_astream.set(args.no_input_astream) | |
def _vn_convback(*args): | |
if not self._args.actually_hasvideo: | |
self._no_input_vstream.set(True) # ignore user request | |
else: | |
self._args.no_input_vstream = self._no_input_vstream.get() | |
self._ss_changed() | |
self._to_changed() | |
def _an_convback(*args): | |
if not self._args.actually_hasaudio: | |
self._no_input_astream.set(True) # ignore user request | |
else: | |
self._args.no_input_astream = self._no_input_astream.get() | |
self._ss_changed() | |
self._to_changed() | |
filemenu.add_checkbutton( | |
label="input has no video stream (C-n)", | |
variable=self._no_input_vstream, command=_vn_convback) | |
def _toggle_vn(*args): | |
c = self._no_input_vstream.get() | |
self._no_input_vstream.set(not c) | |
_vn_convback(*args) | |
self._root.bind("<Control-n>", _toggle_vn) | |
filemenu.add_checkbutton( | |
label="input has no audio stream", | |
variable=self._no_input_astream, command=_an_convback) | |
filemenu.add_command(label="Quit (C-q)", command=self._root.quit) | |
editmenu = tkinter.Menu(menubar, tearoff=0) | |
def _querysettings(): | |
dlg = _FCPSettings(self._root, self._args) | |
self._ss_changed() | |
self._to_changed() | |
editmenu.add_command(label="Settings...", command=_querysettings) | |
menubar.add_cascade(label="File", menu=filemenu) | |
menubar.add_cascade(label="Edit", menu=editmenu) | |
self._root.config(menu=menubar) | |
# ---------------- | |
# | |
# ## | |
# | |
gfr2 = tkinter.Frame(self._root) | |
grlb = tkinter.LabelFrame( | |
gfr2, | |
text="trimming ranges (&R)", | |
font=font, padx=2, pady=5) | |
# | |
class _LBPopupMenu(object): | |
def __init__(self, lb, menuitems, font): | |
self._lb = lb | |
self._menu = tkinter.Menu(lb, tearoff=0, font=font) | |
for lab, cmd in menuitems: | |
self._menu.add_command(label=lab, command=cmd) | |
lb.bind("<Button-3>", self._do_popup) | |
try: | |
lb.bind("<App>", self._do_popup) | |
except Exception: | |
pass # bad event type | |
def _do_popup(self, ev, *args): | |
sels = self._lb.curselection() | |
if not sels: | |
return | |
ccx, ccy, itw, ith = self._lb.bbox(sels[0]) | |
ppos_x = self._lb.winfo_rootx() + ccx + int(itw / 5 * 2) | |
ppos_y = self._lb.winfo_rooty() + ccy - ith | |
try: | |
self._menu.tk_popup(ppos_x, ppos_y) | |
#self._menu.tk_popup(ev.x_root, ev.y_root) | |
finally: | |
self._menu.grab_release() | |
# | |
self._lbitems = [ | |
self._sst2lbitem(_ss, _t) for _ss, _t in values] | |
self._lbitemsvar = tkinter.StringVar(value=self._lbitems) | |
lbfr, lbsc, self._lb = _setup_listboxframe( | |
grlb, width=30, height=18, font=font, | |
listvariable=self._lbitemsvar, | |
selectmode=tkinter.MULTIPLE, | |
# exportselection=False to keep selections highlighted | |
exportselection=False) | |
self._lbpopupmenu = _LBPopupMenu( | |
self._lb, [ | |
("concatinate selected (&C)", self._concat_selected_ranges), | |
("delete selected (&D)", self._delete_selected_ranges), | |
], font=self._args.font_main) | |
self._lb.selection_set(0) | |
lbfr.pack() | |
# | |
grra = tkinter.Frame(gfr2) | |
# | |
grsted = tkinter.Frame(grra) | |
lbl_rass = tkinter.Label(grsted, text="start:", font=font) | |
lbl_rass.grid(row=0, column=0, sticky=E + W) | |
self._ent_rass = _MyTSSsToEntry( | |
grsted, self._last_previewed_hist, width=15, font=font) | |
self._ent_rass_var = tkinter.StringVar() | |
self._ent_rass.config(textvariable=self._ent_rass_var) | |
self._ent_rass.grid(row=0, column=1) | |
lbl_rato = tkinter.Label(grsted, text="stop:", font=font) | |
lbl_rato.grid(row=0, column=2, sticky=E + W) | |
self._ent_rato = _MyTSSsToEntry( | |
grsted, self._last_previewed_hist, width=15, font=font) | |
self._ent_rato_var = tkinter.StringVar() | |
self._ent_rato.config(textvariable=self._ent_rato_var) | |
self._ent_rato.grid(row=0, column=3) | |
grsted.grid(row=0, column=0, sticky=W) | |
# | |
grstep = tkinter.Frame(grra) | |
lbl_rasp = tkinter.Label(grstep, text=" -> split this range by step:", font=font) | |
lbl_rasp.grid(row=0, column=0, sticky=E + W) | |
self._ent_rasp = _MyFloatEntry( | |
grstep, | |
1./120, "{:.2f}", (0.1, 1, 0.01, 10), | |
width=8, font=font) | |
self._ent_rasp_var = tkinter.StringVar() | |
self._ent_rasp.config(textvariable=self._ent_rasp_var) | |
self._ent_rasp.grid(row=0, column=1) | |
# | |
grstepbtns = tkinter.Frame(grstep) | |
btrg = tkinter.Button( | |
grstepbtns, text="*", | |
command=self._refresh_rangeslist, font=font) | |
btad = tkinter.Button( | |
grstepbtns, text="+", | |
command=self._extend_rangeslist, font=font) | |
btrg.pack(side=tkinter.LEFT) | |
btad.pack() | |
grstepbtns.grid(row=0, column=2) | |
# | |
grstep.grid(row=0, column=1, sticky=E) | |
# | |
_ss, _to, _t = 0, args.duration, "2" | |
if args.from_slice: | |
_ss, _to, _t = _getslice_from_arg(args.from_slice, True) | |
elif args.from_splitpoints: | |
_aslist = list(_yr_r(_getrangelist_from_arg(args.from_splitpoints, True))) | |
if _aslist: | |
_ss, _ = _aslist[0] | |
_sslast, _tlast = _aslist[-1] | |
_to = _sslast + _tlast | |
self._ent_rass_var.set(_ts_to_tss(_ss)) | |
self._ent_rato_var.set(_ts_to_tss(_to)) | |
self._ent_rasp_var.set("%.2f" % float(_t)) | |
# | |
grlb.grid(row=0, column=0, sticky=W + N, rowspan=2) | |
grra.grid(row=0, column=1, sticky=W + N) | |
# | |
grex = tkinter.LabelFrame( | |
gfr2, | |
font=font, padx=2, pady=5) | |
# ---------------------------------------------------------------- | |
lbl_ss = tkinter.Label(grex, text="ss (&S):", font=font) | |
lbl_ss.grid(row=0, column=0, sticky=E + W) | |
self._ent_ss = _MyTSSsToEntry( | |
grex, self._last_previewed_hist, width=15, font=font) | |
self._ent_ss_var = _MyStringVar(grex, self._ss_changed) | |
self._ent_ss.config(textvariable=self._ent_ss_var) | |
self._ent_ss.grid(row=0, column=1) | |
# # | |
self._lbl_mp = tkinter.Entry(grex, font=font, state="readonly", justify="center", takefocus=0) | |
self._lbl_mp_var = tkinter.StringVar() | |
self._lbl_mp.config(textvariable=self._lbl_mp_var) | |
self._lbl_mp.grid(row=0, column=2, columnspan=2) | |
# # | |
lbl_to = tkinter.Label(grex, text=" to (&T):", font=font) | |
lbl_to.grid(row=0, column=4, sticky=E + W) | |
self._ent_to = _MyTSSsToEntry( | |
grex, self._last_previewed_hist, width=15, font=font) | |
self._ent_to_var = _MyStringVar(grex, self._to_changed) | |
self._ent_to.config(textvariable=self._ent_to_var) | |
self._ent_to.grid(row=0, column=5) | |
# ---------------------------------------------------------------- | |
thmsize = self._args.guimain_thumbnail_size | |
thmlsize = self._args.guimain_thumbnail_size_large | |
thmsize_mp = self._args.guimain_thumbnail_size_small | |
# === | |
# -ss img | |
self._thn_ss_img = ImageTk.PhotoImage(Image.new("RGB", thmsize)) | |
self._thn_ss_img_l = ImageTk.PhotoImage(Image.new("RGB", thmlsize)) | |
self._thn_ss = tkinter.Label(grex, image=self._thn_ss_img) | |
self._thn_ss.config( | |
borderwidth=4, | |
relief="ridge" # "flat", "raised", "sunken", "ridge", "solid", "groove" | |
) | |
self._ent_ss.bind("<FocusOut>", self._ss_changed) | |
#self._ent_ss.bind("<Control-u>", self._ss_changed) | |
self._thn_ss.grid(row=1, column=0, columnspan=2) | |
# === | |
# midpoint between [ss, to] | |
self._thn_mp_img = ImageTk.PhotoImage(Image.new("RGB", thmsize_mp)) | |
self._thn_mp_img_l = ImageTk.PhotoImage(Image.new("RGB", thmsize_mp)) | |
self._thn_mp = tkinter.Label(grex, image=self._thn_mp_img) | |
self._thn_mp.config( | |
borderwidth=4, | |
relief="ridge" # "flat", "raised", "sunken", "ridge", "solid", "groove" | |
) | |
self._thn_mp.grid(row=1, column=2, columnspan=2) | |
# === | |
# -to img | |
self._thn_to_img = ImageTk.PhotoImage(Image.new("RGB", thmsize)) | |
self._thn_to_img_l = ImageTk.PhotoImage(Image.new("RGB", thmlsize)) | |
self._thn_to = tkinter.Label(grex, image=self._thn_to_img) | |
self._thn_to.config( | |
borderwidth=4, | |
relief="ridge" # "flat", "raised", "sunken", "ridge", "solid", "groove" | |
) | |
self._ent_to.bind("<FocusOut>", self._to_changed) | |
#self._ent_to.bind("<Control-u>", self._to_changed) | |
self._thn_to.grid(row=1, column=4, columnspan=2) | |
# ---------------------------------------------------------------- | |
# | |
# | |
paubtns = tkinter.Frame(grex) | |
btdo = tkinter.Button(paubtns, text=" P ", | |
command=self._execute_viewer, font=font) | |
btdo.pack(side=tkinter.LEFT) | |
Hovertip(btdo, "Preview (&P)") | |
# | |
btap = tkinter.Button(paubtns, text=" A ", | |
command=self._append_this_range, font=font) | |
btap.pack(side=tkinter.LEFT) | |
Hovertip(btap, "Append this range (&A)") | |
btup = tkinter.Button(paubtns, text=" U ", | |
command=self._update_with_this_range, font=font) | |
btup.pack(side=tkinter.LEFT) | |
Hovertip(btup, "Update with this range (&U)") | |
paubtns.grid(row=2, column=0, sticky=W) | |
grex.grid(row=1, column=1, sticky=E + W) | |
gfr2.pack() | |
# ---------------- | |
self._logview_collapsed = True | |
def _toggle_logview(*args): | |
if self._logview_collapsed: | |
self._gfr4.pack(fill=tkinter.BOTH, expand=1) | |
else: | |
self._gfr4.forget() | |
self._logview_collapsed = not self._logview_collapsed | |
gfr3 = tkinter.Frame(self._root) | |
bttlv = tkinter.Button( | |
gfr3, text="... (&L)", | |
command=_toggle_logview, font=font) | |
bttlv.pack(fill=tkinter.BOTH) | |
gfr3.pack(fill=tkinter.BOTH, expand=1) | |
##### | |
self._gfr4 = tkinter.Frame(self._root) | |
btdorender = tkinter.Button( | |
self._gfr4, text="Print (&N)", | |
command=self._execute_render, font=font) | |
btdorender.pack(fill=tkinter.BOTH) | |
# | |
frentres = tkinter.Frame(self._gfr4) | |
# | |
def _tmpleditdialig(*args): | |
_FCPEditTemplateDialog(self._root, self._args) | |
bttmpledt = tkinter.Button( | |
frentres, text="...", | |
command=_tmpleditdialig, font=font_s) | |
bttmpledt.pack(side=tkinter.LEFT, anchor=tkinter.N) | |
# | |
self._entres = ScrolledText(frentres, width=150, height=12, font=font) | |
self._entres.pack(side=tkinter.LEFT, fill=tkinter.BOTH) | |
frentres.pack(fill=tkinter.BOTH) | |
# | |
self._gfr4.pack(fill=tkinter.BOTH, expand=1) | |
self._gfr4.forget() | |
### | |
self._root.bind("<Alt-l>", _toggle_logview) | |
##### | |
# ---------------- | |
self._statusbar = _MyStatusBar(self._root) | |
self._statusbar.pack(fill=tkinter.BOTH) | |
# ---------------- | |
# | |
if self._args.guimain_exec_on_init: | |
self._execute_viewer() | |
# | |
self._root.bind("<Control-q>", lambda *args: self._root.destroy()) | |
self._root.bind("<Control-o>", _opennewmedia) | |
self._root.bind("<Alt-r>", lambda *args: self._lb.focus_set()) | |
self._root.bind("<Alt-s>", lambda *args: self._ent_ss.focus_set()) | |
self._root.bind("<Alt-t>", lambda *args: self._ent_to.focus_set()) | |
# | |
self._root.bind("<Alt-a>", self._append_this_range) | |
self._root.bind("<Alt-u>", self._update_with_this_range) | |
self._root.bind("<Alt-d>", self._delete_selected_ranges) | |
self._root.bind("<Alt-c>", self._concat_selected_ranges) | |
self._root.bind("<Alt-p>", self._execute_viewer) | |
self._root.bind("<Alt-n>", self._execute_render) | |
self._lb.bind("<<ListboxSelect>>", self._lbselchanged, add=1) | |
# --- | |
class _ImgShowPopup(Dialog): | |
def __init__(self, | |
parent, args, | |
whrange, iniimg, t, step, tickpos, | |
on_apply, | |
preview_command, inherit_commands): | |
self._args = args | |
self._img = iniimg | |
self._t = t | |
self._ss, self._to = whrange | |
# | |
self._step = max(1/60., step) | |
# | |
self._on_apply = on_apply | |
self._preview_command = preview_command | |
self._inherit_commands = inherit_commands | |
self._tickpos = tickpos | |
title = "{} -> {}".format(_ts_to_tss(self._ss), _ts_to_tss(self._to)) | |
# | |
Dialog.__init__(self, parent, title) | |
def _build_mini_preview_ranges(self): | |
_fps = 30.0 # NOTE: is not actual framerate | |
#facs = (1, 2, 5, 10, 15, 20, 25, 30, 37.5, 45, 60, 75, 90, 120) | |
facs = (1, 2, 5, 10, 15, 20, 25, 30, 37.5, 45, 60, 75) | |
#facs = (1, 2, 5, 10, 15, 20, 25, 30, 37.5, 45, 60) | |
def get0_(f): return 1 / _fps * f | |
def get1_(f): return self._t + get0_(f) | |
tlst = [] | |
tlst.extend([(get1_(-f), "{:+.3f}".format(get0_(-f))) for f in facs]) | |
tlst.append((get1_(0), 'here')) | |
tlst.extend([(get1_(f), "{:+.3f}".format(get0_(f))) for f in facs]) | |
tlst.append((self._ss, '"-ss"')) | |
tlst.append((self._to, '"-to"')) | |
tlst.append((self._t - self._step, 'prev')) | |
tlst.append((self._t + self._step, 'next')) | |
rngs = list(itertools.combinations(tlst, 2)) | |
return list(sorted([ | |
((p[0] if p[0][0] < p[1][0] else p[1]), (p[1] if p[0][0] < p[1][0] else p[0])) | |
for p in rngs])) | |
def _updtstxt(self): | |
# | |
whr = self._to - self._ss | |
prgr = 0.0 | |
if whr: | |
prgr = (self._t - self._ss) / whr * 100. | |
prgr = "{:+5.1f}%".format(prgr) | |
# | |
t1 = '{}'.format(_ts_to_tss(self._t)) | |
t2 = '(ss{:+.3f}, to{:+.3f}, {}) '.format( | |
self._t - self._ss, self._t - self._to, prgr) | |
self._tstxt1.config(text=t1) | |
self._tstxt2.config(text=re.sub(r"([+-])", r" \1 ", t2)) | |
fgc = "black" | |
relief = "flat" | |
if self._t < self._ss or self._to < self._t: | |
fgc = "red" | |
elif math.isclose(self._t, self._ss) or math.isclose(self._to, self._t): | |
relief = "groove" | |
self._tstxt1.config(foreground=fgc, relief=relief, borderwidth=2) | |
def _do_previewpopup(self, *args): | |
rngs = self._build_mini_preview_ranges() | |
menu = tkinter.Menu(self._btprv, tearoff=0, font=self._args.font_main) | |
# | |
if not hasattr(self, "_lastselpreview"): | |
self._lastselpreview = (None, None) | |
def _menu_previewcmd(ss, to, *args): | |
self._lastselpreview = (ss, to) | |
cmd = CmdwrapperForDynmenu( | |
menu, functools.partial(self._do_preview, ss, to)) | |
cmd(*args) | |
# | |
submenus = [ | |
tkinter.Menu(menu, tearoff=0, font=self._args.font_main), # for "from negative" | |
tkinter.Menu(menu, tearoff=0, font=self._args.font_main), # for "from positive" | |
] | |
menu.add_cascade(label="- ~ ", menu=submenus[0]) | |
for ssf, items in itertools.groupby(rngs, lambda it: it[0]): | |
if "here" not in ssf[1]: | |
submenu = submenus[0] if ssf[0] < self._t else submenus[1] | |
else: | |
submenu = menu | |
subsubmenu = tkinter.Menu(submenu, tearoff=0, font=self._args.font_main) | |
sepadded = False | |
for i, item in enumerate(items): | |
if not sepadded and item[1][1][0] not in "+-": | |
subsubmenu.add_separator() | |
ss_, to_ = ssf[0], item[1][0] | |
fg = "red" if str(self._lastselpreview) == str((ss_, to_)) else "black" | |
subsubmenu.add_command( | |
label=" ~ " + item[1][1], | |
foreground=fg, | |
command=functools.partial(_menu_previewcmd, ss_, to_)) | |
if item[1][1][0] not in "+-": | |
subsubmenu.add_separator() | |
sepadded = True | |
else: | |
sepadded = False | |
submenu.add_cascade(label=ssf[1] + " ~ ", menu=subsubmenu) | |
menu.add_cascade(label="+ ~ ", menu=submenus[1]) | |
# | |
ppos_x = self._btprv.winfo_rootx() + 10 | |
ppos_y = self._btprv.winfo_rooty() + 20 | |
try: | |
menu.tk_popup(ppos_x, ppos_y) | |
finally: | |
menu.grab_release() | |
def _nav(self, sign, scale, *args): | |
self._t += sign * self._step * scale | |
self._updtstxt() | |
_, self._img = _create_thumbnailimage( | |
self._args, self._t, ressize=self._args.guimain_thumbnail_size) | |
self._im.config(image=self._img) | |
def _do_preview(self, prvss, prvto, *args): | |
self._lastpreviewed_ss.set(_ts_to_tss(prvss)) | |
self._lastpreviewed_to.set(_ts_to_tss(prvto)) | |
self._preview_command(prvss, prvto - prvss) | |
def body(self, master): | |
frt = tkinter.Frame(master) | |
frt.pack() | |
# | |
frt1 = tkinter.Frame(frt) | |
frt2 = tkinter.Frame(frt) | |
frt1.grid(row=0, column=1) | |
tebt = [" . ", " x "] | |
btvfrt1 = tkinter.Button(frt, text=tebt[0]) | |
Hovertip(btvfrt1, 'toggle view/hide "nav step" control.') | |
btvfrt1.grid(row=0, column=2) | |
frt2.grid(row=0, column=0) | |
frt1tv = tkinter.Frame(frt1) | |
frt1tv.pack(expand=1) | |
def _toggleviewfrt1(*args): | |
curr = btvfrt1.cget("text") | |
if curr == tebt[0]: | |
frt1tv.pack(expand=1) | |
else: | |
frt1tv.forget() | |
btvfrt1.config(text=tebt[1] if curr == tebt[0] else tebt[0]) | |
btvfrt1.config(command=_toggleviewfrt1) | |
btvfrt1.after(100, _toggleviewfrt1) | |
btvfrt1.after(200, _toggleviewfrt1) | |
# | |
steplb1 = tkinter.Label( | |
frt1tv, text="nav step:", font=self._args.font_small) | |
steplb1.grid(row=2, column=0, sticky=E) | |
self._entstep_var = tkinter.StringVar() | |
self._entstep = _MyFloatEntry( | |
frt1tv, | |
1/60., "{:.3f}", (0.1, 1, 0.01, 5), | |
width=10, font=self._args.font_main, textvariable=self._entstep_var) | |
self._entstep_var.set("{:.3f}".format(self._step)) | |
self._entstep.grid(row=2, column=1) | |
def _updstep(*args): | |
self._step = float(self._entstep_var.get()) | |
self._navlbtnlbl.set("{:+.3f}".format(-self._step)) | |
self._navrbtnlbl.set("{:+.3f}".format(self._step)) | |
self._entstep.bind("<FocusOut>", _updstep) | |
# | |
tslbl = tkinter.Label( | |
frt2, text="timestamp of this\nsnapshot image:", font=self._args.font_small) | |
tslbl.grid(row=0, column=0, sticky=E) | |
frt2tc = tkinter.Frame(frt2) | |
self._tstxt1 = tkinter.Label( | |
frt2tc, | |
font=self._args.font_main) | |
self._tstxt1.pack(side="left") | |
frt2tc.grid(row=0, column=1, sticky=W) | |
self._tstxt2 = tkinter.Label( | |
frt2tc, | |
font=self._args.font_small) | |
self._tstxt2.pack(side="left") | |
tkinter.Label(frt2tc, text=" "*3).pack(side="left") | |
frt2tc.grid(row=0, column=1, sticky=W) | |
self._updtstxt() | |
# | |
# tracking user's last preview operation | |
lstplb1 = tkinter.Label( | |
frt2, text="last\npreviewed:", font=self._args.font_small) | |
lstplb1.grid(row=0, column=2, sticky=E) | |
frt2pc = tkinter.Frame(frt2) | |
frt2pc.grid(row=0, column=3, sticky=W) | |
self._lastpreviewed_ss = tkinter.StringVar() | |
lstplbss = tkinter.Entry( | |
frt2pc, textvariable=self._lastpreviewed_ss, | |
width=15, state="readonly", justify="center", takefocus=0, | |
font=self._args.font_main) | |
lstplbss.pack(side="left") | |
lstplb2 = tkinter.Label( | |
frt2pc, text="->", font=self._args.font_small) | |
lstplb2.pack(side="left", padx=2) | |
self._lastpreviewed_to = tkinter.StringVar() | |
lstplbto = tkinter.Entry( | |
frt2pc, textvariable=self._lastpreviewed_to, | |
width=15, state="readonly", justify="center", takefocus=0, | |
font=self._args.font_main) | |
lstplbto.pack(side="left") | |
tkinter.Label(frt2pc, text=" "*3).pack(side="left") | |
# | |
frb = tkinter.Frame(master) | |
frb.pack() | |
fri = tkinter.Frame(frb) | |
# | |
self._navlbtnlbl = tkinter.StringVar() | |
self._navrbtnlbl = tkinter.StringVar() | |
self._navlbtnlbl.set("{:+.3f}".format(-self._step)) | |
self._navrbtnlbl.set("{:+.3f}".format(self._step)) | |
btp = tkinter.Button( | |
fri, textvariable=self._navlbtnlbl, | |
command=functools.partial(self._nav, -1, 1), | |
font=self._args.font_main) | |
btp.grid(row=0, column=0, sticky=N + S) | |
self._im = tkinter.Label(fri, image=self._img, relief="ridge") | |
self._im.grid(row=0, column=1) | |
btn = tkinter.Button( | |
fri, textvariable=self._navrbtnlbl, | |
command=functools.partial(self._nav, 1, 1), | |
font=self._args.font_main) | |
btn.grid(row=0, column=2, sticky=N + S) | |
fri.grid(row=2, column=0) | |
# | |
self._btprv = tkinter.Button( | |
fri, text="preview", | |
command=self._do_previewpopup, | |
font=self._args.font_main) | |
self._btprv.grid(row=0, column=3, sticky=N + S) | |
# | |
self._cmb_onappliedact = ttk.Combobox( | |
master, width=70, state="readonly", | |
font=self._args.font_main) | |
self._cmb_onappliedact["values"] = [ | |
'update "-ss" with this timestamp', | |
'update "-to" with this timestamp', | |
'update "-ss" with this previewed "-ss"', | |
'update "-to" with this previewed "-to"', | |
'update both "-ss" and "-to" with this previewed "-ss" and "-to"', | |
'update "-ss" with this previewed "-to"', | |
'update "-to" with this previewed "-ss"', | |
] | |
self._cmb_onappliedact.current(0 if self._tickpos < 10 else 1) | |
self._cmb_onappliedact.pack(fill=tkinter.X) | |
# | |
def _upd(*args): | |
threading.Thread(target=self._nav, args=(0, 1)).start() | |
for w in (btp, btn, self._btprv, self._cmb_onappliedact): | |
w.bind("<Left>", functools.partial(self._nav, -1, 1)) | |
w.bind("<less>", functools.partial(self._nav, -1, 1)) | |
w.bind("<greater>", functools.partial(self._nav, 1, 1)) | |
w.bind("<Right>", functools.partial(self._nav, 1, 1)) | |
# | |
w.bind("<Control-Left>", functools.partial(self._nav, -1, 2)) | |
w.bind("<Control-less>", functools.partial(self._nav, -1, 2)) | |
w.bind("<Control-greater>", functools.partial(self._nav, 1, 2)) | |
w.bind("<Control-Right>", functools.partial(self._nav, 1, 2)) | |
# | |
w.bind("<Shift-Left>", functools.partial(self._nav, -1, 1./2)) | |
w.bind("<Shift-less>", functools.partial(self._nav, -1, 1./2)) | |
w.bind("<Shift-greater>", functools.partial(self._nav, 1, 1./2)) | |
w.bind("<Shift-Right>", functools.partial(self._nav, 1, 1./2)) | |
# | |
for desc, ksym, cmd in self._inherit_commands: | |
w.bind(ksym, lambda *args: (cmd(args), _upd(args))) | |
w.bind("<Alt-p>", self._do_previewpopup) | |
w.bind("<Button-3>", self._do_previewpopup) | |
w.bind("<App>", self._do_previewpopup) | |
w.bind("<Alt-s>", lambda *args: rbss.focus_set()) | |
w.bind("<Alt-t>", lambda *args: rbto.focus_set()) | |
# | |
return btp | |
def apply(self): | |
updmodesel = self._cmb_onappliedact.get() | |
updmode = self._cmb_onappliedact["values"].index(updmodesel) | |
prss = self._lastpreviewed_ss.get() | |
prto = self._lastpreviewed_to.get() | |
if prss: | |
prss, prto = parse_time(prss), parse_time(prto) | |
elif updmode >= 2: | |
return | |
self._on_apply(updmode, self._t, prss, prto) | |
# | |
def _thumbpop(tick, div, *args): # tick=0, 1, ..div | |
im, sst = None, 0 | |
(ss, to), _ = self._get_cur_ssto() | |
step = (to - ss) / div | |
if tick == 0: | |
im, sst = self._thn_ss_img_l, ss | |
elif tick == div: | |
im, sst = self._thn_to_img_l, to | |
else: | |
sst = ss + step * tick | |
_, im = _create_thumbnailimage( | |
self._args, sst, ressize=self._args.guimain_thumbnail_size) | |
def _on_apply(ss_or_to, t, prss, prto): | |
if ss_or_to == 0: | |
self._ent_ss_var.set(_ts_to_tss(t)) | |
self._ss_changed() | |
elif ss_or_to == 1: | |
self._ent_to_var.set(_ts_to_tss(t)) | |
self._to_changed() | |
elif ss_or_to == 2: | |
self._ent_ss_var.set(_ts_to_tss(prss)) | |
self._ss_changed() | |
elif ss_or_to == 3: | |
self._ent_to_var.set(_ts_to_tss(prto)) | |
self._to_changed() | |
elif ss_or_to == 4: | |
self._ent_ss_var.set(_ts_to_tss(prss)) | |
self._ss_changed() | |
self._ent_to_var.set(_ts_to_tss(prto)) | |
self._to_changed() | |
elif ss_or_to == 5: | |
self._ent_ss_var.set(_ts_to_tss(prto)) | |
self._ss_changed() | |
elif ss_or_to == 6: | |
self._ent_to_var.set(_ts_to_tss(prss)) | |
self._to_changed() | |
_ImgShowPopup( | |
self._root, self._args, (ss, to), | |
im, sst, step, tick, _on_apply, | |
lambda ss_, t_: self._execute_viewer_0(ss_, t_), | |
[("input has no video stream (C-n)", "<Control-n>", _toggle_vn)]) | |
# | |
def _do_thumbpop_selectionmenu(inidiv, *args): | |
ev = args[0] | |
menu = tkinter.Menu(self._root, tearoff=0, font=self._args.font_main) | |
# | |
if not hasattr(self, "_lastselpreview"): | |
self._lastselpreview = (None, None) | |
def _menu_previewcmd(ss, t, *args): | |
self._lastselpreview = (ss, t) | |
cmd = CmdwrapperForDynmenu( | |
menu, functools.partial(self._execute_viewer_0, ss, t)) | |
cmd(*args) | |
# | |
minstep = 1/60. | |
(ss, to), _ = self._get_cur_ssto() | |
div = inidiv | |
while (to - ss) / div < minstep and 2 < div: | |
div = int(div / 2) | |
if (to - ss) / div < minstep: | |
ticks = [0] | |
else: | |
ticks = list(range(-5, (inidiv + 5), int(inidiv / div))) | |
for i in ticks: | |
submenu = tkinter.Menu(menu, tearoff=0, font=self._args.font_main) | |
if i == 0: | |
menu.add_separator() | |
menu.add_cascade(label="@{:2d}/{:2d}".format(i, div), menu=submenu) | |
if i == div: | |
menu.add_separator() | |
submenu.add_command( | |
label="snapshot image", | |
command=CmdwrapperForDynmenu( | |
menu, functools.partial(_thumbpop, i, div))) | |
# | |
subsubmenu = tkinter.Menu(submenu, tearoff=0, font=self._args.font_main) | |
submenu.add_cascade(label="mini preview", menu=subsubmenu) | |
step = (to - ss) / div | |
mp_ss = ss + i * step | |
for j in range(i + 1, len(ticks) + 1, 1): | |
dur = step * (j - i) | |
if j == 0: | |
subsubmenu.add_separator() | |
fg = "red" if str(self._lastselpreview) == str((mp_ss, dur)) else "black" | |
subsubmenu.add_command( | |
label="~ @{:2d}/{:2d} [{:.3f} secs]".format(j, div, dur), | |
foreground=fg, | |
command=functools.partial(_menu_previewcmd, mp_ss, dur)) | |
if j == div: | |
subsubmenu.add_separator() | |
subsubmenu.add_separator() | |
subsubsubmenus = [ | |
tkinter.Menu(subsubmenu, tearoff=0, font=self._args.font_main), | |
tkinter.Menu(subsubmenu, tearoff=0, font=self._args.font_main), | |
tkinter.Menu(subsubmenu, tearoff=0, font=self._args.font_main), | |
] | |
subsubmenu.add_cascade(label="- > 0", menu=subsubsubmenus[0]) | |
subsubmenu.add_cascade(label="- > +", menu=subsubsubmenus[1]) | |
subsubmenu.add_cascade(label="0 > +", menu=subsubsubmenus[2]) | |
for f in (1, 2, 5, 10, 15, 20, 25, 30, 37.5, 45, 60, 75): | |
s = 1/30.*f | |
for sssmi, (d1, d2) in enumerate(( | |
(-s, 0), | |
(-s, s), | |
(0, s), | |
)): | |
ss_, t_ = mp_ss + d1, (d2 - d1) | |
fg = "red" if str(self._lastselpreview) == str((ss_, t_)) else "black" | |
subsubsubmenus[sssmi].add_command( | |
label="{:+.3f} ~ {:+.3f}".format(d1, d2), | |
foreground=fg, | |
command=functools.partial(_menu_previewcmd, ss_, t_)) | |
try: | |
menu.tk_popup(ev.x_root + 20, ev.y_root) | |
finally: | |
menu.grab_release() | |
# | |
self._thn_ss.bind("<Button-1>", functools.partial(_thumbpop, 0, 10)) | |
self._thn_mp.bind("<Button-1>", functools.partial(_thumbpop, 5, 10)) | |
self._thn_to.bind("<Button-1>", functools.partial(_thumbpop, 10, 10)) | |
self._root.bind("<Control-0>", functools.partial(_do_thumbpop_selectionmenu, 10)) | |
self._root.bind("<Control-colon>", functools.partial(_do_thumbpop_selectionmenu, 10)) | |
self._root.bind("<Control-asterisk>", functools.partial(_do_thumbpop_selectionmenu, 20)) | |
self._root.bind("<Control-semicolon>", functools.partial(_do_thumbpop_selectionmenu, 30)) | |
self._root.bind("<Control-plus>", functools.partial(_do_thumbpop_selectionmenu, 40)) | |
self._thn_ss.bind("<Button-3>", functools.partial(_do_thumbpop_selectionmenu, 10)) | |
self._thn_mp.bind("<Button-3>", functools.partial(_do_thumbpop_selectionmenu, 10)) | |
self._thn_to.bind("<Button-3>", functools.partial(_do_thumbpop_selectionmenu, 10)) | |
self._thn_ss.bind("<Shift-Button-3>", functools.partial(_do_thumbpop_selectionmenu, 20)) | |
self._thn_mp.bind("<Shift-Button-3>", functools.partial(_do_thumbpop_selectionmenu, 20)) | |
self._thn_to.bind("<Shift-Button-3>", functools.partial(_do_thumbpop_selectionmenu, 20)) | |
# --- | |
self._lbselchanged() | |
def _get_cur_ss(self): | |
ss, ss_str = None, None | |
try: | |
ss_str = self._ent_ss_var.get().strip() | |
ss = parse_time(ss_str) | |
except ValueError: | |
pass | |
return ss, ss_str | |
def _get_cur_to(self): | |
to, to_str = None, None | |
try: | |
to_str = self._ent_to_var.get().strip() | |
to = parse_time(to_str) | |
except ValueError: | |
pass | |
return to, to_str | |
def _get_cur_ssto(self): | |
try: | |
ss_str = self._ent_ss_var.get().strip() | |
ss = parse_time(ss_str) | |
to_str = self._ent_to_var.get().strip() | |
to = parse_time(to_str) | |
return (ss, to), (ss_str, to_str) | |
except ValueError: | |
pass | |
def _ss_changed(self, *args): | |
cur = self._get_cur_ssto() | |
if not cur: | |
return | |
(ss, to), (ss_str, to_str) = cur | |
self._statusbar.variable.set( | |
'"{}" -> "{}" ("{}" sec)'.format( | |
ss_str, to_str, _ts_to_tss(to - ss))) | |
self._updatethumb_ss(ss) | |
def _to_changed(self, *args): | |
cur = self._get_cur_ssto() | |
if not cur: | |
return | |
(ss, to), (ss_str, to_str) = cur | |
self._statusbar.variable.set( | |
'"{}" -> "{}" ("{}" sec)'.format( | |
ss_str, to_str, _ts_to_tss(to - ss))) | |
self._updatethumb_to(to) | |
def _updatethumb_ss(self, t): | |
self._thn_ss_img, self._thn_ss_img_l = _create_thumbnailimage( | |
self._args, t, ressize=self._args.guimain_thumbnail_size) | |
self._thn_ss.config(image=self._thn_ss_img) | |
# | |
to, _ = self._get_cur_to() | |
mp = t + (to - t) / 2.0 | |
self._lbl_mp_var.set(_ts_to_tss(mp)) | |
self._thn_mp_img, self._thn_mp_img_l = _create_thumbnailimage( | |
self._args, mp, ressize=self._args.guimain_thumbnail_size_small) | |
self._thn_mp.config(image=self._thn_mp_img) | |
def _updatethumb_to(self, t): | |
self._thn_to_img, self._thn_to_img_l = _create_thumbnailimage( | |
self._args, t, ressize=self._args.guimain_thumbnail_size) | |
self._thn_to.config(image=self._thn_to_img) | |
# | |
ss, _ = self._get_cur_ss() | |
mp = ss + (t - ss) / 2.0 | |
self._lbl_mp_var.set(_ts_to_tss(mp)) | |
self._thn_mp_img, self._thn_mp_img_l = _create_thumbnailimage( | |
self._args, mp, ressize=self._args.guimain_thumbnail_size_small) | |
self._thn_mp.config(image=self._thn_mp_img) | |
def _lbselchanged(self, *args): | |
cs = self._lb.curselection() | |
if not cs: | |
return | |
ci = cs[0] | |
item = eval(self._lbitemsvar.get())[ci] | |
(_ss, _to), (_ss_str, _to_str) = self._lbitem2sst(item) | |
self._ent_ss_var.set(_ss_str) | |
self._ent_to_var.set(_to_str) | |
self._lbl_mp_var.set(_ts_to_tss(_ss + (_to - _ss) / 2.0)) | |
if self._thmupd_invo: | |
self._root.after_cancel(self._thmupd_invo) | |
self._thmupd_invo = self._root.after(500, self._updatethumb_cb) | |
def _updatethumb_cb(self, *args, **kwargs): | |
self._ss_changed() | |
self._to_changed() | |
def _build_newranges(self, *args): | |
try: | |
_ss = parse_time(self._ent_rass_var.get().strip()) | |
_to = parse_time(self._ent_rato_var.get().strip()) | |
_t = float(self._ent_rasp_var.get().strip()) | |
return _yr_g(_ss, _to, _t) | |
except ValueError as exc: | |
_log.warning(exc) | |
def _updatelist_and_reselect_new(self, newlist, oldv=None): | |
def _in_range(t, s, e): | |
return (s <= t and t <= e) | |
newlist.sort() | |
newsel = 0 | |
if oldv: | |
newsel = newlist.index(oldv) | |
elif self._lbitems: | |
selidxes = list(self._lb.curselection()) | |
if selidxes: | |
oldsel = selidxes[0] | |
else: | |
oldsel = 0 | |
olds, olde = tuple(map(parse_time, re.split(r"\s*->\s*", self._lbitems[oldsel]))) | |
for i, item in enumerate(newlist): | |
s, e = tuple(map(parse_time, re.split(r"\s*->\s*", item))) | |
if _in_range(s, olds, olde) or _in_range(e, olds, olde): | |
newsel = i | |
break | |
self._lbitems = newlist | |
self._lbitemsvar.set(self._lbitems) | |
for i in self._lb.curselection(): | |
self._lb.selection_clear(i) | |
self._lb.selection_set(newsel) | |
self._lbselchanged() | |
def _refresh_rangeslist(self, *args): | |
nl = self._build_newranges() | |
if not nl: | |
return | |
self._updatelist_and_reselect_new([ | |
self._sst2lbitem(_ss, _t) for _ss, _t in nl]) | |
def _extend_rangeslist(self, *args): | |
nl = self._build_newranges() | |
if not nl: | |
return | |
newlist = list(self._lbitems) | |
newlist.extend([ | |
self._sst2lbitem(_ss, _t) for _ss, _t in nl]) | |
self._updatelist_and_reselect_new(newlist) | |
def _append_this_range(self, *args): | |
v = self._getlbitem_from_sstoent() | |
if v and v not in self._lbitems: | |
newlist = list(self._lbitems) | |
newlist.append(v) | |
self._updatelist_and_reselect_new(newlist, v) | |
def _update_with_this_range(self, *args): | |
selidxes = list(self._lb.curselection()) | |
if len(selidxes) != 1: | |
return | |
sel = selidxes[0] | |
v = self._getlbitem_from_sstoent() | |
if v: | |
newlist = list(self._lbitems) | |
newlist[sel] = v | |
self._updatelist_and_reselect_new(newlist, v) | |
def _delete_selected_ranges(self, *args): | |
selidxes = list(self._lb.curselection()) | |
newlist = [ | |
item for i, item in enumerate(self._lbitems) | |
if i not in selidxes] | |
self._updatelist_and_reselect_new(newlist) | |
def _concat_selected_ranges(self, *args): | |
selidxes = list(self._lb.curselection()) | |
newlist = [ | |
item for i, item in enumerate(self._lbitems) | |
if i not in selidxes] # unmodified | |
minss, maxto = float("inf"), -float("inf") | |
for item in [item for i, item in enumerate(self._lbitems) if i in selidxes]: | |
ss, to = tuple(map(parse_time, re.split(r"\s*->\s*", item))) | |
minss, maxto = min(ss, minss), max(to, maxto) | |
newlist.append(self._sst2lbitem(minss, maxto - minss)) | |
self._updatelist_and_reselect_new(newlist) | |
def _getlbitem_from_sstoent(self): | |
try: | |
_ss_str = self._ent_ss_var.get().strip() | |
_ss = parse_time(_ss_str) | |
_to_str = self._ent_to_var.get().strip() | |
_to = parse_time(_to_str) | |
return self._sst2lbitem(_ss, _to - _ss) | |
except ValueError as exc: | |
_log.warning(exc) | |
def _sst2lbitem(self, ss, t): | |
return "{} -> {}".format( | |
_ts_to_tss(ss), _ts_to_tss(ss + t)) | |
def _lbitem2sst(self, item): | |
_ss_str, _to_str = map(lambda s: s.strip(), item.split("->")) | |
_ss = parse_time(_ss_str.strip()) | |
_t = parse_time(_to_str.strip()) - _ss | |
return (_ss, _t), (_ss_str, _to_str) | |
def _execute_viewer(self, *args): | |
cur = self._get_cur_ssto() | |
if not cur: | |
return | |
(_ss, _to), (_ss_str, _to_str) = cur | |
_t = _to - _ss | |
self._execute_viewer_0(_ss, _t) | |
def _execute_viewer_0(self, ss, t, *args): | |
if (ss, t) in self._last_previewed_hist: | |
self._last_previewed_hist.remove((ss, t)) | |
self._last_previewed_hist.append((ss, t)) | |
while len(self._last_previewed_hist) > 5: | |
self._last_previewed_hist.pop(0) | |
if self._args.player == 1: | |
player_command = self._args.player_command | |
else: | |
player_command = self._args.player2_command | |
self._statusbar.variable.set( | |
'"{}" -> "{}"'.format( | |
_ts_to_tss(ss), _ts_to_tss(ss + t))) # normally, you cannot see this. | |
t = threading.Thread(target=self._execute_viewer_run, args=(player_command, ss, t)) | |
t.start() | |
def _execute_viewer_run(self, player_command, ss, t): | |
scale = self._args.scale | |
volume = self._args.volume | |
_execute_preview_one( | |
self._args, player_command, ss, t, scale, volume, | |
lambda msg: self._statusbar.variable.set(msg)) | |
def _execute_render(self, *args): | |
cur = self._get_cur_ssto() | |
if not cur: | |
return | |
(_ss, _to), (_ss_str, _to_str) = cur | |
_t = _to - _ss | |
sr = [] | |
for sel in self._lb.curselection(): | |
s, e = re.split(r"\s*->\s*", self._lbitems[sel]) | |
s = parse_time(s) | |
e = parse_time(e) | |
sr.append((s, e)) | |
# | |
r = _Renderer( | |
self._args, | |
self._args.logview_initial_template) | |
result, error = r.render(ss=_ss, to=_to, selected_ranges=sr) | |
txt = result if not error else "{}\n".format(error) | |
self._entres.insert("end", txt) | |
class _Renderer(object): | |
def __init__(self, args, bodytempl=""): | |
self._args = args | |
self._bodytempl = bodytempl | |
self._tmpl = None | |
def render(self, ss, to, selected_ranges): | |
import random | |
result, error = "", "" | |
try: | |
if not self._tmpl: | |
self._tmpl = Template(self._bodytempl) | |
result = self._tmpl.render( | |
# data | |
args=self._args, | |
ss=ss, | |
to=to, | |
ranges=selected_ranges, | |
# modules | |
os=os, | |
sys=sys, | |
pipes=pipes, | |
math=math, | |
random=random, | |
json=json, | |
# local utilities | |
parse_time=parse_time, | |
to_tss=_ts_to_tss, | |
fmtsec=lambda d: ("%.3f" % d)) | |
except Exception as exc: | |
error = "Your template is broken: {}: {}".format( | |
str(type(exc)).split("'")[1], | |
str(exc)) | |
return result, error | |
if __name__ == '__main__': | |
import argparse | |
import atexit | |
try: | |
from configparser import ConfigParser # Python 3.x | |
except ImportError: | |
from ConfigParser import ConfigParser # Python 2.x | |
_LOGFMT = "%(asctime)s:%(levelname)5s:%(module)s(%(lineno)d):%(thread)x| %(message)s" | |
logging.basicConfig( | |
level=logging.INFO, | |
stream=sys.stderr, | |
format=_LOGFMT) | |
# | |
# search conf order: user's home -> current dir | |
# | |
# .ffchopreview.conf example: | |
# --------------------------------------------------------------------- | |
# [env] | |
# ffmpeg="c:/Program Files/ffmpeg-4.2.1-win64-shared/bin/ffmpeg" | |
# player_command=["ffplay", "-hide_banner"] | |
# player2_command=["c:/Program Files/MPC-HC/mpc-hc64.exe"] | |
# | |
# [app_defaults] | |
# ; if you want to use player2, set 2 to "player" else 1 | |
# player=1 | |
# volume=0.4 | |
# ;scale=960:540 | |
# audio_visualize_if_no_input_vstream=showcqt=size=1280x720 | |
# ;audio_visualize_if_no_input_vstream=showfreqs=size=1280x720 | |
# ;audio_visualize_if_no_input_vstream=showwaves=size=1280x720 | |
# ;audio_visualize_if_no_input_vstream=volume=3.0,showcqt=size=1920x1080 | |
# ;audio_visualize_if_no_input_vstream=volume=2.5,showwaves=size=960x540:split_channels=1 | |
# ;audio_visualize_if_no_input_vstream=ahistogram=size=1280x720:dmode=separate | |
# audio_visualize_duration_if_no_input_vstream=3 | |
# verbose=ffmpeg_stderr | |
# | |
# [gui] | |
# font_main=["courier", 12, "normal"] | |
# font_small=["courier", 10, "normal"] | |
# thumbnail_size_large=[1280, 720] | |
# thumbnail_size=[540, 270] | |
# thumbnail_size_small=[320, 180] | |
confname = ".{}.conf".format(__MYNAME__) | |
cp = ConfigParser() | |
cp.read([ | |
os.path.join(d, confname) | |
for d in (os.environ.get("HOME", os.environ.get("USERPROFILE")), ".") | |
if d and os.path.exists(os.path.join(d, confname))]) | |
defaults = dict( | |
ffmpeg=cp.get("env", "ffmpeg", fallback="ffmpeg"), | |
player=cp.getint("app_defaults", "player", fallback=1), | |
player_command=cp.get("env", "player_command", fallback='["ffplay", "-hide_banner"]'), | |
player2_command=cp.get("env", "player2_command", fallback='["vlc"]'), | |
volume=cp.getfloat("app_defaults", "volume", fallback=0.4), | |
scale=cp.get("app_defaults", "scale", fallback="640:-1"), | |
audio_visualize_if_no_input_vstream=cp.get( | |
"app_defaults", | |
"audio_visualize_if_no_input_vstream", | |
fallback="showcqt=size=1280x720"), | |
audio_visualize_duration_if_no_input_vstream=cp.getint( | |
"app_defaults", | |
"audio_visualize_duration_if_no_input_vstream", | |
fallback=3), | |
logview_initial_template=cp.get( | |
"app_defaults", | |
"logview_initial_template", | |
fallback=os.path.join( | |
os.environ.get("HOME", os.environ.get("USERPROFILE")), | |
".{}.tmpl".format(__MYNAME__))), | |
logview_template_dir=cp.get( | |
"app_defaults", | |
"logview_template_dir", | |
fallback=os.path.join( | |
os.environ.get("HOME", os.environ.get("USERPROFILE")), | |
".{}.d".format(__MYNAME__), "templates")), | |
verbose=cp.get("app_defaults", "verbose", fallback=""), | |
font_main=cp.get("gui", "font_main", fallback='["courier", 12, "normal"]'), | |
font_small=cp.get("gui", "font_small", fallback='["courier", 10, "normal"]'), | |
guimain_thumbnail_size=cp.get("gui", "thumbnail_size", fallback='[540, 270]'), | |
guimain_thumbnail_size_small=cp.get("gui", "thumbnail_size_small", fallback='[320, 180]'), | |
guimain_thumbnail_size_large=cp.get("gui", "thumbnail_size_large", fallback='[960, 540]'), | |
) | |
ap = argparse.ArgumentParser() | |
ap.add_argument("video", nargs="?") | |
ap.add_argument("--no_tk", action="store_true") | |
ap.add_argument("--guimain_exec_on_init", action="store_true") | |
ap.add_argument("--from_splitpoints") # as json's list | |
ap.add_argument("--from_slice") # [start, end, step] | |
ap.add_argument("--ffmpeg", default=defaults["ffmpeg"]) | |
ap.add_argument("--player", default=defaults["player"], choices=[1, 2], type=int) | |
ap.add_argument("--player_command", default=defaults["player_command"]) | |
ap.add_argument("--player2_command", default=defaults["player2_command"]) | |
ap.add_argument("--volume", type=float, default=defaults["volume"]) | |
ap.add_argument("--scale", default=defaults["scale"]) | |
ap.add_argument("--no_input_vstream", action="store_true") | |
ap.add_argument("--no_input_astream", action="store_true") | |
ap.add_argument("--audio_visualize_if_no_input_vstream", | |
default=defaults["audio_visualize_if_no_input_vstream"]) | |
ap.add_argument("--audio_visualize_duration_if_no_input_vstream", | |
default=defaults["audio_visualize_duration_if_no_input_vstream"], | |
type=int) | |
ap.add_argument("--logview_initial_template", | |
default=defaults["logview_initial_template"]) | |
ap.add_argument("--logview_template_dir", | |
default=defaults["logview_template_dir"]) | |
ap.add_argument("--no_drawpts", action="store_true", | |
help="if you use ffmpeg 3.x, you will have to use this option.") | |
ap.add_argument("--guimain_thumbnail_size", default=defaults["guimain_thumbnail_size"]) | |
ap.add_argument("--guimain_thumbnail_size_large", default=defaults["guimain_thumbnail_size_large"]) | |
ap.add_argument("--guimain_thumbnail_size_small", default=defaults["guimain_thumbnail_size_small"]) | |
# ffmpeg_stdout, player_stdout, ffmpeg_stderr, player_stderr | |
ap.add_argument("--verbose", default=defaults["verbose"]) | |
args = ap.parse_args() | |
setattr(args, "font_main", tuple(json.loads(defaults["font_main"]))) | |
setattr(args, "font_small", tuple(json.loads(defaults["font_small"]))) | |
args.guimain_thumbnail_size = tuple(json.loads(args.guimain_thumbnail_size)) | |
args.guimain_thumbnail_size_large = tuple(json.loads(args.guimain_thumbnail_size_large)) | |
args.guimain_thumbnail_size_small = tuple(json.loads(args.guimain_thumbnail_size_small)) | |
if args.logview_initial_template and os.path.exists(args.logview_initial_template): | |
tn = args.logview_initial_template | |
tc = io.open(tn, encoding="utf-8").read().strip() | |
args.logview_initial_template = tc | |
else: | |
args.logview_initial_template = """\ | |
ffmpeg -y -ss "${to_tss(ss)}" -i ${pipes.quote(args.video)} -ss 0 -t ${to - ss} out.mkv""" | |
### other example: | |
### -------------------------------------- | |
### % for ss, to in ranges: | |
### ["${to_tss(ss)}", "${to_tss(to)}", [1, "", "volume=0.0025"]], | |
### % endfor | |
### -------------------------------------- | |
# | |
tkroot = None | |
if not args.no_tk: | |
tkroot = tkinter.Tk() | |
while not args.video or not os.path.exists(args.video): | |
r = _my_askopenfilename(tkroot) | |
if r: | |
args.video = r | |
else: | |
# cancel? | |
sys.exit(0) | |
# | |
if args.no_tk: | |
_update_argmediainfo(args) | |
for _ss, _t in _yr(args): | |
_execute_preview_one( | |
args, _ss, _t, args.scale, args.volume) | |
else: | |
lb = _FCPMainGui(tkroot, args, list(_yr(args))) | |
tkroot.mainloop() | |
_HISTMNG.save_history() |
example of .ffchopreview.conf (added on Rev44):
[env]
ffmpeg=c:/Program Files/ffmpeg-4.2.1-win64-shared/bin/ffmpeg
;player_command=["c:/Program Files/ffmpeg-4.2.1-win64-shared/bin/ffplay", "-hide_banner"]
player_command=["c:/Program Files/MPC-HC/mpc-hc64.exe"][app_defaults]
volume=0.3
;scale=640:-1
scale=960:540
;verbose=ffmpeg_stderr
new dependancy: pillow
please "pip install pillow"
new dependancy: mako
please "pip install mako"
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
https://youtu.be/zo3f1PQTlgQ