From 326cbf53a2cfa1c115523b9eb4837f629011a1f7 Mon Sep 17 00:00:00 2001 From: Melody Horn Date: Fri, 29 Jan 2021 01:10:56 -0700 Subject: pull code out into a python module --- vidslice/__init__.py | 0 vidslice/__main__.py | 123 ++++++++++++++++++++ vidslice/options.py | 313 +++++++++++++++++++++++++++++++++++++++++++++++++++ vidslice/output.py | 162 ++++++++++++++++++++++++++ vidslice/preview.py | 89 +++++++++++++++ vidslice/sources.py | 134 ++++++++++++++++++++++ 6 files changed, 821 insertions(+) create mode 100644 vidslice/__init__.py create mode 100644 vidslice/__main__.py create mode 100644 vidslice/options.py create mode 100644 vidslice/output.py create mode 100644 vidslice/preview.py create mode 100644 vidslice/sources.py (limited to 'vidslice') diff --git a/vidslice/__init__.py b/vidslice/__init__.py new file mode 100644 index 0000000..e69de29 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() -- cgit v1.2.3