import tkinter as tk # https://docs.python.org/3/library/tkinter.html from tkinter import filedialog, ttk import av import subprocess from pathlib import Path import time import datetime from PIL import Image, ImageTk, ImageOps from RangeSlider.RangeSlider import RangeSliderH from ffpyplayer.player import MediaPlayer from probe import get_keyframes_list, get_keyframe_interval, get_video_duration class VideoClipExtractor: def __init__(self, master): # Initialize variables self.video_duration = int() # milliseconds self.video_path = Path() # Path object self.video_keyframes = list() # list of ints (keyframe pts in milliseconds) self.clip_start = tk.IntVar(value = 0) # milliseconds self.clip_end = tk.IntVar(value = 1) # milliseconds self.preview_image_timestamp = tk.IntVar(value = 0) # milliseconds self.debug_checkvar = tk.IntVar() # Checkbox variable self.background_color = "#BBBBBB" self.text_color = "#000000" self.preview_background_color = "#2222FF" # Set up master UI self.master = master self.master.title("Video Clip Extractor") self.master.configure(background=self.background_color) self.master.resizable(False, False) self.master.geometry("") self.window_max_width = self.master.winfo_screenwidth()*0.75 self.window_max_height = self.master.winfo_screenheight()*0.75 self.preview_width = 1280 self.preview_height = 720 self.preview_image = Image.new("RGB", (self.preview_width, self.preview_height), color=self.background_color) self.preview_image_tk = ImageTk.PhotoImage(self.preview_image) self.timeline_width = self.preview_width self.timeline_height = 64 self.interface_width = self.preview_width self.interface_height = 200 # Initialize frames, buttons and labels self.preview_frame = tk.Frame(self.master, width=self.preview_width, height=self.preview_height, bg=self.preview_background_color, borderwidth=0, bd=0) self.timeline_frame = tk.Frame(self.master, width=self.timeline_width, height=self.timeline_height, bg=self.background_color) self.interface_pane = tk.Frame(self.master, width=self.interface_width, height=self.interface_height, bg=self.background_color) self.buttons_pane = tk.Frame(self.interface_pane, bg=self.background_color) self.info_pane = tk.Frame(self.interface_pane, bg=self.background_color) self.preview_canvas = tk.Canvas(self.preview_frame, width=self.preview_width, height=self.preview_height, bg=self.preview_background_color, borderwidth=0, bd=0) self.browse_button = tk.Button(self.buttons_pane, text="Browse...", command=self.browse_video_file, background=self.background_color, foreground=self.text_color) self.extract_button = tk.Button(self.buttons_pane, text="Extract Clip", command=self.extract_clip, background=self.background_color, foreground=self.text_color) self.debug_checkbutton = tk.Checkbutton(self.buttons_pane, text="Print ffmpeg to console", variable=self.debug_checkvar, background=self.background_color, foreground=self.text_color) self.preview_button = tk.Button(self.buttons_pane, text="Preview Clip", command=self.ffplaySegment, background=self.background_color, foreground=self.text_color) self.video_path_label = tk.Label(self.info_pane, text=f"Source video: {self.video_path}", background=self.background_color, foreground=self.text_color) self.clip_start_label = tk.Label(self.timeline_frame, text=f"{self.timeStr(self.clip_start.get())}", background=self.background_color, foreground=self.text_color) self.clip_end_label = tk.Label(self.timeline_frame, text=f"{self.timeStr(self.clip_end.get())}", background=self.background_color, foreground=self.text_color) self.video_duration_label = tk.Label(self.info_pane, text=f"Video duration: {self.timeStr(self.video_duration)}", background=self.background_color, foreground=self.text_color) self.timeline_canvas = tk.Canvas(self.timeline_frame, width=self.preview_width, height=self.timeline_height, background=self.background_color) self.timeline = RangeSliderH( self.timeline_canvas, [self.clip_start, self.clip_end], max_val=max(self.video_duration,1), show_value=False, bgColor=self.background_color, Width=self.timeline_width, Height=self.timeline_height ) self.preview_label = tk.Label(self.preview_frame, image=self.preview_image_tk) print(f"Widget widths (after pack):\n\ self.clip_start_label.winfo_width(): {self.clip_start_label.winfo_width()}\n\ self.clip_end_label.winfo_width(): {self.clip_end_label.winfo_width()}\n\ self.timeline.winfo_width(): {self.timeline.winfo_width()}\n\ ") # Arrange frames inside master window self.preview_frame.pack(side='top', fill='both', expand=True, padx=0, pady=0) self.timeline_frame.pack(fill='x', expand=True, padx=20, pady=20) self.interface_pane.pack(side='bottom', fill='both', expand=True, padx=10, pady=10) self.buttons_pane.pack(side='left') self.info_pane.pack(side='right') # Draw elements inside frames self.browse_button.pack(side='top') self.extract_button.pack(side='top') self.preview_button.pack(side='top') self.debug_checkbutton.pack(side='top') self.video_path_label.pack(side='top') self.clip_start_label.pack(side='left') self.clip_end_label.pack(side='right') self.video_duration_label.pack(side='top') self.preview_label.pack(fill='both', expand=True) # Draw timeline canvas and timeline slider self.timeline_canvas.pack(fill="both", expand=True) self.timeline.pack(fill="both", expand=True) print(f"Widget widths (after pack):\n\ self.clip_start_label.winfo_width(): {self.clip_start_label.winfo_width()}\n\ self.clip_end_label.winfo_width(): {self.clip_end_label.winfo_width()}\n\ self.timeline.winfo_width(): {self.timeline.winfo_width()}\n\ ") def getThumbnail(self): with av.open(str(self.video_path)) as container: time_ms = self.clip_start.get() # This works as long as container has a timebase of 1/1000 container.seek(time_ms, stream=container.streams.video[0]) time.sleep(0.1) frame = next(container.decode(video=0)) # Get the frame object for the seeked timestamp if self.preview_image_timestamp != time_ms: self.preview_image_tk = ImageTk.PhotoImage(frame.to_image(width=self.preview_width, height=self.preview_height)) # Convert the frame object to an image self.preview_label.config(image=self.preview_image_tk) self.preview_image_timestamp = time_ms def ffplaySegment(self): ffplay_command = [ "ffplay", "-hide_banner", "-autoexit", "-volume", "10", "-window_title", f"{self.timeStr(self.clip_start.get())} to {self.timeStr(self.clip_end.get())}", "-x", "1280", "-y", "720", "-ss", f"{self.clip_start.get()}ms", "-i", str(self.video_path), "-t", f"{self.clip_end.get() - self.clip_start.get()}ms" ] print("Playing video. Press \"q\" or \"Esc\" to exit.") print("") subprocess.run(ffplay_command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def redrawTimeline(self): self.timeline.forget() step_size = get_keyframe_interval(self.video_keyframes) step_marker = False if len(self.video_keyframes) < self.timeline_width/4 and step_size > 0: step_marker = True self.timeline = RangeSliderH( self.timeline_canvas, [self.clip_start, self.clip_end], max_val=max(self.video_duration,1), step_marker=step_marker, step_size=step_size, show_value=False, bgColor=self.background_color, Width=self.timeline_width, Height=self.timeline_height ) self.timeline.pack() #self.preview_canvas.create_text(self.preview_canvas.winfo_width() // 2, self.preview_canvas.winfo_height() // 2, text=f"Loading video...", fill="black", font=("Helvetica", 48)) def timeStr(self, milliseconds: int): # Takes milliseconds int or float and returns a string in the preferred format h = int(milliseconds/3600000) # Get the hours component m = int((milliseconds%3600000)/60000) # Get the minutes component s = int((milliseconds%60000)/1000) # Get the seconds component ms = int(milliseconds%1000) # Get the milliseconds component if milliseconds < 60000: return f"{s}.{ms:03}" elif milliseconds < 3600000: return f"{m}:{s:02}.{ms:03}" else: return f"{h}:{m:02}:{s:02}.{ms:03}" def clip_selector(self): def updateClipRange(var, index, mode): clip_end = self.clip_end.get() nearest_keyframe_start = self.nearest_keyframe(self.clip_start.get(), self.video_keyframes) # Add a specific check to make sure that the clip end is not changing to be equal to or less than the clip start if clip_end <= nearest_keyframe_start: clip_end = nearest_keyframe_start + self.timeline.__dict__['step_size'] self.clip_start_label.config(text=f"{self.timeStr(nearest_keyframe_start)}") self.clip_end_label.config(text=f"{self.timeStr(clip_end)}") self.timeline.forceValues([nearest_keyframe_start, clip_end]) self.getThumbnail() if str(self.video_path) == "()": return False self.clip_start.trace_add("write", callback=updateClipRange) # This actually triggers on both start and end def nearest_keyframe(self, test_pts: int, valid_pts: list): return(min(valid_pts, key=lambda x:abs(x-float(test_pts)))) def browse_video_file(self): video_path = filedialog.askopenfilename( initialdir="~/Git/Clip/TestClips/", title="Select file", filetypes=(("mp4/mkv files", '*.mp4 *.mkv'), ("all files", "*.*")) ) print(f"video path: \"{video_path}\" (type: {type(video_path)})") if not Path(str(video_path)).is_file(): return video_keyframes = get_keyframes_list(video_path) while video_keyframes == None: print(f"No keyframes found in {video_path}. Choose a different video file.") video_path = filedialog.askopenfilename( initialdir="~/Git/Clip/TestClips/", title="Select file", filetypes=(("mp4/mkv files", '*.mp4 *.mkv'), ("all files", "*.*")) ) # Once we have a video file, we need to set the Source video, Clip start, Clip end, and Video duration values and redraw the GUI. self.video_path = Path(video_path) self.video_duration = get_video_duration(video_path) self.video_keyframes = video_keyframes self.clip_start.set(min(self.video_keyframes)) self.clip_end.set(max(self.video_keyframes)) self.clip_start_label.config(text=f"{self.timeStr(self.nearest_keyframe(self.clip_start.get(), self.video_keyframes))}") self.clip_end_label.config(text=f"{self.timeStr(self.clip_end.get())}") self.getThumbnail() self.video_path_label.config(text=f"Source video: {self.video_path}") self.video_duration_label.config(text=f"Video duration: {self.timeStr(self.video_duration)}") self.redrawTimeline() self.clip_selector() def extract_clip(self): video_path = self.video_path file_extension = video_path.suffix clip_start = self.clip_start.get() clip_end = self.clip_end.get() output_path = Path( filedialog.asksaveasfilename( initialdir=video_path.parent, initialfile=str( f"[Clip] {video_path.stem} ({datetime.timedelta(milliseconds=clip_start)}-{datetime.timedelta(milliseconds=clip_end)}){file_extension}"), title="Select output file", defaultextension=file_extension ) ) if output_path == Path("."): return False ffmpeg_command = [ "ffmpeg", "-y", # The output path prompt asks for confirmation before overwriting "-hide_banner", "-i", str(video_path), "-ss", f"{clip_start}ms", "-to", f"{clip_end}ms", "-map", "0", "-c:v", "copy", "-c:a", "copy", str(output_path), ] if self.debug_checkvar.get() == 1: subprocess.run(ffmpeg_command) else: subprocess.run(ffmpeg_command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) print(f"Finished! Saved to {output_path}") root = tk.Tk() app = VideoClipExtractor(root) root.mainloop()