import tkinter as tk from tkinter import ttk, filedialog, messagebox import threading import subprocess import os import json import psutil from datetime import datetime import cv2 from PIL import Image, ImageTk import ffmpeg import sv_ttk class VodKeeper: def __init__(self, root): self.root = root self.root.title("VodKeeper - Video Processing Workflow") self.root.geometry("1200x800") # Dark mode colors self.colors = { 'bg': '#2b2b2b', 'fg': '#e0e0e0', 'select_bg': '#0078d4', 'select_fg': '#ffffff', 'entry_bg': '#3c3c3c', 'entry_fg': '#e0e0e0', 'button_bg': '#404040', 'button_fg': '#e0e0e0', 'frame_bg': '#383838', 'text_bg': '#1e1e1e', 'text_fg': '#e0e0e0' } # Configuration self.config = { 'input_path': 'R:\\Videos\\mux\\input', 'output_path': 'R:\\Videos\\mux\\output', 'logs_path': 'R:\\Videos\\mux\\logs', 'codec': 'h265_nvenc', 'preset': 'p7', 'cq': 0, 'auto_bitrate': True, 'preview_duration': 60 } # Render state self.current_process = None self.render_queue = [] self.is_rendering = False self.setup_ui() self.load_config() self.refresh_files() # Populate file list on startup def setup_dark_theme(self): # Configure root window self.root.configure(bg=self.colors['bg']) # Configure ttk styles for dark theme style = ttk.Style() style.theme_use('clam') # Configure Notebook (tabs) style.configure('TNotebook', background=self.colors['bg'], borderwidth=0) style.configure('TNotebook.Tab', background=self.colors['frame_bg'], foreground=self.colors['fg'], padding=[10, 4]) style.map('TNotebook.Tab', background=[('selected', self.colors['select_bg']), ('active', self.colors['select_bg'])]) # Configure Frames style.configure('TFrame', background=self.colors['bg'], borderwidth=0) style.configure('TLabelFrame', background=self.colors['bg'], foreground=self.colors['fg'], borderwidth=1, relief='solid') style.configure('TLabelFrame.Label', background=self.colors['bg'], foreground=self.colors['fg']) # Configure Labels style.configure('TLabel', background=self.colors['bg'], foreground=self.colors['fg']) # Configure Buttons style.configure('TButton', background=self.colors['button_bg'], foreground=self.colors['button_fg'], borderwidth=1, relief='solid') style.map('TButton', background=[('active', self.colors['select_bg']), ('pressed', self.colors['entry_bg'])]) # Configure Entry widgets style.configure('TEntry', fieldbackground=self.colors['entry_bg'], foreground=self.colors['entry_fg'], borderwidth=1, insertcolor=self.colors['entry_fg']) style.map('TEntry', focuscolor=[('!focus', self.colors['entry_bg'])]) # Configure Combobox style.configure('TCombobox', fieldbackground=self.colors['entry_bg'], foreground=self.colors['entry_fg'], background=self.colors['entry_bg'], borderwidth=1, arrowcolor=self.colors['fg']) style.map('TCombobox', fieldbackground=[('readonly', self.colors['entry_bg'])], background=[('readonly', self.colors['entry_bg'])]) # Configure Spinbox style.configure('TSpinbox', fieldbackground=self.colors['entry_bg'], foreground=self.colors['entry_fg'], background=self.colors['entry_bg'], borderwidth=1, arrowcolor=self.colors['fg']) # Configure Checkbutton style.configure('TCheckbutton', background=self.colors['bg'], foreground=self.colors['fg'], focuscolor=self.colors['bg']) # Configure Progressbar style.configure('TProgressbar', background=self.colors['select_bg'], troughcolor=self.colors['entry_bg'], borderwidth=1, lightcolor=self.colors['select_bg'], darkcolor=self.colors['select_bg']) # Configure Treeview style.configure('Treeview', background=self.colors['entry_bg'], foreground=self.colors['entry_fg'], fieldbackground=self.colors['entry_bg'], borderwidth=1) style.configure('Treeview.Heading', background=self.colors['frame_bg'], foreground=self.colors['fg'], borderwidth=1, relief='solid') style.map('Treeview', background=[('selected', self.colors['select_bg'])], foreground=[('selected', self.colors['select_fg'])]) # Configure Scrollbar style.configure('Vertical.TScrollbar', background=self.colors['frame_bg'], troughcolor=self.colors['entry_bg'], borderwidth=1, arrowcolor=self.colors['fg'], darkcolor=self.colors['frame_bg'], lightcolor=self.colors['frame_bg']) style.map('Vertical.TScrollbar', background=[('active', self.colors['select_bg'])]) style.configure('Horizontal.TScrollbar', background=self.colors['frame_bg'], troughcolor=self.colors['entry_bg'], borderwidth=1, arrowcolor=self.colors['fg'], darkcolor=self.colors['frame_bg'], lightcolor=self.colors['frame_bg']) style.map('Horizontal.TScrollbar', background=[('active', self.colors['select_bg'])]) def setup_ui(self): # Create notebook for tabs self.notebook = ttk.Notebook(self.root) self.notebook.pack(fill='both', expand=True, padx=10, pady=10) # Input Tab self.setup_input_tab() # Render Tab self.setup_render_tab() # QC Tab self.setup_qc_tab() def setup_input_tab(self): input_frame = ttk.Frame(self.notebook) self.notebook.add(input_frame, text="Input") # Create main horizontal layout main_frame = ttk.Frame(input_frame) main_frame.pack(fill='both', expand=True, padx=10, pady=5) main_frame.columnconfigure(0, weight=3) # File selection gets more space main_frame.columnconfigure(1, weight=2) # File info gets proportional space main_frame.rowconfigure(0, weight=1) # Allow vertical expansion # File selection (left side) file_frame = ttk.LabelFrame(main_frame, text="File Selection", padding=10) file_frame.grid(row=0, column=0, sticky='nsew', padx=(0,5)) file_frame.columnconfigure(0, weight=1) file_frame.columnconfigure(1, weight=1) file_frame.columnconfigure(2, weight=0) file_frame.rowconfigure(2, weight=1) # Make file list expand vertically ttk.Label(file_frame, text="Input Directory:").grid(row=0, column=0, sticky='w', pady=2) self.input_path_var = tk.StringVar(value=self.config['input_path']) ttk.Entry(file_frame, textvariable=self.input_path_var, width=50).grid(row=0, column=1, padx=5, pady=2) ttk.Button(file_frame, text="Browse", command=self.browse_input).grid(row=0, column=2, padx=5, pady=2) # File list spanning full width ttk.Label(file_frame, text="Available Files:").grid(row=1, column=0, sticky='w', pady=(10,2)) # Create frame for listbox with scrollbar listbox_frame = ttk.Frame(file_frame) listbox_frame.grid(row=2, column=0, columnspan=3, sticky='nsew', pady=2) listbox_frame.rowconfigure(0, weight=1) listbox_frame.columnconfigure(0, weight=1) self.file_listbox = tk.Listbox(listbox_frame, bg=self.colors['entry_bg'], fg=self.colors['entry_fg'], selectbackground=self.colors['select_bg'], selectforeground=self.colors['select_fg']) self.file_listbox.grid(row=0, column=0, sticky='nsew') self.file_listbox.bind('<>', self.on_file_select) # Add scrollbar to listbox listbox_scrollbar = ttk.Scrollbar(listbox_frame, orient='vertical', command=self.file_listbox.yview) listbox_scrollbar.grid(row=0, column=1, sticky='ns') self.file_listbox.config(yscrollcommand=listbox_scrollbar.set) # Refresh button ttk.Button(file_frame, text="Refresh Files", command=self.refresh_files).grid(row=3, column=0, pady=5) # File information (right side) info_frame = ttk.LabelFrame(main_frame, text="File Information", padding=10) info_frame.grid(row=0, column=1, sticky='nsew', padx=(5,0)) info_frame.rowconfigure(0, weight=1) info_frame.columnconfigure(0, weight=1) # Create Text widget with matching scrollbar style self.file_info_text = tk.Text(info_frame, bg=self.colors['text_bg'], fg=self.colors['text_fg'], insertbackground=self.colors['text_fg'], wrap='word') self.file_info_text.grid(row=0, column=0, sticky='nsew') # Add matching scrollbar to file info info_scrollbar = ttk.Scrollbar(info_frame, orient='vertical', command=self.file_info_text.yview) info_scrollbar.grid(row=0, column=1, sticky='ns') self.file_info_text.config(yscrollcommand=info_scrollbar.set) # Configure the info frame columns info_frame.columnconfigure(0, weight=1) info_frame.columnconfigure(1, weight=0) # Encoding settings settings_frame = ttk.LabelFrame(input_frame, text="Encoding Settings", padding=10) settings_frame.pack(fill='x', padx=10, pady=5) # Codec selection ttk.Label(settings_frame, text="Codec:").grid(row=0, column=0, sticky='w', pady=2) self.codec_var = tk.StringVar(value=self.config['codec']) codec_combo = ttk.Combobox(settings_frame, textvariable=self.codec_var, values=['h265_nvenc', 'vp9', 'av1'], state='readonly') codec_combo.grid(row=0, column=1, sticky='w', padx=5, pady=2) # Preset selection ttk.Label(settings_frame, text="Preset:").grid(row=1, column=0, sticky='w', pady=2) self.preset_var = tk.StringVar(value=self.config['preset']) preset_combo = ttk.Combobox(settings_frame, textvariable=self.preset_var, values=['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7'], state='readonly') preset_combo.grid(row=1, column=1, sticky='w', padx=5, pady=2) # CQ setting ttk.Label(settings_frame, text="CQ:").grid(row=2, column=0, sticky='w', pady=2) self.cq_var = tk.IntVar(value=self.config['cq']) cq_spin = ttk.Spinbox(settings_frame, from_=0, to=51, textvariable=self.cq_var, width=10) cq_spin.grid(row=2, column=1, sticky='w', padx=5, pady=2) # Bitrate settings bitrate_frame = ttk.LabelFrame(settings_frame, text="Bitrate Settings", padding=5) bitrate_frame.grid(row=3, column=0, columnspan=2, sticky='ew', pady=10) self.auto_bitrate_var = tk.BooleanVar(value=self.config['auto_bitrate']) ttk.Checkbutton(bitrate_frame, text="Auto-detect bitrate cap", variable=self.auto_bitrate_var).grid(row=0, column=0, sticky='w') ttk.Label(bitrate_frame, text="Manual bitrate cap (Mbps):").grid(row=1, column=0, sticky='w', pady=2) self.manual_bitrate_var = tk.DoubleVar(value=0) manual_bitrate_entry = ttk.Entry(bitrate_frame, textvariable=self.manual_bitrate_var, width=10) manual_bitrate_entry.grid(row=1, column=1, padx=5, pady=2) # Action buttons button_frame = ttk.Frame(input_frame) button_frame.pack(fill='x', padx=10, pady=10) ttk.Button(button_frame, text="Add to Queue", command=self.add_to_queue).pack(side='left', padx=5) ttk.Button(button_frame, text="Preview (1 min)", command=self.preview_encode).pack(side='left', padx=5) ttk.Button(button_frame, text="Save Settings", command=self.save_config).pack(side='right', padx=5) def setup_render_tab(self): render_frame = ttk.Frame(self.notebook) self.notebook.add(render_frame, text="Render") # Progress section progress_frame = ttk.LabelFrame(render_frame, text="Render Progress", padding=10) progress_frame.pack(fill='x', padx=10, pady=5) self.progress_var = tk.DoubleVar() self.progress_bar = ttk.Progressbar(progress_frame, variable=self.progress_var, maximum=100) self.progress_bar.pack(fill='x', pady=5) self.progress_label = ttk.Label(progress_frame, text="Ready") self.progress_label.pack() # Control buttons control_frame = ttk.Frame(progress_frame) control_frame.pack(pady=10) self.start_button = ttk.Button(control_frame, text="Start Rendering", command=self.start_rendering) self.start_button.pack(side='left', padx=5) self.pause_button = ttk.Button(control_frame, text="Pause", command=self.pause_rendering, state='disabled') self.pause_button.pack(side='left', padx=5) self.stop_button = ttk.Button(control_frame, text="Stop", command=self.stop_rendering, state='disabled') self.stop_button.pack(side='left', padx=5) # Queue section queue_frame = ttk.LabelFrame(render_frame, text="Render Queue", padding=10) queue_frame.pack(fill='both', expand=True, padx=10, pady=5) self.queue_tree = ttk.Treeview(queue_frame, columns=('Status', 'Progress'), show='tree headings') self.queue_tree.heading('#0', text='File') self.queue_tree.heading('Status', text='Status') self.queue_tree.heading('Progress', text='Progress') self.queue_tree.pack(fill='both', expand=True) # Log output log_frame = ttk.LabelFrame(render_frame, text="FFmpeg Output", padding=10) log_frame.pack(fill='both', expand=True, padx=10, pady=5) # Create frame for log text with consistent scrollbar log_text_frame = ttk.Frame(log_frame) log_text_frame.pack(fill='both', expand=True) log_text_frame.rowconfigure(0, weight=1) log_text_frame.columnconfigure(0, weight=1) self.log_text = tk.Text(log_text_frame, bg=self.colors['text_bg'], fg=self.colors['text_fg'], insertbackground=self.colors['text_fg'], wrap='word') self.log_text.grid(row=0, column=0, sticky='nsew') # Add matching scrollbar log_scrollbar = ttk.Scrollbar(log_text_frame, orient='vertical', command=self.log_text.yview) log_scrollbar.grid(row=0, column=1, sticky='ns') self.log_text.config(yscrollcommand=log_scrollbar.set) def setup_qc_tab(self): qc_frame = ttk.Frame(self.notebook) self.notebook.add(qc_frame, text="QC") # File selection for QC qc_file_frame = ttk.LabelFrame(qc_frame, text="QC File Selection", padding=10) qc_file_frame.pack(fill='x', padx=10, pady=5) ttk.Label(qc_file_frame, text="Source File:").grid(row=0, column=0, sticky='w', pady=2) self.qc_source_var = tk.StringVar() ttk.Entry(qc_file_frame, textvariable=self.qc_source_var, width=50).grid(row=0, column=1, padx=5, pady=2) ttk.Button(qc_file_frame, text="Browse", command=self.browse_qc_source).grid(row=0, column=2, padx=5, pady=2) ttk.Label(qc_file_frame, text="Output File:").grid(row=1, column=0, sticky='w', pady=2) self.qc_output_var = tk.StringVar() ttk.Entry(qc_file_frame, textvariable=self.qc_output_var, width=50).grid(row=1, column=1, padx=5, pady=2) ttk.Button(qc_file_frame, text="Browse", command=self.browse_qc_output).grid(row=1, column=2, padx=5, pady=2) # QC controls qc_control_frame = ttk.Frame(qc_frame) qc_control_frame.pack(fill='x', padx=10, pady=5) ttk.Button(qc_control_frame, text="Run QC Check", command=self.run_qc_check).pack(side='left', padx=5) ttk.Button(qc_control_frame, text="Compare Frames", command=self.compare_frames).pack(side='left', padx=5) ttk.Button(qc_control_frame, text="Export Report", command=self.export_qc_report).pack(side='left', padx=5) # Frame comparison viewer comparison_frame = ttk.LabelFrame(qc_frame, text="Frame Comparison", padding=10) comparison_frame.pack(fill='both', expand=True, padx=10, pady=5) # Frame navigation nav_frame = ttk.Frame(comparison_frame) nav_frame.pack(fill='x', pady=5) ttk.Button(nav_frame, text="Previous Frame", command=self.prev_frame).pack(side='left', padx=5) self.frame_label = ttk.Label(nav_frame, text="Frame: 0") self.frame_label.pack(side='left', padx=20) ttk.Button(nav_frame, text="Next Frame", command=self.next_frame).pack(side='left', padx=5) # Frame display display_frame = ttk.Frame(comparison_frame) display_frame.pack(fill='both', expand=True) self.source_canvas = tk.Canvas(display_frame, bg=self.colors['text_bg']) self.source_canvas.pack(side='left', fill='both', expand=True, padx=5) self.output_canvas = tk.Canvas(display_frame, bg=self.colors['text_bg']) self.output_canvas.pack(side='right', fill='both', expand=True, padx=5) # QC results results_frame = ttk.LabelFrame(qc_frame, text="QC Results", padding=10) results_frame.pack(fill='x', padx=10, pady=5) # Create frame for QC results with consistent scrollbar qc_text_frame = ttk.Frame(results_frame) qc_text_frame.pack(fill='both', expand=True) qc_text_frame.rowconfigure(0, weight=1) qc_text_frame.columnconfigure(0, weight=1) self.qc_results_text = tk.Text(qc_text_frame, bg=self.colors['text_bg'], fg=self.colors['text_fg'], insertbackground=self.colors['text_fg'], wrap='word') self.qc_results_text.grid(row=0, column=0, sticky='nsew') # Add matching scrollbar qc_scrollbar = ttk.Scrollbar(qc_text_frame, orient='vertical', command=self.qc_results_text.yview) qc_scrollbar.grid(row=0, column=1, sticky='ns') self.qc_results_text.config(yscrollcommand=qc_scrollbar.set) def browse_input(self): # Use current path if it exists, otherwise use a reasonable default initial_dir = self.input_path_var.get() if os.path.exists(self.input_path_var.get()) else os.path.expanduser("~") path = filedialog.askdirectory(initialdir=initial_dir, title="Select Input Directory") if path: self.input_path_var.set(path) self.config['input_path'] = path # Update config self.refresh_files() def refresh_files(self): self.file_listbox.delete(0, tk.END) input_path = self.input_path_var.get() if not input_path: self.file_listbox.insert(tk.END, "No input directory selected") return if not os.path.exists(input_path): self.file_listbox.insert(tk.END, f"Directory not found: {input_path}") return try: files = os.listdir(input_path) video_files = [f for f in files if f.lower().endswith(('.mp4', '.avi', '.mkv', '.mov', '.wmv', '.m4v', '.flv', '.webm'))] if not video_files: self.file_listbox.insert(tk.END, "No video files found in directory") else: for file in sorted(video_files): self.file_listbox.insert(tk.END, file) except PermissionError: self.file_listbox.insert(tk.END, "Permission denied accessing directory") except Exception as e: self.file_listbox.insert(tk.END, f"Error reading directory: {str(e)}") def on_file_select(self, event): selection = self.file_listbox.curselection() if selection: filename = self.file_listbox.get(selection[0]) # Check if it's an actual file or an error/status message if filename and not filename.startswith(("No ", "Directory not found", "Permission denied", "Error reading")): self.update_file_info(filename) else: # Clear file info if it's not a real file self.file_info_text.delete(1.0, tk.END) self.file_info_text.insert(1.0, "Select a valid video file to see information") def update_file_info(self, filename): filepath = os.path.join(self.input_path_var.get(), filename) if os.path.exists(filepath): try: probe = ffmpeg.probe(filepath) video_info = next(s for s in probe['streams'] if s['codec_type'] == 'video') info_text = f"File: {filename}\n" info_text += f"Duration: {float(probe['format']['duration']):.2f} seconds\n" info_text += f"Resolution: {video_info['width']}x{video_info['height']}\n" info_text += f"Codec: {video_info['codec_name']}\n" info_text += f"Bitrate: {int(probe['format']['bit_rate']) / 1000000:.2f} Mbps\n" self.file_info_text.delete(1.0, tk.END) self.file_info_text.insert(1.0, info_text) except Exception as e: self.file_info_text.delete(1.0, tk.END) self.file_info_text.insert(1.0, f"Error reading file: {str(e)}") def add_to_queue(self): selection = self.file_listbox.curselection() if selection: filename = self.file_listbox.get(selection[0]) self.render_queue.append({ 'filename': filename, 'status': 'Queued', 'progress': 0 }) self.update_queue_display() def preview_encode(self): selection = self.file_listbox.curselection() if selection: filename = self.file_listbox.get(selection[0]) self.encode_file(filename, preview=True) def start_rendering(self): if self.render_queue and not self.is_rendering: self.is_rendering = True self.start_button.config(state='disabled') self.pause_button.config(state='normal') self.stop_button.config(state='normal') # Start rendering thread render_thread = threading.Thread(target=self.render_queue_worker) render_thread.daemon = True render_thread.start() def render_queue_worker(self): while self.render_queue and self.is_rendering: job = self.render_queue[0] self.encode_file(job['filename']) if self.render_queue: self.render_queue.pop(0) self.update_queue_display() self.is_rendering = False self.start_button.config(state='normal') self.pause_button.config(state='disabled') self.stop_button.config(state='disabled') def encode_file(self, filename, preview=False): input_path = os.path.join(self.input_path_var.get(), filename) output_filename = f"encoded_{filename}" output_path = os.path.join(self.config['output_path'], output_filename) # Build FFmpeg command cmd = [ 'ffmpeg', '-i', input_path, '-c:v', self.codec_var.get(), '-preset', self.preset_var.get(), '-cq', str(self.cq_var.get()) ] if preview: cmd.extend(['-t', str(self.config['preview_duration'])]) cmd.append(output_path) try: process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, bufsize=1) self.current_process = process for line in process.stdout: self.log_text.insert(tk.END, line) self.log_text.see(tk.END) self.root.update_idletasks() process.wait() except Exception as e: messagebox.showerror("Error", f"Encoding failed: {str(e)}") def pause_rendering(self): if self.current_process: self.current_process.terminate() def stop_rendering(self): self.is_rendering = False if self.current_process: self.current_process.terminate() def update_queue_display(self): for item in self.queue_tree.get_children(): self.queue_tree.delete(item) for i, job in enumerate(self.render_queue): self.queue_tree.insert('', 'end', text=job['filename'], values=(job['status'], f"{job['progress']}%")) def browse_qc_source(self): filename = filedialog.askopenfilename( title="Select Source File", filetypes=[("Video files", "*.mp4 *.avi *.mkv *.mov *.wmv")] ) if filename: self.qc_source_var.set(filename) def browse_qc_output(self): filename = filedialog.askopenfilename( title="Select Output File", filetypes=[("Video files", "*.mp4 *.avi *.mkv *.mov *.wmv")] ) if filename: self.qc_output_var.set(filename) def run_qc_check(self): source = self.qc_source_var.get() output = self.qc_output_var.get() if not source or not output: messagebox.showerror("Error", "Please select both source and output files") return # Run QC check (placeholder for check_files.py functionality) self.qc_results_text.delete(1.0, tk.END) self.qc_results_text.insert(1.0, "QC check completed...\n") def compare_frames(self): source = self.qc_source_var.get() output = self.qc_output_var.get() if not source or not output: messagebox.showerror("Error", "Please select both source and output files") return # Load frames for comparison self.load_comparison_frames() def load_comparison_frames(self): # Placeholder for frame loading and display pass def prev_frame(self): # Placeholder for frame navigation pass def next_frame(self): # Placeholder for frame navigation pass def export_qc_report(self): # Placeholder for QC report export pass def save_config(self): self.config.update({ 'input_path': self.input_path_var.get(), 'codec': self.codec_var.get(), 'preset': self.preset_var.get(), 'cq': self.cq_var.get(), 'auto_bitrate': self.auto_bitrate_var.get() }) with open('vodkeeper_config.json', 'w') as f: json.dump(self.config, f, indent=2) messagebox.showinfo("Success", "Configuration saved") def load_config(self): try: with open('vodkeeper_config.json', 'r') as f: config = json.load(f) self.config.update(config) except FileNotFoundError: pass def main(): root = tk.Tk() sv_ttk.use_dark_theme() app = VodKeeper(root) root.mainloop() if __name__ == "__main__": main()