v2.1: rewrite on CustomTkinter with safer ffmpeg handling
- Migrate the UI from tkinter to customtkinter (themed, system appearance) - Detect ffmpeg via shutil.which; warn and disable concat if it's missing - Auto-fill the output name from the first selected file - Inline output-name conflict detection against the source files, with a live red warning and the button disabled while a conflict exists - Escape paths in the concat list file (quotes/backslashes), write as UTF-8 - Run ffmpeg with explicit -c:v copy / -c:a copy and surface stderr on error - Re-enable the button and clean up file_list.txt on every exit path Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
335
video_concat.py
335
video_concat.py
@@ -1,174 +1,257 @@
|
|||||||
import tkinter as tk
|
|
||||||
from tkinter import filedialog, messagebox, Listbox
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import shutil
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import filedialog, messagebox
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
class VideoConcatenatorApp:
|
# Set appearance and color theme
|
||||||
def __init__(self, root):
|
ctk.set_appearance_mode("system") # "light" | "dark" | "system"
|
||||||
self.root = root
|
ctk.set_default_color_theme("blue") # "blue" | "green" | "dark-blue"
|
||||||
self.root.title("Video Concatenator")
|
|
||||||
|
class VideoConcatenatorApp(ctk.CTk):
|
||||||
# Selected files
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.title("Video Concatenator (CustomTkinter)")
|
||||||
|
self.geometry("700x700")
|
||||||
|
|
||||||
|
# State
|
||||||
self.selected_files = []
|
self.selected_files = []
|
||||||
self.output_file_name = "output.mp4"
|
self.output_file_name = "output.mp4"
|
||||||
self.delete_original = tk.BooleanVar(value=False)
|
self.delete_original = tk.BooleanVar(value=False)
|
||||||
|
self.output_dir = ""
|
||||||
# Section 1: Select files
|
# detect ffmpeg.exe
|
||||||
self.label1 = tk.Label(root, text="1. Select Video Files", font=("Arial", 12))
|
self.ffmpeg_path = shutil.which("ffmpeg")
|
||||||
self.label1.pack(pady=5)
|
|
||||||
|
# 1. Select Video Files
|
||||||
self.select_btn = tk.Button(root, text="Select Videos", command=self.select_videos)
|
self.label1 = ctk.CTkLabel(self, text="1. Select Video Files", font=("Arial", 14))
|
||||||
|
self.label1.pack(pady=(10,5))
|
||||||
|
self.select_btn = ctk.CTkButton(self, text="Select Videos", command=self.select_videos)
|
||||||
self.select_btn.pack(pady=5)
|
self.select_btn.pack(pady=5)
|
||||||
|
|
||||||
self.files_listbox = Listbox(root, selectmode=tk.SINGLE, width=80, height=10)
|
# Listbox frame
|
||||||
self.files_listbox.pack(pady=5)
|
self.listbox_frame = ctk.CTkFrame(self)
|
||||||
|
self.listbox_frame.pack(pady=5, fill="x", expand=False)
|
||||||
# Section 2: Organize sequence
|
self.files_listbox = tk.Listbox(self.listbox_frame, selectmode=tk.SINGLE, width=80, height=8)
|
||||||
self.label2 = tk.Label(root, text="2. Organize Sequence", font=("Arial", 12))
|
self.files_listbox.pack(padx=5, pady=5, fill="both", expand=True)
|
||||||
self.label2.pack(pady=5)
|
|
||||||
|
# 2. Organize sequence
|
||||||
self.up_btn = tk.Button(root, text="Move Up", command=self.move_up)
|
self.label2 = ctk.CTkLabel(self, text="2. Organize Sequence", font=("Arial", 14))
|
||||||
self.up_btn.pack(pady=5)
|
self.label2.pack(pady=(10,5))
|
||||||
|
self.up_down_frame = ctk.CTkFrame(self)
|
||||||
self.down_btn = tk.Button(root, text="Move Down", command=self.move_down)
|
self.up_down_frame.pack(pady=5)
|
||||||
self.down_btn.pack(pady=5)
|
self.up_btn = ctk.CTkButton(self.up_down_frame, text="Move Up", command=self.move_up)
|
||||||
|
self.up_btn.grid(row=0, column=0, padx=10, pady=5)
|
||||||
# Section 3: Name the output file
|
self.down_btn = ctk.CTkButton(self.up_down_frame, text="Move Down", command=self.move_down)
|
||||||
self.label3 = tk.Label(root, text="3. Name the Output File", font=("Arial", 12))
|
self.down_btn.grid(row=0, column=1, padx=10, pady=5)
|
||||||
self.label3.pack(pady=5)
|
|
||||||
|
# 3. Name the Output File
|
||||||
self.output_name_entry = tk.Entry(root, width=50)
|
self.label3 = ctk.CTkLabel(self, text="3. Name the Output File", font=("Arial", 14))
|
||||||
self.output_name_entry.insert(0, "output.mp4")
|
self.label3.pack(pady=(10,5))
|
||||||
|
self.output_name_entry = ctk.CTkEntry(self, width=300)
|
||||||
|
self.output_name_entry.insert(0, self.output_file_name)
|
||||||
self.output_name_entry.pack(pady=5)
|
self.output_name_entry.pack(pady=5)
|
||||||
|
|
||||||
# Section 4: Choose output directory
|
# Inline name-conflict warning
|
||||||
self.label4 = tk.Label(root, text="4. Select Output Directory", font=("Arial", 12))
|
self.name_warning_label = ctk.CTkLabel(
|
||||||
self.label4.pack(pady=5)
|
self,
|
||||||
|
text="",
|
||||||
self.output_dir_btn = tk.Button(root, text="Choose Output Directory", command=self.select_output_dir)
|
text_color="red",
|
||||||
|
font=("Arial", 12)
|
||||||
|
)
|
||||||
|
self.name_warning_label.pack(pady=(0,5))
|
||||||
|
|
||||||
|
# Bind to update warning and button state on typing
|
||||||
|
self.output_name_entry.bind("<KeyRelease>", self.check_name_conflict)
|
||||||
|
|
||||||
|
# 4. Select Output Directory
|
||||||
|
self.label4 = ctk.CTkLabel(self, text="4. Select Output Directory", font=("Arial", 14))
|
||||||
|
self.label4.pack(pady=(10,5))
|
||||||
|
self.output_dir_btn = ctk.CTkButton(self, text="Choose Output Directory", command=self.select_output_dir)
|
||||||
self.output_dir_btn.pack(pady=5)
|
self.output_dir_btn.pack(pady=5)
|
||||||
|
self.output_dir_label = ctk.CTkLabel(self, text="", text_color="blue")
|
||||||
self.output_dir_label = tk.Label(root, text="", fg="blue")
|
|
||||||
self.output_dir_label.pack(pady=5)
|
self.output_dir_label.pack(pady=5)
|
||||||
|
|
||||||
# Section 5: Delete original files option
|
# 5. Delete original files option
|
||||||
self.delete_original_check = tk.Checkbutton(root, text="Delete Original Files After Concatenation", variable=self.delete_original)
|
self.delete_original_check = ctk.CTkCheckBox(
|
||||||
|
self,
|
||||||
|
text="Delete Original Files After Concatenation",
|
||||||
|
variable=self.delete_original
|
||||||
|
)
|
||||||
self.delete_original_check.pack(pady=5)
|
self.delete_original_check.pack(pady=5)
|
||||||
|
|
||||||
# Concatenate Button
|
# Concatenate Button
|
||||||
self.concat_btn = tk.Button(root, text="Concatenate Videos", command=self.start_concatenation, state=tk.DISABLED)
|
self.concat_btn = ctk.CTkButton(self, text="Concatenate Videos", command=self.start_concatenation, state="disabled")
|
||||||
self.concat_btn.pack(pady=10)
|
self.concat_btn.pack(pady=10)
|
||||||
|
|
||||||
# Time Left Label
|
# Time Left Label
|
||||||
self.time_left_label = tk.Label(root, text="", font=("Arial", 10), fg="green")
|
self.time_left_label = ctk.CTkLabel(self, text="", font=("Arial", 12), text_color="green")
|
||||||
self.time_left_label.pack(pady=5)
|
self.time_left_label.pack(pady=5)
|
||||||
|
|
||||||
|
# If ffmpeg not found, disable concatenation
|
||||||
|
if not self.ffmpeg_path:
|
||||||
|
messagebox.showerror(
|
||||||
|
"Missing Dependency",
|
||||||
|
"Could not find ffmpeg on your system.\n"
|
||||||
|
"Please install ffmpeg and make sure it's on your PATH."
|
||||||
|
)
|
||||||
|
self.concat_btn.configure(state="disabled")
|
||||||
|
|
||||||
def select_videos(self):
|
def select_videos(self):
|
||||||
files = filedialog.askopenfilenames(filetypes=[("Video Files", "*.mp4;*.mov;*.mkv;*.avi;*.flv")])
|
files = filedialog.askopenfilenames(
|
||||||
|
filetypes=[("Video Files", "*.mp4;*.mov;*.mkv;*.avi;*.flv")]
|
||||||
|
)
|
||||||
if files:
|
if files:
|
||||||
self.selected_files = list(files)
|
self.selected_files = list(files)
|
||||||
self.update_files_listbox()
|
self.update_files_listbox()
|
||||||
self.concat_btn.config(state=tk.NORMAL)
|
|
||||||
|
# Auto-fill output name from the first file
|
||||||
|
first_file = os.path.basename(self.selected_files[0])
|
||||||
|
default_name = os.path.splitext(first_file)[0] + ".mp4"
|
||||||
|
self.output_name_entry.delete(0, tk.END)
|
||||||
|
self.output_name_entry.insert(0, default_name)
|
||||||
|
|
||||||
|
# Re-check for conflicts and button state
|
||||||
|
self.check_name_conflict()
|
||||||
|
|
||||||
|
# Only enable if no conflict and ffmpeg exists
|
||||||
|
if self.ffmpeg_path and not self._has_name_conflict():
|
||||||
|
self.concat_btn.configure(state="normal")
|
||||||
|
|
||||||
def update_files_listbox(self):
|
def update_files_listbox(self):
|
||||||
self.files_listbox.delete(0, tk.END)
|
self.files_listbox.delete(0, tk.END)
|
||||||
for file in self.selected_files:
|
for f in self.selected_files:
|
||||||
self.files_listbox.insert(tk.END, file)
|
self.files_listbox.insert(tk.END, f)
|
||||||
|
|
||||||
def move_up(self):
|
def move_up(self):
|
||||||
selected = self.files_listbox.curselection()
|
sel = self.files_listbox.curselection()
|
||||||
if selected and selected[0] > 0:
|
if sel and sel[0] > 0:
|
||||||
index = selected[0]
|
i = sel[0]
|
||||||
self.selected_files[index], self.selected_files[index - 1] = self.selected_files[index - 1], self.selected_files[index]
|
self.selected_files[i-1], self.selected_files[i] = self.selected_files[i], self.selected_files[i-1]
|
||||||
self.update_files_listbox()
|
self.update_files_listbox()
|
||||||
self.files_listbox.select_set(index - 1)
|
self.files_listbox.select_set(i-1)
|
||||||
|
|
||||||
def move_down(self):
|
def move_down(self):
|
||||||
selected = self.files_listbox.curselection()
|
sel = self.files_listbox.curselection()
|
||||||
if selected and selected[0] < len(self.selected_files) - 1:
|
if sel and sel[0] < len(self.selected_files)-1:
|
||||||
index = selected[0]
|
i = sel[0]
|
||||||
self.selected_files[index], self.selected_files[index + 1] = self.selected_files[index + 1], self.selected_files[index]
|
self.selected_files[i+1], self.selected_files[i] = self.selected_files[i], self.selected_files[i+1]
|
||||||
self.update_files_listbox()
|
self.update_files_listbox()
|
||||||
self.files_listbox.select_set(index + 1)
|
self.files_listbox.select_set(i+1)
|
||||||
|
|
||||||
def select_output_dir(self):
|
def select_output_dir(self):
|
||||||
directory = filedialog.askdirectory()
|
d = filedialog.askdirectory()
|
||||||
if directory:
|
if d:
|
||||||
self.output_dir = directory
|
self.output_dir = d
|
||||||
self.output_dir_label.config(text=directory)
|
self.output_dir_label.configure(text=d)
|
||||||
|
|
||||||
|
def _has_name_conflict(self):
|
||||||
|
name = self.output_name_entry.get().strip()
|
||||||
|
base_name = os.path.splitext(name)[0]
|
||||||
|
return any(
|
||||||
|
os.path.splitext(os.path.basename(f))[0] == base_name
|
||||||
|
for f in self.selected_files
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_name_conflict(self, event=None):
|
||||||
|
"""
|
||||||
|
Show warning and disable/enable the concat button based on name conflict.
|
||||||
|
"""
|
||||||
|
conflict = self._has_name_conflict()
|
||||||
|
if conflict:
|
||||||
|
self.name_warning_label.configure(
|
||||||
|
text="⚠️ This name matches one of your originals!"
|
||||||
|
)
|
||||||
|
self.concat_btn.configure(state="disabled")
|
||||||
|
else:
|
||||||
|
self.name_warning_label.configure(text="")
|
||||||
|
# Only re-enable if videos are selected and ffmpeg exists
|
||||||
|
if self.selected_files and self.ffmpeg_path:
|
||||||
|
self.concat_btn.configure(state="normal")
|
||||||
|
|
||||||
def start_concatenation(self):
|
def start_concatenation(self):
|
||||||
# Disable button to prevent re-clicking
|
self.concat_btn.configure(state="disabled")
|
||||||
self.concat_btn.config(state=tk.DISABLED)
|
self.time_left_label.configure(text="Starting concatenation...")
|
||||||
self.time_left_label.config(text="Starting concatenation...")
|
|
||||||
|
|
||||||
# Run the concatenation process in a separate thread
|
|
||||||
threading.Thread(target=self.concatenate_videos).start()
|
threading.Thread(target=self.concatenate_videos).start()
|
||||||
|
|
||||||
def concatenate_videos(self):
|
def concatenate_videos(self):
|
||||||
|
# sanity checks
|
||||||
if not self.selected_files:
|
if not self.selected_files:
|
||||||
messagebox.showerror("Error", "No videos selected.")
|
messagebox.showerror("Error", "No videos selected.")
|
||||||
|
self.concat_btn.configure(state="normal")
|
||||||
return
|
return
|
||||||
|
if not self.output_dir:
|
||||||
if not hasattr(self, "output_dir"):
|
|
||||||
messagebox.showerror("Error", "Output directory not selected.")
|
messagebox.showerror("Error", "Output directory not selected.")
|
||||||
|
self.concat_btn.configure(state="normal")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get output file name
|
# get output file name
|
||||||
self.output_file_name = self.output_name_entry.get().strip()
|
outname = self.output_name_entry.get().strip()
|
||||||
if not self.output_file_name:
|
if not outname:
|
||||||
messagebox.showerror("Error", "Output file name cannot be empty.")
|
messagebox.showerror("Error", "Output file name cannot be empty.")
|
||||||
|
self.concat_btn.configure(state="normal")
|
||||||
return
|
return
|
||||||
|
if not outname.lower().endswith(".mp4"):
|
||||||
# Ensure the output file name ends with .mp4
|
outname += ".mp4"
|
||||||
if not self.output_file_name.endswith(".mp4"):
|
|
||||||
self.output_file_name += ".mp4"
|
output_file = os.path.join(self.output_dir, outname)
|
||||||
|
|
||||||
# Create file list for FFmpeg
|
# build the file list
|
||||||
temp_file_list = os.path.join(self.output_dir, "file_list.txt")
|
list_path = os.path.join(self.output_dir, "file_list.txt")
|
||||||
with open(temp_file_list, "w") as f:
|
with open(list_path, "w", encoding="utf-8") as f:
|
||||||
for file in self.selected_files:
|
for file in self.selected_files:
|
||||||
f.write(f"file '{file}'\n")
|
path = file.replace("\\", "/").replace("'", r"'\\''")
|
||||||
|
f.write(f"file '{path}'\n")
|
||||||
# Output file path
|
|
||||||
output_file = os.path.join(self.output_dir, self.output_file_name)
|
cmd = [
|
||||||
|
self.ffmpeg_path,
|
||||||
# FFmpeg command
|
"-y",
|
||||||
command = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-f", "concat",
|
"-f", "concat",
|
||||||
"-safe", "0",
|
"-safe", "0",
|
||||||
"-i", temp_file_list,
|
"-i", "file_list.txt",
|
||||||
"-c", "copy",
|
"-c:v", "copy",
|
||||||
|
"-c:a", "copy",
|
||||||
output_file
|
output_file
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
# estimate time
|
||||||
total_time = len(self.selected_files) * 5 # Estimate: ~5 seconds per video
|
estimate = len(self.selected_files) * 5
|
||||||
for i in range(total_time):
|
for i in range(estimate):
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
self.time_left_label.config(text=f"Estimated time left: {total_time - i}s")
|
self.time_left_label.configure(text=f"Estimated time left: {estimate - i}s")
|
||||||
self.root.update_idletasks()
|
self.update_idletasks()
|
||||||
|
|
||||||
subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
# run ffmpeg
|
||||||
self.time_left_label.config(text="Concatenation completed!")
|
proc = subprocess.run(
|
||||||
messagebox.showinfo("Success", f"Videos concatenated successfully! Output: {output_file}")
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
# Optionally delete original files
|
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.strip())
|
||||||
|
else:
|
||||||
|
self.time_left_label.configure(text="Concatenation completed!")
|
||||||
|
messagebox.showinfo("Success", f"Output: {output_file}")
|
||||||
|
|
||||||
if self.delete_original.get():
|
if self.delete_original.get():
|
||||||
for file in self.selected_files:
|
for file in self.selected_files:
|
||||||
os.remove(file)
|
try:
|
||||||
|
os.remove(file)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
messagebox.showinfo("Info", "Original files have been deleted.")
|
messagebox.showinfo("Info", "Original files have been deleted.")
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
self.time_left_label.config(text="Error during concatenation.")
|
# cleanup & re-enable
|
||||||
messagebox.showerror("Error", "Failed to concatenate videos.")
|
if os.path.exists(list_path):
|
||||||
finally:
|
os.remove(list_path)
|
||||||
os.remove(temp_file_list)
|
self.concat_btn.configure(state="normal")
|
||||||
self.concat_btn.config(state=tk.NORMAL)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
root = tk.Tk()
|
app = VideoConcatenatorApp()
|
||||||
app = VideoConcatenatorApp(root)
|
app.mainloop()
|
||||||
root.mainloop()
|
|
||||||
Reference in New Issue
Block a user