Files
VodKeeper/vodkeeper.py
T
2025-07-02 19:22:59 -06:00

577 lines
25 KiB
Python

import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
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'])])
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))
self.file_listbox = tk.Listbox(file_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=2, column=0, columnspan=3, sticky='nsew', pady=2)
self.file_listbox.bind('<<ListboxSelect>>', self.on_file_select)
# 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)
self.file_info_text = scrolledtext.ScrolledText(info_frame,
bg=self.colors['text_bg'], fg=self.colors['text_fg'],
insertbackground=self.colors['text_fg'])
self.file_info_text.grid(row=0, column=0, sticky='nsew')
# 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)
self.log_text = scrolledtext.ScrolledText(log_frame, height=10,
bg=self.colors['text_bg'], fg=self.colors['text_fg'],
insertbackground=self.colors['text_fg'])
self.log_text.pack(fill='both', expand=True)
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)
self.qc_results_text = scrolledtext.ScrolledText(results_frame, height=6,
bg=self.colors['text_bg'], fg=self.colors['text_fg'],
insertbackground=self.colors['text_fg'])
self.qc_results_text.pack(fill='both', expand=True)
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()