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 --- options.py | 313 --------------------------------------------------- output.py | 162 -------------------------- preview.py | 89 --------------- setup.py | 33 ------ sources.py | 134 ---------------------- vidslice.py | 123 -------------------- vidslice/__init__.py | 0 vidslice/__main__.py | 123 ++++++++++++++++++++ vidslice/options.py | 313 +++++++++++++++++++++++++++++++++++++++++++++++++++ vidslice/output.py | 162 ++++++++++++++++++++++++++ vidslice/preview.py | 89 +++++++++++++++ vidslice/sources.py | 134 ++++++++++++++++++++++ 12 files changed, 821 insertions(+), 854 deletions(-) delete mode 100644 options.py delete mode 100644 output.py delete mode 100644 preview.py delete mode 100644 setup.py delete mode 100644 sources.py delete mode 100644 vidslice.py 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 diff --git a/options.py b/options.py deleted file mode 100644 index 016fbda..0000000 --- a/options.py +++ /dev/null @@ -1,313 +0,0 @@ -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/output.py b/output.py deleted file mode 100644 index f39cdc4..0000000 --- a/output.py +++ /dev/null @@ -1,162 +0,0 @@ -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/preview.py b/preview.py deleted file mode 100644 index f22c6ba..0000000 --- a/preview.py +++ /dev/null @@ -1,89 +0,0 @@ -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/setup.py b/setup.py deleted file mode 100644 index 92300c8..0000000 --- a/setup.py +++ /dev/null @@ -1,33 +0,0 @@ -import os -import sys - -from cx_Freeze import setup, Executable - -from vidslice import VERSION - -# Dependencies are automatically detected, but it might need -# fine tuning. -buildOptions = dict(packages=[], excludes=[]) - -PYTHON_INSTALL_DIR = os.path.dirname(os.path.dirname(os.__file__)) -if sys.platform == "win32": - dlls_folder = os.path.join(PYTHON_INSTALL_DIR, 'DLLs') - targets = ['libcrypto', 'libssl'] - include_files = [] - for dll in os.listdir(dlls_folder): - for target in targets: - if target.startswith(target): - include_files.append(os.path.join(dlls_folder, dll)) - buildOptions['include_files'] = include_files - -base = 'Win32GUI' if sys.platform == 'win32' else None - -executables = [ - Executable('vidslice.py', base=base) -] - -setup(name='vidslice', - version=VERSION, - description='', - options=dict(build_exe=buildOptions), - executables=executables) diff --git a/sources.py b/sources.py deleted file mode 100644 index 180e51c..0000000 --- a/sources.py +++ /dev/null @@ -1,134 +0,0 @@ -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() diff --git a/vidslice.py b/vidslice.py deleted file mode 100644 index eca036c..0000000 --- a/vidslice.py +++ /dev/null @@ -1,123 +0,0 @@ -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/__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