Files
video_concat/video_concat.py
youfu f8499e9668 v3: drag-to-reorder and DPI scaling
- Replace the Move Up/Down buttons with click-and-drag reordering in the list
- DPI-aware sizing: derive a base font from screen DPI and scale Tk
- Listbox now fills and expands; entry and labels stretch to full width
- Re-check the name conflict when the output directory changes too
- Streamlined three-section layout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:27:18 +00:00

195 lines
8.1 KiB
Python
Executable File

import os
import subprocess
import threading
import time
import shutil
import tkinter as tk
from tkinter import filedialog, messagebox
import customtkinter as ctk
# Set appearance and color theme
ctk.set_appearance_mode("system") # "light" | "dark" | "system"
ctk.set_default_color_theme("blue") # "blue" | "green" | "dark-blue"
class VideoConcatenatorApp(ctk.CTk):
def __init__(self):
super().__init__()
self.title("Video Concatenator")
self.geometry("800x800")
# DPI scaling
dpi = self.winfo_fpixels('1i') / 72
self.tk.call('tk', 'scaling', dpi)
base_font = int(12 * dpi)
# State
self.selected_files = []
self.output_file_name = "output.mp4"
self.delete_original = tk.BooleanVar(value=False)
self.output_dir = ""
self.ffmpeg_path = shutil.which("ffmpeg")
# 1. Select and Order Videos
self.label1 = ctk.CTkLabel(self, text="1. Select and Order Videos", font=(None, base_font+4))
self.label1.pack(pady=(10,5))
self.select_btn = ctk.CTkButton(self, text="Select Videos", command=self.select_videos, font=(None, base_font))
self.select_btn.pack(pady=5)
# Listbox for files with drag-and-drop
self.listbox_frame = ctk.CTkFrame(self)
self.listbox_frame.pack(pady=5, fill="both", expand=True)
self.files_listbox = tk.Listbox(self.listbox_frame, selectmode=tk.SINGLE)
self.files_listbox.pack(padx=5, pady=5, fill="both", expand=True)
self.files_listbox.bind('<Button-1>', self.on_drag_start)
self.files_listbox.bind('<B1-Motion>', self.on_drag_motion)
# 2. Name the Output File
self.label2 = ctk.CTkLabel(self, text="2. Name the Output File", font=(None, base_font+4))
self.label2.pack(pady=(10,5))
self.output_name_entry = ctk.CTkEntry(self, width=0)
self.output_name_entry.pack(pady=5, fill="x", expand=True)
self.output_name_entry.insert(0, self.output_file_name)
self.output_name_entry.bind("<KeyRelease>", self.check_name_conflict)
self.name_warning_label = ctk.CTkLabel(self, text="", text_color="red", font=(None, base_font))
self.name_warning_label.pack(pady=(0,5))
# 3. Select Output Directory
self.label3 = ctk.CTkLabel(self, text="3. Select Output Directory", font=(None, base_font+4))
self.label3.pack(pady=(10,5))
self.output_dir_btn = ctk.CTkButton(self, text="Choose Output Directory", command=self.select_output_dir, font=(None, base_font))
self.output_dir_btn.pack(pady=5)
self.output_dir_label = ctk.CTkLabel(self, text="", text_color="blue", font=(None, base_font))
self.output_dir_label.pack(pady=5, fill="x", expand=True)
# Delete original option
self.delete_original_check = ctk.CTkCheckBox(
self,
text="Delete Original Files After Concatenation",
variable=self.delete_original,
font=(None, base_font)
)
self.delete_original_check.pack(pady=5)
# Concatenate Button
self.concat_btn = ctk.CTkButton(self, text="Concatenate Videos", command=self.start_concatenation, state="disabled", font=(None, base_font+2))
self.concat_btn.pack(pady=10)
# Time Left Label
self.time_left_label = ctk.CTkLabel(self, text="", font=(None, base_font+2), text_color="green")
self.time_left_label.pack(pady=5)
# Disable if ffmpeg missing
if not self.ffmpeg_path:
messagebox.showerror(
"Missing Dependency",
"ffmpeg not found. Please install and add it to PATH."
)
self.concat_btn.configure(state="disabled")
def select_videos(self):
files = filedialog.askopenfilenames(
filetypes=[("Video Files", "*.mp4;*.mov;*.mkv;*.avi;*.flv")]
)
if files:
self.selected_files = list(files)
self.refresh_listbox()
# autofill name
first = os.path.basename(self.selected_files[0])
default = os.path.splitext(first)[0] + ".mp4"
self.output_name_entry.delete(0, tk.END)
self.output_name_entry.insert(0, default)
self.check_name_conflict()
def refresh_listbox(self):
self.files_listbox.delete(0, tk.END)
for f in self.selected_files:
self.files_listbox.insert(tk.END, f)
def on_drag_start(self, event):
self.drag_start = self.files_listbox.nearest(event.y)
def on_drag_motion(self, event):
i = self.files_listbox.nearest(event.y)
if i != self.drag_start:
# swap
self.selected_files[self.drag_start], self.selected_files[i] = (
self.selected_files[i], self.selected_files[self.drag_start]
)
self.refresh_listbox()
self.files_listbox.select_set(i)
self.drag_start = i
def select_output_dir(self):
d = filedialog.askdirectory()
if d:
self.output_dir = d
self.output_dir_label.configure(text=d)
self.check_name_conflict()
def _has_name_conflict(self):
name = self.output_name_entry.get().strip()
base = os.path.splitext(name)[0]
return any(os.path.splitext(os.path.basename(f))[0] == base for f in self.selected_files)
def check_name_conflict(self, event=None):
conflict = self._has_name_conflict()
if conflict:
self.name_warning_label.configure(text="⚠️ Name matches an original file.")
self.concat_btn.configure(state="disabled")
else:
self.name_warning_label.configure(text="")
if self.selected_files and self.output_dir and self.ffmpeg_path:
self.concat_btn.configure(state="normal")
def start_concatenation(self):
self.concat_btn.configure(state="disabled")
self.time_left_label.configure(text="Starting...")
threading.Thread(target=self.concatenate_videos).start()
def concatenate_videos(self):
# checks
if not self.selected_files:
messagebox.showerror("Error", "No videos selected.")
return
if not self.output_dir:
messagebox.showerror("Error", "Output directory not selected.")
return
name = self.output_name_entry.get().strip() or self.output_file_name
if not name.lower().endswith('.mp4'):
name += '.mp4'
outp = os.path.join(self.output_dir, name)
list_path = os.path.join(self.output_dir, 'file_list.txt')
with open(list_path, 'w', encoding='utf-8') as f:
for file in self.selected_files:
p = file.replace('\\', '/').replace("'", "'\\''")
f.write(f"file '{p}'\n")
cmd = [self.ffmpeg_path, '-y', '-f', 'concat', '-safe', '0', '-i', 'file_list.txt', '-c:v', 'copy', '-c:a', 'copy', outp]
# estimated time
for sec in range(len(self.selected_files)*5):
time.sleep(1)
self.time_left_label.configure(text=f"Est. time left: {len(self.selected_files)*5 - sec}s")
self.update_idletasks()
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=self.output_dir)
if proc.returncode != 0:
self.time_left_label.configure(text="Error during concatenation.")
messagebox.showerror("FFmpeg error", proc.stderr)
else:
self.time_left_label.configure(text="Done!")
messagebox.showinfo("Success", f"Output saved to: {outp}")
if self.delete_original.get():
for f in self.selected_files:
try: os.remove(f)
except: pass
messagebox.showinfo("Info", "Original files deleted.")
if os.path.exists(list_path): os.remove(list_path)
self.concat_btn.configure(state="normal")
if __name__ == '__main__':
app = VideoConcatenatorApp()
app.mainloop()