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>
This commit is contained in:
229
video_concat.py
229
video_concat.py
@@ -14,88 +14,76 @@ ctk.set_default_color_theme("blue") # "blue" | "green" | "dark-blue"
|
||||
class VideoConcatenatorApp(ctk.CTk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title("Video Concatenator (CustomTkinter)")
|
||||
self.geometry("700x700")
|
||||
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 = ""
|
||||
# detect ffmpeg.exe
|
||||
self.ffmpeg_path = shutil.which("ffmpeg")
|
||||
|
||||
# 1. Select Video Files
|
||||
self.label1 = ctk.CTkLabel(self, text="1. Select Video Files", font=("Arial", 14))
|
||||
# 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)
|
||||
self.select_btn = ctk.CTkButton(self, text="Select Videos", command=self.select_videos, font=(None, base_font))
|
||||
self.select_btn.pack(pady=5)
|
||||
|
||||
# Listbox frame
|
||||
# Listbox for files with drag-and-drop
|
||||
self.listbox_frame = ctk.CTkFrame(self)
|
||||
self.listbox_frame.pack(pady=5, fill="x", expand=False)
|
||||
self.files_listbox = tk.Listbox(self.listbox_frame, selectmode=tk.SINGLE, width=80, height=8)
|
||||
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. Organize sequence
|
||||
self.label2 = ctk.CTkLabel(self, text="2. Organize Sequence", font=("Arial", 14))
|
||||
# 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.up_down_frame = ctk.CTkFrame(self)
|
||||
self.up_down_frame.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)
|
||||
self.down_btn = ctk.CTkButton(self.up_down_frame, text="Move Down", command=self.move_down)
|
||||
self.down_btn.grid(row=0, column=1, padx=10, pady=5)
|
||||
|
||||
# 3. Name the Output File
|
||||
self.label3 = ctk.CTkLabel(self, text="3. Name the Output File", font=("Arial", 14))
|
||||
self.label3.pack(pady=(10,5))
|
||||
self.output_name_entry = ctk.CTkEntry(self, width=300)
|
||||
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.pack(pady=5)
|
||||
|
||||
# Inline name-conflict warning
|
||||
self.name_warning_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="",
|
||||
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_label = ctk.CTkLabel(self, text="", text_color="blue")
|
||||
self.output_dir_label.pack(pady=5)
|
||||
self.name_warning_label = ctk.CTkLabel(self, text="", text_color="red", font=(None, base_font))
|
||||
self.name_warning_label.pack(pady=(0,5))
|
||||
|
||||
# 5. Delete original files option
|
||||
# 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
|
||||
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")
|
||||
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=("Arial", 12), text_color="green")
|
||||
self.time_left_label = ctk.CTkLabel(self, text="", font=(None, base_font+2), text_color="green")
|
||||
self.time_left_label.pack(pady=5)
|
||||
|
||||
# If ffmpeg not found, disable concatenation
|
||||
# Disable if ffmpeg missing
|
||||
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."
|
||||
"ffmpeg not found. Please install and add it to PATH."
|
||||
)
|
||||
self.concat_btn.configure(state="disabled")
|
||||
|
||||
@@ -105,153 +93,102 @@ class VideoConcatenatorApp(ctk.CTk):
|
||||
)
|
||||
if files:
|
||||
self.selected_files = list(files)
|
||||
self.update_files_listbox()
|
||||
|
||||
# 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.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_name)
|
||||
|
||||
# Re-check for conflicts and button state
|
||||
self.output_name_entry.insert(0, default)
|
||||
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 refresh_listbox(self):
|
||||
self.files_listbox.delete(0, tk.END)
|
||||
for f in self.selected_files:
|
||||
self.files_listbox.insert(tk.END, f)
|
||||
|
||||
def move_up(self):
|
||||
sel = self.files_listbox.curselection()
|
||||
if sel and sel[0] > 0:
|
||||
i = sel[0]
|
||||
self.selected_files[i-1], self.selected_files[i] = self.selected_files[i], self.selected_files[i-1]
|
||||
self.update_files_listbox()
|
||||
self.files_listbox.select_set(i-1)
|
||||
def on_drag_start(self, event):
|
||||
self.drag_start = self.files_listbox.nearest(event.y)
|
||||
|
||||
def move_down(self):
|
||||
sel = self.files_listbox.curselection()
|
||||
if sel and sel[0] < len(self.selected_files)-1:
|
||||
i = sel[0]
|
||||
self.selected_files[i+1], self.selected_files[i] = self.selected_files[i], self.selected_files[i+1]
|
||||
self.update_files_listbox()
|
||||
self.files_listbox.select_set(i+1)
|
||||
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_name = os.path.splitext(name)[0]
|
||||
return any(
|
||||
os.path.splitext(os.path.basename(f))[0] == base_name
|
||||
for f in self.selected_files
|
||||
)
|
||||
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):
|
||||
"""
|
||||
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.name_warning_label.configure(text="⚠️ Name matches an original file.")
|
||||
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:
|
||||
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 concatenation...")
|
||||
self.time_left_label.configure(text="Starting...")
|
||||
threading.Thread(target=self.concatenate_videos).start()
|
||||
|
||||
def concatenate_videos(self):
|
||||
# sanity checks
|
||||
# checks
|
||||
if not self.selected_files:
|
||||
messagebox.showerror("Error", "No videos selected.")
|
||||
self.concat_btn.configure(state="normal")
|
||||
return
|
||||
if not self.output_dir:
|
||||
messagebox.showerror("Error", "Output directory not selected.")
|
||||
self.concat_btn.configure(state="normal")
|
||||
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)
|
||||
|
||||
# get output file name
|
||||
outname = self.output_name_entry.get().strip()
|
||||
if not outname:
|
||||
messagebox.showerror("Error", "Output file name cannot be empty.")
|
||||
self.concat_btn.configure(state="normal")
|
||||
return
|
||||
if not outname.lower().endswith(".mp4"):
|
||||
outname += ".mp4"
|
||||
|
||||
output_file = os.path.join(self.output_dir, outname)
|
||||
|
||||
# build the file list
|
||||
list_path = os.path.join(self.output_dir, "file_list.txt")
|
||||
with open(list_path, "w", encoding="utf-8") as f:
|
||||
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:
|
||||
path = file.replace("\\", "/").replace("'", r"'\\''")
|
||||
f.write(f"file '{path}'\n")
|
||||
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",
|
||||
output_file
|
||||
]
|
||||
cmd = [self.ffmpeg_path, '-y', '-f', 'concat', '-safe', '0', '-i', 'file_list.txt', '-c:v', 'copy', '-c:a', 'copy', outp]
|
||||
|
||||
# estimate time
|
||||
estimate = len(self.selected_files) * 5
|
||||
for i in range(estimate):
|
||||
# estimated time
|
||||
for sec in range(len(self.selected_files)*5):
|
||||
time.sleep(1)
|
||||
self.time_left_label.configure(text=f"Estimated time left: {estimate - i}s")
|
||||
self.time_left_label.configure(text=f"Est. time left: {len(self.selected_files)*5 - sec}s")
|
||||
self.update_idletasks()
|
||||
|
||||
# run ffmpeg
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
cwd=self.output_dir
|
||||
)
|
||||
|
||||
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.strip())
|
||||
messagebox.showerror("FFmpeg error", proc.stderr)
|
||||
else:
|
||||
self.time_left_label.configure(text="Concatenation completed!")
|
||||
messagebox.showinfo("Success", f"Output: {output_file}")
|
||||
|
||||
self.time_left_label.configure(text="Done!")
|
||||
messagebox.showinfo("Success", f"Output saved to: {outp}")
|
||||
if self.delete_original.get():
|
||||
for file in self.selected_files:
|
||||
try:
|
||||
os.remove(file)
|
||||
except OSError:
|
||||
pass
|
||||
messagebox.showinfo("Info", "Original files have been deleted.")
|
||||
|
||||
# cleanup & re-enable
|
||||
if os.path.exists(list_path):
|
||||
os.remove(list_path)
|
||||
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__":
|
||||
if __name__ == '__main__':
|
||||
app = VideoConcatenatorApp()
|
||||
app.mainloop()
|
||||
Reference in New Issue
Block a user