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:
2026-06-26 13:27:18 +00:00
parent a3fe0fcf65
commit f8499e9668

View File

@@ -14,88 +14,76 @@ ctk.set_default_color_theme("blue") # "blue" | "green" | "dark-blue"
class VideoConcatenatorApp(ctk.CTk): class VideoConcatenatorApp(ctk.CTk):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.title("Video Concatenator (CustomTkinter)") self.title("Video Concatenator")
self.geometry("700x700") self.geometry("800x800")
# DPI scaling
dpi = self.winfo_fpixels('1i') / 72
self.tk.call('tk', 'scaling', dpi)
base_font = int(12 * dpi)
# State # 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 = "" self.output_dir = ""
# detect ffmpeg.exe
self.ffmpeg_path = shutil.which("ffmpeg") self.ffmpeg_path = shutil.which("ffmpeg")
# 1. Select Video Files # 1. Select and Order Videos
self.label1 = ctk.CTkLabel(self, text="1. Select Video Files", font=("Arial", 14)) self.label1 = ctk.CTkLabel(self, text="1. Select and Order Videos", font=(None, base_font+4))
self.label1.pack(pady=(10,5)) 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) self.select_btn.pack(pady=5)
# Listbox frame # Listbox for files with drag-and-drop
self.listbox_frame = ctk.CTkFrame(self) self.listbox_frame = ctk.CTkFrame(self)
self.listbox_frame.pack(pady=5, fill="x", expand=False) self.listbox_frame.pack(pady=5, fill="both", expand=True)
self.files_listbox = tk.Listbox(self.listbox_frame, selectmode=tk.SINGLE, width=80, height=8) 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.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 # 2. Name the Output File
self.label2 = ctk.CTkLabel(self, text="2. Organize Sequence", font=("Arial", 14)) self.label2 = ctk.CTkLabel(self, text="2. Name the Output File", font=(None, base_font+4))
self.label2.pack(pady=(10,5)) self.label2.pack(pady=(10,5))
self.up_down_frame = ctk.CTkFrame(self) self.output_name_entry = ctk.CTkEntry(self, width=0)
self.up_down_frame.pack(pady=5) self.output_name_entry.pack(pady=5, fill="x", expand=True)
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.insert(0, self.output_file_name) 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) self.output_name_entry.bind("<KeyRelease>", self.check_name_conflict)
# 4. Select Output Directory self.name_warning_label = ctk.CTkLabel(self, text="", text_color="red", font=(None, base_font))
self.label4 = ctk.CTkLabel(self, text="4. Select Output Directory", font=("Arial", 14)) self.name_warning_label.pack(pady=(0,5))
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)
# 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.delete_original_check = ctk.CTkCheckBox(
self, self,
text="Delete Original Files After Concatenation", 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) self.delete_original_check.pack(pady=5)
# Concatenate Button # 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) self.concat_btn.pack(pady=10)
# Time Left Label # 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) self.time_left_label.pack(pady=5)
# If ffmpeg not found, disable concatenation # Disable if ffmpeg missing
if not self.ffmpeg_path: if not self.ffmpeg_path:
messagebox.showerror( messagebox.showerror(
"Missing Dependency", "Missing Dependency",
"Could not find ffmpeg on your system.\n" "ffmpeg not found. Please install and add it to PATH."
"Please install ffmpeg and make sure it's on your PATH."
) )
self.concat_btn.configure(state="disabled") self.concat_btn.configure(state="disabled")
@@ -105,153 +93,102 @@ class VideoConcatenatorApp(ctk.CTk):
) )
if files: if files:
self.selected_files = list(files) self.selected_files = list(files)
self.update_files_listbox() self.refresh_listbox()
# autofill name
# Auto-fill output name from the first file first = os.path.basename(self.selected_files[0])
first_file = os.path.basename(self.selected_files[0]) default = os.path.splitext(first)[0] + ".mp4"
default_name = os.path.splitext(first_file)[0] + ".mp4"
self.output_name_entry.delete(0, tk.END) self.output_name_entry.delete(0, tk.END)
self.output_name_entry.insert(0, default_name) self.output_name_entry.insert(0, default)
# Re-check for conflicts and button state
self.check_name_conflict() self.check_name_conflict()
# Only enable if no conflict and ffmpeg exists def refresh_listbox(self):
if self.ffmpeg_path and not self._has_name_conflict():
self.concat_btn.configure(state="normal")
def update_files_listbox(self):
self.files_listbox.delete(0, tk.END) self.files_listbox.delete(0, tk.END)
for f in self.selected_files: for f in self.selected_files:
self.files_listbox.insert(tk.END, f) self.files_listbox.insert(tk.END, f)
def move_up(self): def on_drag_start(self, event):
sel = self.files_listbox.curselection() self.drag_start = self.files_listbox.nearest(event.y)
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 move_down(self): def on_drag_motion(self, event):
sel = self.files_listbox.curselection() i = self.files_listbox.nearest(event.y)
if sel and sel[0] < len(self.selected_files)-1: if i != self.drag_start:
i = sel[0] # swap
self.selected_files[i+1], self.selected_files[i] = self.selected_files[i], self.selected_files[i+1] self.selected_files[self.drag_start], self.selected_files[i] = (
self.update_files_listbox() self.selected_files[i], self.selected_files[self.drag_start]
self.files_listbox.select_set(i+1) )
self.refresh_listbox()
self.files_listbox.select_set(i)
self.drag_start = i
def select_output_dir(self): def select_output_dir(self):
d = filedialog.askdirectory() d = filedialog.askdirectory()
if d: if d:
self.output_dir = d self.output_dir = d
self.output_dir_label.configure(text=d) self.output_dir_label.configure(text=d)
self.check_name_conflict()
def _has_name_conflict(self): def _has_name_conflict(self):
name = self.output_name_entry.get().strip() name = self.output_name_entry.get().strip()
base_name = os.path.splitext(name)[0] base = os.path.splitext(name)[0]
return any( return any(os.path.splitext(os.path.basename(f))[0] == base for f in self.selected_files)
os.path.splitext(os.path.basename(f))[0] == base_name
for f in self.selected_files
)
def check_name_conflict(self, event=None): def check_name_conflict(self, event=None):
"""
Show warning and disable/enable the concat button based on name conflict.
"""
conflict = self._has_name_conflict() conflict = self._has_name_conflict()
if conflict: if conflict:
self.name_warning_label.configure( self.name_warning_label.configure(text="⚠️ Name matches an original file.")
text="⚠️ This name matches one of your originals!"
)
self.concat_btn.configure(state="disabled") self.concat_btn.configure(state="disabled")
else: else:
self.name_warning_label.configure(text="") self.name_warning_label.configure(text="")
# Only re-enable if videos are selected and ffmpeg exists if self.selected_files and self.output_dir and self.ffmpeg_path:
if self.selected_files and self.ffmpeg_path:
self.concat_btn.configure(state="normal") self.concat_btn.configure(state="normal")
def start_concatenation(self): def start_concatenation(self):
self.concat_btn.configure(state="disabled") 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() threading.Thread(target=self.concatenate_videos).start()
def concatenate_videos(self): def concatenate_videos(self):
# sanity checks # 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 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
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 list_path = os.path.join(self.output_dir, 'file_list.txt')
outname = self.output_name_entry.get().strip() with open(list_path, 'w', encoding='utf-8') as f:
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:
for file in self.selected_files: for file in self.selected_files:
path = file.replace("\\", "/").replace("'", r"'\\''") p = file.replace('\\', '/').replace("'", "'\\''")
f.write(f"file '{path}'\n") f.write(f"file '{p}'\n")
cmd = [ cmd = [self.ffmpeg_path, '-y', '-f', 'concat', '-safe', '0', '-i', 'file_list.txt', '-c:v', 'copy', '-c:a', 'copy', outp]
self.ffmpeg_path,
"-y",
"-f", "concat",
"-safe", "0",
"-i", "file_list.txt",
"-c:v", "copy",
"-c:a", "copy",
output_file
]
# estimate time # estimated time
estimate = len(self.selected_files) * 5 for sec in range(len(self.selected_files)*5):
for i in range(estimate):
time.sleep(1) 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() 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: if proc.returncode != 0:
self.time_left_label.configure(text="Error during concatenation.") self.time_left_label.configure(text="Error during concatenation.")
messagebox.showerror("FFmpeg error", proc.stderr.strip()) messagebox.showerror("FFmpeg error", proc.stderr)
else: else:
self.time_left_label.configure(text="Concatenation completed!") self.time_left_label.configure(text="Done!")
messagebox.showinfo("Success", f"Output: {output_file}") messagebox.showinfo("Success", f"Output saved to: {outp}")
if self.delete_original.get(): if self.delete_original.get():
for file in self.selected_files: for f in self.selected_files:
try: try: os.remove(f)
os.remove(file) except: pass
except OSError: messagebox.showinfo("Info", "Original files deleted.")
pass if os.path.exists(list_path): os.remove(list_path)
messagebox.showinfo("Info", "Original files have been deleted.")
# cleanup & re-enable
if os.path.exists(list_path):
os.remove(list_path)
self.concat_btn.configure(state="normal") self.concat_btn.configure(state="normal")
if __name__ == "__main__": if __name__ == '__main__':
app = VideoConcatenatorApp() app = VideoConcatenatorApp()
app.mainloop() app.mainloop()