aboutsummaryrefslogtreecommitdiff
path: root/vidslice
diff options
context:
space:
mode:
authorMelody Horn <melody@boringcactus.com>2021-01-29 01:10:56 -0700
committerMelody Horn <melody@boringcactus.com>2021-01-29 01:10:56 -0700
commit326cbf53a2cfa1c115523b9eb4837f629011a1f7 (patch)
tree58531cc0f849d4e146b71130734775b2af0c6041 /vidslice
parentc81da475babdda24000488da62b33048d3239947 (diff)
downloadvidslice-326cbf53a2cfa1c115523b9eb4837f629011a1f7.tar.gz
vidslice-326cbf53a2cfa1c115523b9eb4837f629011a1f7.zip
pull code out into a python module
Diffstat (limited to 'vidslice')
-rw-r--r--vidslice/__init__.py0
-rw-r--r--vidslice/__main__.py123
-rw-r--r--vidslice/options.py313
-rw-r--r--vidslice/output.py162
-rw-r--r--vidslice/preview.py89
-rw-r--r--vidslice/sources.py134
6 files changed, 821 insertions, 0 deletions
diff --git a/vidslice/__init__.py b/vidslice/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/vidslice/__init__.py
diff --git a/vidslice/__main__.py b/vidslice/__main__.py
new file mode 100644
index 0000000..eca036c
--- /dev/null
+++ b/vidslice/__main__.py
@@ -0,0 +1,123 @@
+import json
+import subprocess
+import urllib.request
+from tkinter import *
+from tkinter import messagebox
+from tkinter import ttk
+
+from options import OptionsPanel
+from output import OutputPanel
+from preview import PreviewPanel
+from sources import SourcesPanel, update_ytdl
+
+VERSION = "1.6"
+
+
+def check_update(parent):
+ latest_release_api_url = 'https://api.github.com/repos/boringcactus/vidslice/releases/latest'
+ with urllib.request.urlopen(latest_release_api_url) as latest_release_response:
+ latest_release_obj = json.load(latest_release_response)
+ newest_version = latest_release_obj['tag_name'].lstrip('v')
+ if VERSION != newest_version:
+ open_update = messagebox.askyesno(message="vidslice update available. download?", title="Update",
+ parent=parent)
+ if open_update:
+ import webbrowser
+
+ webbrowser.open("https://github.com/boringcactus/vidslice/releases/latest")
+
+
+def has_ffmpeg():
+ try:
+ subprocess.run(["ffmpeg", "-version"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
+ return True
+ except FileNotFoundError:
+ return False
+
+
+class VidsliceFrame:
+ def __init__(self, root: Tk):
+ self.root = root
+ root.title('vidslice')
+
+ mainframe = ttk.Frame(root, padding="3 3 12 12")
+ mainframe.grid(column=0, row=0, sticky=(N, W, E, S))
+ root.columnconfigure(0, weight=1)
+ root.rowconfigure(0, weight=1)
+
+ self.sources_panel = SourcesPanel(mainframe)
+ self.sources_panel.grid(column=0, row=0, columnspan=2, sticky=(W, E, N, S), padx=5, pady=5)
+
+ self.options_panel = OptionsPanel(mainframe)
+ self.options_panel.grid(column=0, row=1, columnspan=2, sticky=(W, N, S), padx=5, pady=5)
+ self.sources_panel.on_update(self.options_panel.update_info)
+
+ self.preview_panel = PreviewPanel(mainframe, get_ffmpeg_args=self.options_panel.ffmpeg_opts,
+ get_frame_count=self.options_panel.frame_count)
+ self.preview_panel.grid(column=0, row=2, sticky=(W, E, N, S), padx=5, pady=5)
+ mainframe.rowconfigure(2, weight=1)
+ mainframe.columnconfigure(0, weight=2)
+ self.sources_panel.on_update(
+ lambda data: self.preview_panel.set_input_path(self.sources_panel.get_file(), data))
+
+ self.output_panel = OutputPanel(mainframe, get_ffmpeg_args=self.options_panel.ffmpeg_opts,
+ get_frame_count=self.options_panel.frame_count)
+ self.output_panel.grid(column=1, row=2, sticky=(W, E, N, S), padx=5, pady=5)
+ mainframe.columnconfigure(1, weight=1)
+ self.sources_panel.on_update(lambda data: self.output_panel.set_input_path(self.sources_panel.get_file(), data))
+
+ # create a menu bar
+ self.make_menu_bar(root)
+
+ if len(sys.argv) > 1:
+ self.sources_panel.file_text.SetValue(sys.argv[1])
+
+ def make_menu_bar(self, root):
+ root.option_add('*tearOff', FALSE)
+
+ menubar = Menu(root)
+ root['menu'] = menubar
+
+ file_menu = Menu(menubar)
+ menubar.add_cascade(menu=file_menu, label='File', underline=0)
+ file_menu.add_command(label="Update youtube-dl", command=self.on_update, underline=0)
+ file_menu.add_separator()
+ file_menu.add_command(label='Exit', command=self.on_exit, underline=1)
+
+ help_menu = Menu(menubar)
+ menubar.add_cascade(menu=help_menu, label='Help', underline=0)
+ help_menu.add_command(label='About', command=self.on_about, underline=0)
+
+ def on_exit(self, *args):
+ self.root.destroy()
+
+ def on_update(self, *args):
+ import threading
+ threading.Thread(target=update_ytdl, args=(self.root,)).start()
+
+ def on_about(self, *args):
+ messagebox.showinfo(message=f"vidslice {VERSION}")
+
+
+def check_ffmpeg(root: Tk):
+ if not has_ffmpeg():
+ open_readme = messagebox.askyesno(message="Could not find ffmpeg. Open vidslice README?", title="Error",
+ icon='error', parent=root)
+ if open_readme:
+ import webbrowser
+
+ webbrowser.open("https://github.com/boringcactus/vidslice/blob/master/README.md")
+ root.after(1000, root.destroy)
+
+
+def main():
+ root = Tk()
+ frame = VidsliceFrame(root)
+ root.after_idle(check_ffmpeg, root)
+ root.after(1000, check_update, root)
+ root.mainloop()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/vidslice/options.py b/vidslice/options.py
new file mode 100644
index 0000000..016fbda
--- /dev/null
+++ b/vidslice/options.py
@@ -0,0 +1,313 @@
+import typing
+from tkinter import *
+from tkinter import ttk
+
+HEADERS_ROW = 0
+START_ROW = 1
+END_ROW = 2
+DURATION_ROW = 3
+WIDTH_ROW = 4
+HEIGHT_ROW = 5
+FRAMERATE_ROW = 6
+CROP_TOP_ROW = 7
+CROP_BOTTOM_ROW = 8
+CROP_LEFT_ROW = 9
+CROP_RIGHT_ROW = 10
+LABEL_COL = 0
+ORIG_COL = 1
+EDIT_BOX_COL = 2
+NEW_COL = 3
+
+
+class FFmpegOptions:
+ def __init__(self, input, output, vf):
+ self.input = input
+ self.output = output
+ self.vf = vf
+
+ def output_with_vf(self):
+ if len(self.vf) > 0:
+ return self.output + ['-vf', ','.join(self.vf)]
+ else:
+ return self.output
+
+
+class Property:
+ def __init__(self, parent, name, row, convert: typing.Callable = int):
+ self.handlers = []
+ ttk.Label(parent, text=name).grid(column=LABEL_COL, row=row, sticky=(E, W))
+ self.orig = StringVar(parent, value="N/A")
+ ttk.Label(parent, textvariable=self.orig).grid(column=ORIG_COL, row=row, sticky=(E, W))
+ self.edit = BooleanVar(parent)
+ self.edit_widget = ttk.Checkbutton(parent, variable=self.edit, command=self.handle_edit)
+ self.edit_widget.grid(column=EDIT_BOX_COL, row=row, sticky=(E, W))
+ self.new = ttk.Spinbox(parent, command=self.handle_change)
+ self.new.state(['disabled'])
+ self.new.grid(column=NEW_COL, row=row, sticky=(E, W))
+ self.convert = convert
+ self.disable()
+
+ def disable(self):
+ self.enable(False)
+
+ def enable(self, enabled=True):
+ if enabled:
+ self.edit_widget.state(['!disabled'])
+ else:
+ self.edit.set(False)
+ self.edit_widget.state(['disabled'])
+ self.new.state(['disabled'])
+
+ def is_enabled(self):
+ return self.edit_widget.instate(['!disabled'])
+
+ def is_edit(self):
+ return self.edit.get()
+
+ def set_orig(self, val):
+ self.orig.set(str(val))
+
+ def get_orig(self):
+ return self.orig.get()
+
+ def set_calc_new(self, val):
+ if not self.is_edit():
+ if self.convert(self.new['from']) > val:
+ self.new.configure(from_=val)
+ if self.convert(self.new['to']) < val:
+ self.new.configure(to=val)
+ self.new.set(val)
+
+ def set_range(self, min, max):
+ self.new.configure(from_=min, to=max)
+
+ def get_final(self):
+ if len(self.new.get()) == 0:
+ return self.orig.get()
+ return self.new.get()
+
+ def handle_edit(self, *args):
+ if self.edit.get():
+ self.new.state(['!disabled'])
+ else:
+ self.new.state(['disabled'])
+ self.handle_change(None)
+
+ def on_change(self, callback):
+ self.handlers.append(callback)
+
+ def handle_change(self, *args):
+ for handler in self.handlers:
+ handler()
+
+
+class OptionsPanel(ttk.LabelFrame):
+ """
+ A Panel displaying ffmpeg options
+ """
+
+ def __init__(self, *args, **kw):
+ super(OptionsPanel, self).__init__(*args, text="Options", **kw)
+
+ def place_header(text, **kwargs):
+ ttk.Label(self, text=text, font='TkHeadingFont', justify='center', anchor='center'
+ ).grid(sticky=(E, W), **kwargs)
+
+ place_header("Field", column=LABEL_COL, row=HEADERS_ROW)
+ place_header("Original Value", column=ORIG_COL, row=HEADERS_ROW)
+ place_header("Edit?", column=EDIT_BOX_COL, row=HEADERS_ROW)
+ place_header("New Value", column=NEW_COL, row=HEADERS_ROW)
+
+ self.start_time = Property(self, "Start time (seconds)", START_ROW, float)
+ self.start_time.on_change(self.enforce_constraints)
+
+ self.end_time = Property(self, "End time (seconds)", END_ROW, float)
+ self.end_time.on_change(self.enforce_constraints)
+
+ self.duration = Property(self, "Duration (seconds)", DURATION_ROW, float)
+ self.duration.on_change(self.enforce_constraints)
+
+ self.width = Property(self, "Width", WIDTH_ROW, int)
+ self.width.on_change(self.enforce_constraints)
+
+ self.height = Property(self, "Height", HEIGHT_ROW, int)
+ self.height.on_change(self.enforce_constraints)
+
+ self.framerate = Property(self, "Framerate", FRAMERATE_ROW, float)
+ self.framerate.on_change(self.enforce_constraints)
+
+ self.crop_top = Property(self, "Crop Top", CROP_TOP_ROW, int)
+ self.crop_top.on_change(self.enforce_constraints)
+
+ self.crop_bottom = Property(self, "Crop Bottom", CROP_BOTTOM_ROW, int)
+ self.crop_bottom.on_change(self.enforce_constraints)
+
+ self.crop_left = Property(self, "Crop Left", CROP_LEFT_ROW, int)
+ self.crop_left.on_change(self.enforce_constraints)
+
+ self.crop_right = Property(self, "Crop Right", CROP_RIGHT_ROW, int)
+ self.crop_right.on_change(self.enforce_constraints)
+
+ for child in self.winfo_children():
+ child.grid_configure(padx=2, pady=2)
+
+ self.state(['disabled'])
+
+ def enforce_constraints(self):
+ self.start_time.enable()
+ self.end_time.enable()
+ self.duration.enable()
+ orig_start = float(self.start_time.get_orig())
+ orig_end = float(self.end_time.get_orig())
+ orig_duration = float(self.duration.get_orig())
+ if self.start_time.is_edit() and self.end_time.is_edit():
+ new_start = float(self.start_time.get_final())
+ new_end = float(self.end_time.get_final())
+ new_duration = new_end - new_start
+ self.start_time.set_range(orig_start, new_end)
+ self.end_time.set_range(new_start, orig_end)
+ self.duration.disable()
+ self.duration.set_calc_new(new_duration)
+ elif self.start_time.is_edit() and self.duration.is_edit():
+ new_start = float(self.start_time.get_final())
+ new_duration = float(self.duration.get_final())
+ new_end = new_start + new_duration
+ self.start_time.set_range(orig_start, orig_end - new_duration)
+ self.duration.set_range(0, orig_end - new_start)
+ self.end_time.disable()
+ self.end_time.set_calc_new(new_end)
+ elif self.end_time.is_edit() and self.duration.is_edit():
+ new_end = float(self.end_time.get_final())
+ new_duration = float(self.duration.get_final())
+ new_start = new_end - new_duration
+ self.end_time.set_range(orig_start + new_duration, orig_end)
+ self.duration.set_range(0, new_end - orig_start)
+ self.start_time.disable()
+ self.start_time.set_calc_new(new_start)
+ else:
+ new_start = float(self.start_time.get_final())
+ new_end = float(self.end_time.get_final())
+ new_duration = new_end - new_start
+ if self.duration.is_edit():
+ new_duration = float(self.duration.get_final())
+ new_end = new_start + new_duration
+ self.start_time.set_range(orig_start, orig_end)
+ self.end_time.set_range(orig_start, orig_end)
+ self.duration.set_range(0, orig_duration)
+ self.start_time.set_calc_new(new_start)
+ self.end_time.set_calc_new(new_end)
+ self.duration.set_calc_new(new_duration)
+
+ if self.width.is_enabled() and self.height.is_enabled():
+ orig_width = int(self.width.get_orig())
+ orig_height = int(self.height.get_orig())
+ new_width = int(self.width.get_final())
+ new_height = int(self.height.get_final())
+ self.width.set_range(1, 10 * orig_width)
+ self.height.set_range(1, 10 * orig_height)
+
+ self.width.set_calc_new(round(orig_width / orig_height * new_height))
+ self.height.set_calc_new(round(orig_height / orig_width * new_width))
+
+ self.crop_top.set_calc_new(0)
+ self.crop_top.set_range(0, int(self.height.get_final()) - int(self.crop_bottom.get_final()))
+ self.crop_bottom.set_calc_new(0)
+ self.crop_bottom.set_range(0, int(self.height.get_final()) - int(self.crop_top.get_final()))
+
+ self.crop_right.set_calc_new(0)
+ self.crop_right.set_range(0, int(self.width.get_final()) - int(self.crop_left.get_final()))
+ self.crop_left.set_calc_new(0)
+ self.crop_left.set_range(0, int(self.width.get_final()) - int(self.crop_right.get_final()))
+
+ if self.framerate.is_enabled():
+ orig_framerate = float(self.framerate.get_orig())
+ self.framerate.set_range(0, orig_framerate)
+ self.framerate.set_calc_new(orig_framerate)
+
+ def update_info(self, info):
+ import fractions
+
+ if info is None:
+ self.state(['disabled'])
+ for prop in [self.start_time, self.duration, self.end_time, self.width, self.height, self.framerate]:
+ prop.disable()
+ else:
+ start_time = float(info['format']['start_time'])
+ self.start_time.set_orig(start_time)
+
+ duration = float(info['format']['duration'])
+ self.duration.set_orig(duration)
+
+ self.end_time.set_orig(start_time + duration)
+
+ video_streams = [stream for stream in info['streams'] if
+ stream['codec_type'] == 'video' and stream['avg_frame_rate'] != '0/0']
+ if len(video_streams) > 0:
+ video_stream = video_streams[0]
+ self.width.enable()
+ self.width.set_orig(video_stream['width'])
+ self.height.enable()
+ self.height.set_orig(video_stream['height'])
+ self.crop_top.enable()
+ self.crop_top.set_orig(0)
+ self.crop_bottom.enable()
+ self.crop_bottom.set_orig(0)
+ self.crop_left.enable()
+ self.crop_left.set_orig(0)
+ self.crop_right.enable()
+ self.crop_right.set_orig(0)
+
+ framerate = round(float(fractions.Fraction(video_stream['avg_frame_rate'])), 3)
+ self.framerate.enable()
+ self.framerate.set_orig(framerate)
+ else:
+ self.width.disable()
+ self.height.disable()
+ self.framerate.disable()
+ self.crop_top.disable()
+ self.crop_bottom.disable()
+ self.crop_left.disable()
+ self.crop_right.disable()
+
+ self.state(['!disabled'])
+ self.enforce_constraints()
+
+ def ffmpeg_opts(self):
+ input_opts = []
+ output_opts = []
+ vf = []
+
+ if self.start_time.is_edit():
+ input_opts += ['-ss', str(self.start_time.get_final())]
+ elif self.end_time.is_edit() and self.duration.is_edit():
+ new_end = float(self.end_time.get_final())
+ new_duration = float(self.duration.get_final())
+ new_start = new_end - new_duration
+ input_opts += ['-ss', str(new_start)]
+ if self.end_time.is_edit():
+ input_opts += ['-to', str(self.end_time.get_final())]
+ if self.duration.is_edit():
+ output_opts += ['-t', str(self.duration.get_final())]
+
+ if self.width.is_edit() or self.height.is_edit():
+ width = str(self.width.get_final())
+ height = str(self.height.get_final())
+ if not self.width.is_edit():
+ width = "-1"
+ if not self.height.is_edit():
+ height = "-1"
+ vf += ['scale=' + width + ':' + height]
+
+ if self.crop_top.is_edit() or self.crop_bottom.is_edit() or \
+ self.crop_left.is_edit() or self.crop_right.is_edit():
+ out_w = int(self.width.get_final()) - int(self.crop_left.get_final()) - int(self.crop_right.get_final())
+ out_h = int(self.height.get_final()) - int(self.crop_top.get_final()) - int(self.crop_bottom.get_final())
+ vf += [f'crop={out_w}:{out_h}:{self.crop_left.get_final()}:{self.crop_top.get_final()}']
+
+ if self.framerate.is_edit():
+ output_opts += ['-r', str(self.framerate.get_final())]
+
+ return FFmpegOptions(input_opts, output_opts, vf)
+
+ def frame_count(self):
+ return float(self.duration.get_final()) * float(self.framerate.get_final())
diff --git a/vidslice/output.py b/vidslice/output.py
new file mode 100644
index 0000000..f39cdc4
--- /dev/null
+++ b/vidslice/output.py
@@ -0,0 +1,162 @@
+import os
+import subprocess
+import threading
+from tkinter import *
+from tkinter import filedialog
+from tkinter import ttk
+
+from options import FFmpegOptions
+
+
+class OutputPanel(ttk.LabelFrame):
+ def __init__(self, *args, get_ffmpeg_args=lambda: FFmpegOptions([], [], []), get_frame_count=lambda: 0, **kw):
+ super(OutputPanel, self).__init__(*args, text='Output', **kw)
+ self.update_listeners = []
+ self.input_path = None
+ self.get_ffmpeg_args = get_ffmpeg_args
+ self.get_frame_count = get_frame_count
+
+ ttk.Label(self, text="File").grid(column=0, row=0, sticky=W)
+ self.file_text = StringVar(self)
+ ttk.Entry(self, textvariable=self.file_text, width=30).grid(column=1, row=0, columnspan=2, sticky=(E, W))
+ self.columnconfigure(1, weight=1)
+ self.columnconfigure(2, weight=1)
+ self.file_text.trace_add("write", self.handle_file_changed)
+ ttk.Button(self, text="Browse", command=self.handle_file_browse_pressed).grid(column=3, row=0, sticky=E)
+
+ self.silence = BooleanVar(self)
+ ttk.Checkbutton(self, text="Silence", variable=self.silence, onvalue=True, offvalue=False
+ ).grid(column=0, row=1, columnspan=2, sticky=W)
+ self.deepfry = BooleanVar(self)
+ ttk.Checkbutton(self, text="Compress beyond recognition", variable=self.deepfry, onvalue=True, offvalue=False
+ ).grid(column=2, row=1, columnspan=2, sticky=W)
+
+ run_button = ttk.Button(self, text="Run", command=self.handle_run_pressed)
+ run_button.grid(column=0, row=2, sticky=(E, W))
+ run_preview_button = ttk.Button(self, text="Run & Preview", command=self.handle_run_preview_pressed)
+ run_preview_button.grid(column=1, row=2, columnspan=2, sticky=(E, W))
+ run_quit_button = ttk.Button(self, text="Run & Quit", command=self.handle_run_quit_pressed)
+ run_quit_button.grid(column=3, row=2, sticky=(E, W))
+
+ self.progress = ttk.Progressbar(self, orient=HORIZONTAL, mode='determinate')
+ self.progress.grid(column=0, row=3, columnspan=4, sticky=(N, S, E, W))
+
+ self.logs = StringVar(self)
+ logs_widget = ttk.Label(self, textvariable=self.logs, font="TkFixedFont",
+ justify='left')
+ logs_widget.grid(column=0, row=4, columnspan=4, sticky=(N, S, E, W))
+ self.rowconfigure(4, weight=1)
+
+ for child in self.winfo_children():
+ child.grid_configure(padx=2, pady=2)
+
+ self.enable(False)
+
+ def enable(self, enabled, run_enabled=None):
+ if run_enabled is None:
+ run_enabled = enabled
+ state = 'disabled'
+ if enabled:
+ state = '!' + state
+ run_state = 'disabled'
+ if run_enabled:
+ run_state = '!' + run_state
+ self.state([state])
+ for child in self.winfo_children():
+ if 'text' in child.configure() and child['text'].startswith('Run'):
+ child.state([run_state])
+ else:
+ child.state([state])
+
+ def handle_file_changed(self, *args):
+ path = self.file_text.get()
+ (folder, name) = os.path.split(path)
+ try:
+ os.stat(folder)
+ self.enable(True)
+ except FileNotFoundError:
+ self.enable(True, False)
+
+ def handle_file_browse_pressed(self, *args):
+ filename = filedialog.asksaveasfilename(parent=self)
+ if filename != '':
+ self.file_text.set(filename)
+
+ def handle_run_pressed(self, *args, callback=lambda _: None):
+ self.logs.set('')
+ self.progress['value'] = 0
+ self.enable(False)
+ real_args = self.get_ffmpeg_args()
+ self.progress['maximum'] = float(self.get_frame_count())
+ print(self.get_frame_count())
+ input_args = real_args.input
+ output_args = real_args.output_with_vf()
+ output_path = self.file_text.get()
+ (folder, name) = os.path.split(output_path)
+ (name, ext) = os.path.splitext(name)
+ if self.silence.get():
+ output_args += ['-an']
+ if ext == '.gif':
+ filter_before = '[0:v] '
+ filter_after = 'split [a][b];[a] palettegen [p];[b][p] paletteuse'
+ filter_during = ''
+ if '-vf' in output_args:
+ for i in range(len(output_args) - 1):
+ [a, b] = output_args[i:i + 2]
+ if a == '-vf':
+ filter_during = b + ','
+ output_args[i:i + 2] = []
+ break
+ output_args += ['-filter_complex', filter_before + filter_during + filter_after]
+ if self.deepfry.get():
+ if ext == '.mp3':
+ output_args += ['-q:a', '9']
+ else:
+ output_args += ['-q:a', '0.1', '-crf', '51']
+ args = ['ffmpeg', '-hide_banner', '-v', 'warning', '-stats', '-y'] + input_args + ['-i',
+ self.input_path] + output_args + [
+ output_path]
+ print(args)
+
+ def run():
+ # noinspection PyArgumentList
+ proc = subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+ text=True, creationflags=subprocess.CREATE_NO_WINDOW)
+ while proc.poll() is None:
+ out_data = proc.stdout.readline()
+ if out_data != '':
+ progress_data = re.match(r'^frame=\s*(\d+)', out_data)
+ print(out_data, end='')
+ if progress_data is not None:
+ self.progress['value'] = float(progress_data.group(1))
+ else:
+ self.logs.set(self.logs.get() + out_data)
+ self.progress['value'] = self.progress['maximum']
+ self.enable(True)
+ callback(proc.returncode)
+
+ threading.Thread(target=run).start()
+
+ def handle_run_preview_pressed(self, *args):
+ def preview(code):
+ if code == 0:
+ out_file = self.file_text.get()
+ subprocess.Popen(['ffplay', '-autoexit', out_file], creationflags=subprocess.CREATE_NO_WINDOW)
+
+ self.handle_run_pressed(*args, callback=preview)
+
+ def handle_run_quit_pressed(self, *args):
+ def quit(code):
+ if code == 0:
+ toplevel = self.winfo_toplevel()
+ toplevel.destroy()
+
+ self.handle_run_pressed(*args, callback=quit)
+
+ def set_input_path(self, path, data):
+ if data is None:
+ self.enable(False)
+ self.input_path = None
+ else:
+ self.handle_file_changed()
+ self.input_path = path
diff --git a/vidslice/preview.py b/vidslice/preview.py
new file mode 100644
index 0000000..f22c6ba
--- /dev/null
+++ b/vidslice/preview.py
@@ -0,0 +1,89 @@
+import subprocess
+import tempfile
+import threading
+from tkinter import *
+from tkinter import ttk
+
+from options import FFmpegOptions
+
+
+class PreviewPanel(ttk.LabelFrame):
+ def __init__(self, *args, get_ffmpeg_args=lambda: FFmpegOptions([], []), get_frame_count=lambda: 0, **kw):
+ super(PreviewPanel, self).__init__(*args, text='Preview', **kw)
+ self.input_path = None
+ self.get_ffmpeg_args = get_ffmpeg_args
+ self.get_frame_count = get_frame_count
+
+ def button(text, command, column):
+ ttk.Button(self, text=text, command=command).grid(column=column, row=0, sticky=(N, W, S, E))
+ self.columnconfigure(column, weight=1)
+
+ button("Preview Start", self.preview_start, 0)
+ button("Preview Middle", self.preview_middle, 1)
+ button("Preview End", self.preview_end, 2)
+
+ self.image = None
+ self.image_label = ttk.Label(self, anchor='center')
+ self.image_label.grid(column=0, row=1, columnspan=3, sticky=(N, W, S, E))
+ self.rowconfigure(1, weight=1)
+
+ self.enable(False)
+
+ def preview_at(self, offset):
+ offset = int(offset)
+ self.enable(False)
+ real_args = self.get_ffmpeg_args()
+ input_args = real_args.input
+ width = self.image_label.winfo_width()
+ height = self.image_label.winfo_height()
+ real_args.vf += [
+ rf'select=eq(n\,{offset})',
+ f'scale=w={width}:h={height}:force_original_aspect_ratio=decrease'
+ ]
+ real_args.output += ['-frames:v', '1']
+ output_args = real_args.output_with_vf()
+
+ def run():
+ _, output_path = tempfile.mkstemp(suffix='.png')
+ args = ['ffmpeg', '-hide_banner', '-v', 'warning', '-y'] + input_args + \
+ ['-i', self.input_path] + output_args + [output_path]
+ print(args)
+ # noinspection PyArgumentList
+ proc = subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT, text=True, creationflags=subprocess.CREATE_NO_WINDOW)
+ while proc.poll() is None:
+ out_data = proc.stdout.readline()
+ if out_data != '':
+ print(out_data, end='')
+ self.enable(True)
+ self.image = PhotoImage(file=output_path)
+ self.image_label['image'] = self.image
+
+ threading.Thread(target=run).start()
+
+ def preview_start(self, *args):
+ self.preview_at(0)
+
+ def preview_middle(self, *args):
+ self.preview_at(self.get_frame_count() / 2)
+
+ def preview_end(self, *args):
+ self.preview_at(self.get_frame_count() - 1)
+
+ def enable(self, enabled):
+ state = 'disabled'
+ if enabled:
+ state = '!' + state
+ self.state([state])
+ for child in self.winfo_children():
+ try:
+ child.state([state])
+ except AttributeError:
+ pass
+
+ def set_input_path(self, path, data):
+ self.enable(data is not None)
+ if data is None:
+ self.input_path = None
+ else:
+ self.input_path = path
diff --git a/vidslice/sources.py b/vidslice/sources.py
new file mode 100644
index 0000000..180e51c
--- /dev/null
+++ b/vidslice/sources.py
@@ -0,0 +1,134 @@
+import glob
+import json
+import os
+import subprocess
+import tempfile
+import threading
+from tkinter import *
+from tkinter import filedialog
+from tkinter import messagebox
+from tkinter import ttk
+
+
+def has_ytdl():
+ try:
+ subprocess.run(["youtube-dl", "--version"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
+ return True
+ except FileNotFoundError:
+ return False
+
+
+def update_ytdl(root):
+ try:
+ youtube_dl_found = subprocess.run(['where', 'youtube-dl'], stdout=subprocess.PIPE, text=True,
+ creationflags=subprocess.CREATE_NO_WINDOW)
+ except FileNotFoundError:
+ youtube_dl_found = subprocess.run(['which', 'youtube-dl'], stdout=subprocess.PIPE, text=True,
+ creationflags=subprocess.CREATE_NO_WINDOW)
+ if youtube_dl_found.returncode != 0:
+ answer = messagebox.askyesno(message="Could not find youtube-dl. Open vidslice README?", title="Error",
+ icon='error', parent=root)
+ if answer:
+ import webbrowser
+ webbrowser.open("https://github.com/boringcactus/vidslice/blob/master/README.md")
+ youtube_dl_path = youtube_dl_found.stdout.split("\n")[0]
+ old_mtime = os.stat(youtube_dl_path).st_mtime
+ proc = subprocess.run(["youtube-dl", "-U"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
+ creationflags=subprocess.CREATE_NO_WINDOW)
+ if not proc.stdout.startswith("youtube-dl is up-to-date") and not proc.stdout.startswith("ERROR"):
+ while os.stat(youtube_dl_path).st_mtime == old_mtime:
+ from time import sleep
+ sleep(0.25)
+ messagebox.showinfo(message="Updated youtube-dl successfully", title="Complete", parent=root)
+
+
+class SourcesPanel(ttk.LabelFrame):
+ """
+ A Panel representing source info
+ """
+
+ def __init__(self, *args, **kw):
+ super(SourcesPanel, self).__init__(*args, text='Sources', **kw)
+ self.update_listeners = []
+
+ if has_ytdl():
+ ttk.Label(self, text="URL").grid(column=0, row=0, sticky=(E, W))
+ self.url_text = StringVar(self)
+ ttk.Entry(self, textvariable=self.url_text).grid(column=1, row=0, sticky=(E, W))
+ ttk.Button(self, text="Download", command=self.handle_url_download_pressed
+ ).grid(column=2, row=0, sticky=(E, W))
+ else:
+ ttk.Label(self, text="Could not find youtube-dl, can't download videos automatically"
+ ).grid(column=0, row=0, columnspan=3, sticky=(E, W))
+ self.url_text = None
+
+ ttk.Label(self, text="File").grid(column=0, row=1, sticky=(E, W))
+ self.file_text = StringVar(self)
+ self.file_text.trace_add("write", self.handle_file_changed)
+ ttk.Entry(self, textvariable=self.file_text).grid(column=1, row=1, sticky=(E, W))
+ self.columnconfigure(1, weight=1)
+ ttk.Button(self, text="Browse", command=self.handle_file_browse_pressed).grid(column=2, row=1, sticky=(E, W))
+
+ self.status_label = StringVar(self, "Status: Select a file")
+ ttk.Label(self, textvariable=self.status_label).grid(column=0, row=2, columnspan=3, sticky=(E, W))
+
+ for child in self.winfo_children():
+ child.grid_configure(padx=2, pady=2)
+
+ def set_status(self, text):
+ self.status_label.set("Status: " + text)
+
+ def handle_url_download_pressed(self, *args):
+ self.set_status("Downloading...")
+
+ def download():
+ file = tempfile.NamedTemporaryFile(delete=False)
+ # noinspection PyArgumentList
+ proc = subprocess.Popen([
+ 'youtube-dl', '-o', file.name + '.%(ext)s', self.url_text.get()
+ ], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
+ creationflags=subprocess.CREATE_NO_WINDOW)
+ while proc.poll() is None:
+ out_data = proc.stdout.readline()
+ if out_data != '':
+ self.set_status("Downloading: " + out_data.strip())
+ if proc.returncode == 0:
+ output_file = glob.glob(glob.escape(file.name) + '.*')[0]
+ self.set_status("Downloaded!")
+ self.file_text.set(output_file)
+ else:
+ error = ''.join(proc.stderr.readlines()).strip()
+ self.set_status("Couldn't download: " + error)
+
+ threading.Thread(target=download).start()
+
+ def handle_file_browse_pressed(self, *args):
+ filename = filedialog.askopenfilename(parent=self)
+ if filename != '':
+ self.file_text.set(filename)
+
+ def handle_file_changed(self, *args):
+ def probe():
+ result = subprocess.run([
+ 'ffprobe', '-v', 'error', '-of', 'json',
+ '-show_entries', 'format=start_time,duration:stream=index,codec_type,avg_frame_rate,width,height',
+ self.file_text.get()
+ ], capture_output=True, text=True, creationflags=subprocess.CREATE_NO_WINDOW)
+ if result.returncode == 0:
+ ffprobe_data = json.loads(result.stdout)
+ self.set_status("Successfully loaded media info")
+ for listener in self.update_listeners:
+ listener(ffprobe_data)
+ else:
+ self.set_status("Failed to load media info: " + result.stderr)
+ for listener in self.update_listeners:
+ listener(None)
+
+ threading.Thread(target=probe).start()
+
+ def on_update(self, callback):
+ self.update_listeners.append(callback)
+
+ def get_file(self):
+ return self.file_text.get()