From a35b2c649861c7ef378c9c501696091ad00338ed Mon Sep 17 00:00:00 2001 From: youfu Date: Fri, 26 Jun 2026 13:27:37 +0000 Subject: [PATCH] =?UTF-8?q?v4:=20production=20rewrite=20=E2=80=94=20real?= =?UTF-8?q?=20progress,=20durations,=20richer=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete rewrite of the application. Highlights: - Real progress bar driven by ffmpeg '-progress pipe:1' (out_time_ms vs total duration), held at 99% until 'progress=end' then snapped to 100% so the bar no longer jumps/catches up at the finish - ffprobe-based per-file duration scanning in background threads, cached per item; running total shown; probes parallelised (one thread per file) - No console/bash windows on Windows: CREATE_NO_WINDOW on all subprocesses - Optional drag-and-drop via tkinterdnd2 with a graceful fallback - Card-based scrollable UI with rows of (# index, checkbox, name, duration), per-row activate/highlight, plus Move Up/Down/Remove/Select All/Select None - Output settings: name + extension dropdown + directory, with live output path preview - Start/Stop control: terminate ffmpeg and delete the partial output on stop; overwrite, name-conflict, and single-file guards before starting - Elapsed / ETA / speed readout, collapsible ffmpeg log, light/dark toggle - Locate ffmpeg/ffprobe next to the exe/script (PyInstaller-friendly) then PATH - Output hardening: -movflags +faststart and -nostdin Co-Authored-By: Claude Opus 4.8 --- video_concat.py | 1218 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 1024 insertions(+), 194 deletions(-) diff --git a/video_concat.py b/video_concat.py index 59ca8f5..279071b 100755 --- a/video_concat.py +++ b/video_concat.py @@ -1,194 +1,1024 @@ -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('', self.on_drag_start) - self.files_listbox.bind('', 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("", 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() +""" +Video Concatenator — Production Rewrite +======================================== +Key improvements over the original: + + [CRITICAL FIX] + 1. No console/bash windows ever appear — subprocess.CREATE_NO_WINDOW applied + to ALL subprocess calls (ffmpeg, ffprobe), including duration scanning. + + [PROGRESS FIX] + 2. Progress bar no longer "catches up" at the end: + - Bar is capped at 99% while ffmpeg is still writing. + - The moment ffmpeg emits "progress=end" the bar snaps to 100% and status + reads "Finalising…" — the user sees an honest state with zero lag. + + [UX] + 3. Drag-and-drop support (tkinterdnd2 if installed; graceful fallback). + 4. Per-file duration shown in the list, fetched in background threads. + 5. Row index (#) column so order is immediately obvious. + 6. Separate elapsed / ETA / speed label so progress row isn't cluttered. + 7. Output file already-exists guard with overwrite prompt. + 8. Single-file concat warning (copy semantics). + 9. Partial output file removed automatically on Stop. + 10. Light/Dark theme toggle button in title bar. + + [PERFORMANCE] + 11. Duration values are cached per item after the first ffprobe scan, so + the total_duration calculation before concat is near-instant. + 12. ffprobe calls for the list are parallelised (one thread per file). + + [RESPONSIVE UI] + 13. DPI-aware: calls SetProcessDpiAwareness(2) before any Tk window is + created; minsize scales with DPI so the window is usable on 4K displays. +""" + +import os +import sys +import json +import re +import shutil +import subprocess +import tempfile +import threading +import time +from pathlib import Path + +import tkinter as tk +from tkinter import filedialog, messagebox +import customtkinter as ctk + +# ── DPI awareness (must happen before any Tk/CTk window) ────────────────────── +if sys.platform == "win32": + try: + import ctypes + ctypes.windll.shcore.SetProcessDpiAwareness(2) # Per-monitor V2 + except Exception: + try: + ctypes.windll.user32.SetProcessDPIAware() + except Exception: + pass + +# ── Optional drag-and-drop ───────────────────────────────────────────────────── +try: + from tkinterdnd2 import TkinterDnD, DND_FILES # type: ignore + _DND = True +except ImportError: + _DND = False + +# ── Appearance ───────────────────────────────────────────────────────────────── +ctk.set_appearance_mode("system") +ctk.set_default_color_theme("blue") + +# ── Suppress console windows on Windows for ALL child processes ──────────────── +_NO_WINDOW: dict = {} +if sys.platform == "win32": + _NO_WINDOW["creationflags"] = subprocess.CREATE_NO_WINDOW + + +# ══════════════════════════════════════════════════════════════════════════════ +# Utility helpers +# ══════════════════════════════════════════════════════════════════════════════ + +def _app_dir() -> str: + if getattr(sys, "frozen", False): + return os.path.dirname(sys.executable) + return os.path.dirname(os.path.abspath(__file__)) + + +def _find_tool(name: str) -> str | None: + """Locate ffmpeg / ffprobe next to the exe / script, then on PATH.""" + base = _app_dir() + for sub in ["_internal", ""]: + for variant in [name + ".exe", name]: + p = os.path.join(base, sub, variant) + if os.path.exists(p): + return p + return shutil.which(name + ".exe") or shutil.which(name) + + +def _fmt_dur(seconds: float) -> str: + """Format a duration as H:MM:SS or M:SS.""" + if seconds < 0: + return "…" + if seconds == 0: + return "—" + s = int(round(seconds)) + h, rem = divmod(s, 3600) + m, sec = divmod(rem, 60) + return f"{h}:{m:02d}:{sec:02d}" if h else f"{m}:{sec:02d}" + + +def _fmt_elapsed(s: float) -> str: + if s < 60: + return f"{int(s)}s" + m, sec = divmod(int(s), 60) + return f"{m}m {sec:02d}s" + + +def _dpi_scale(widget) -> float: + try: + return max(1.0, widget.winfo_fpixels("1i") / 96.0) + except Exception: + return 1.0 + + +# ══════════════════════════════════════════════════════════════════════════════ +# Application +# ══════════════════════════════════════════════════════════════════════════════ + +_BaseClass = TkinterDnD.Tk if _DND else ctk.CTk # type: ignore[misc] + + +class App(_BaseClass): # type: ignore[valid-type] + + VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".avi", ".flv", + ".ts", ".wmv", ".m4v", ".webm", ".mts", ".m2ts"} + + def __init__(self): + super().__init__() + self.title("Video Concatenator") + self.geometry("820x860") + self.minsize(700, 660) + + # ── State ───────────────────────────────────────────────────────────── + # Each item: {path:str, checked:BooleanVar, duration:float, dur_label:CTkLabel|None, row_widget} + self.items: list[dict] = [] + self.active_index: int | None = None + self._dur_lock = threading.Lock() + + self.out_name_var = tk.StringVar(value="output_concat") + self.out_ext_var = tk.StringVar(value=".mp4") + self.out_dir_var = tk.StringVar(value="") + self.del_orig_var = tk.BooleanVar(value=False) + + self._proc: subprocess.Popen | None = None + self._stop_req = False + + # ── Tools ───────────────────────────────────────────────────────────── + self.ffmpeg = _find_tool("ffmpeg") + self.ffprobe = _find_tool("ffprobe") + + # ── Build UI ────────────────────────────────────────────────────────── + self._build_ui() + self.update_idletasks() + scale = _dpi_scale(self) + self.minsize(int(700 * scale), int(660 * scale)) + + # ── Initial status ───────────────────────────────────────────────────── + if not self.ffmpeg: + self._set_status( + "ffmpeg not found — place ffmpeg.exe next to this app or add to PATH.", True + ) + self.start_btn.configure(state="disabled") + messagebox.showerror( + "Missing dependency", + "ffmpeg could not be found.\n\n" + "Please place ffmpeg.exe (and ffprobe.exe) next to this program,\n" + "or install ffmpeg and ensure it is on your PATH." + ) + else: + hint = ("Ready. Drag & drop videos or click + Add Videos." + if _DND else "Ready. Click + Add Videos to get started.") + self._set_status(hint) + + self._refresh_start() + + # ══════════════════════════════════════════════════════════════════════════ + # UI Construction + # ══════════════════════════════════════════════════════════════════════════ + + def _build_ui(self): + card = ctk.CTkFrame(self, corner_radius=16) + card.pack(fill="both", expand=True, padx=20, pady=20) + + # Title bar + tbar = ctk.CTkFrame(card, fg_color="transparent") + tbar.pack(fill="x", padx=16, pady=(16, 2)) + ctk.CTkLabel(tbar, text="🎬 Video Concatenator", + font=ctk.CTkFont(size=22, weight="bold")).pack(side="left") + ctk.CTkButton( + tbar, text="☀ / ☾", width=68, + fg_color="transparent", + text_color=("gray30", "gray70"), + hover_color=("gray85", "gray25"), + command=self._toggle_theme + ).pack(side="right") + + # Scrollable body + scroll = ctk.CTkScrollableFrame(card, corner_radius=12) + scroll.pack(fill="both", expand=True, padx=14, pady=(8, 14)) + + self._build_sec_add(scroll) + self._build_sec_list(scroll) + self._build_sec_output(scroll) + self._build_sec_options(scroll) + self._build_sec_run(scroll) + + # ── Section: Add Videos ─────────────────────────────────────────────────── + + def _build_sec_add(self, parent): + sec = self._section(parent, "1 · Add Videos") + sec.pack(fill="x", pady=(0, 12)) + + row = ctk.CTkFrame(sec, fg_color="transparent") + row.pack(fill="x", padx=14, pady=(10, 4)) + + ctk.CTkButton(row, text="+ Add Videos", width=170, + command=self.pick_files).pack(side="left") + + ctk.CTkButton( + row, text="Clear All", width=110, + fg_color=("gray80", "gray25"), + text_color=("gray10", "gray90"), + hover_color=("gray70", "gray35"), + command=self.clear_all + ).pack(side="left", padx=(10, 0)) + + self.count_lbl = ctk.CTkLabel( + row, text="0 files", + font=ctk.CTkFont(size=12), text_color=("gray45", "gray55") + ) + self.count_lbl.pack(side="right") + + ctk.CTkLabel( + sec, + text=("📂 Drag & drop videos here, or use the button above." + if _DND else + "Tip: you can add videos multiple times to repeat a clip."), + font=ctk.CTkFont(size=12), text_color=("gray50", "gray55") + ).pack(anchor="w", padx=14, pady=(0, 12)) + + if _DND: + self.drop_target_register(DND_FILES) + self.dnd_bind("<>", self._on_drop) + + # ── Section: List ───────────────────────────────────────────────────────── + + def _build_sec_list(self, parent): + sec = self._section(parent, "2 · Review & Reorder") + sec.pack(fill="x", pady=(0, 12)) + + body = ctk.CTkFrame(sec, fg_color="transparent") + body.pack(fill="x", padx=14, pady=(10, 4)) + + # Left — list panel + left = ctk.CTkFrame(body, corner_radius=10) + left.pack(side="left", fill="both", expand=True, padx=(0, 10)) + + # Column header row + hdr = ctk.CTkFrame(left, fg_color=("gray88", "gray20"), corner_radius=8) + hdr.pack(fill="x", padx=8, pady=(8, 2)) + for text, w, anchor in [ + ("#", 30, "center"), + ("✓", 30, "center"), + ("File name", 0, "w"), + ("Duration", 76, "center"), + ]: + lbl = ctk.CTkLabel(hdr, text=text, + font=ctk.CTkFont(size=11, weight="bold"), + anchor=anchor) + if w: + lbl.configure(width=w) + lbl.pack(side="left" if text != "Duration" else "right", + padx=(6 if text == "#" else 0, 8 if text == "Duration" else 0), + pady=4) + else: + lbl.pack(side="left", fill="x", expand=True, padx=4, pady=4) + + self.list_area = ctk.CTkScrollableFrame(left, height=190, corner_radius=8) + self.list_area.pack(fill="both", expand=True, padx=8, pady=(0, 8)) + + # Right — buttons + right = ctk.CTkFrame(body, fg_color="transparent") + right.pack(side="right", fill="y") + self.btn_up = self._side_btn(right, "↑ Move Up", self.move_up) + self.btn_down = self._side_btn(right, "↓ Move Down", self.move_down) + self.btn_remove = self._side_btn(right, "✕ Remove", self.remove_active) + ctk.CTkFrame(right, height=10, fg_color="transparent").pack() + self.btn_all = self._side_btn(right, "Select All", self.select_all) + self.btn_none = self._side_btn(right, "Select None", self.select_none) + + self.total_lbl = ctk.CTkLabel( + sec, text="Total: —", + font=ctk.CTkFont(size=12), text_color=("gray45", "gray55") + ) + self.total_lbl.pack(anchor="e", padx=14, pady=(0, 10)) + + # ── Section: Output ─────────────────────────────────────────────────────── + + def _build_sec_output(self, parent): + sec = self._section(parent, "3 · Output Settings") + sec.pack(fill="x", pady=(0, 12)) + + body = ctk.CTkFrame(sec, fg_color="transparent") + body.pack(fill="x", padx=14, pady=(10, 4)) + + # Name row + r1 = ctk.CTkFrame(body, fg_color="transparent") + r1.pack(fill="x", pady=(0, 8)) + ctk.CTkLabel(r1, text="File name:", width=96, anchor="w").pack(side="left") + ctk.CTkEntry(r1, textvariable=self.out_name_var).pack(side="left", fill="x", expand=True) + ctk.CTkOptionMenu( + r1, values=[".mp4", ".mkv", ".mov", ".avi", ".ts"], + variable=self.out_ext_var, width=84 + ).pack(side="left", padx=(8, 0)) + + # Directory row + r2 = ctk.CTkFrame(body, fg_color="transparent") + r2.pack(fill="x") + ctk.CTkLabel(r2, text="Save to:", width=96, anchor="w").pack(side="left") + ctk.CTkEntry(r2, textvariable=self.out_dir_var).pack(side="left", fill="x", expand=True) + ctk.CTkButton( + r2, text="Browse…", width=100, + fg_color=("gray80", "gray25"), + text_color=("gray10", "gray90"), + hover_color=("gray70", "gray35"), + command=self.pick_dir + ).pack(side="left", padx=(8, 0)) + + self.out_path_lbl = ctk.CTkLabel( + sec, text="Output path: —", + font=ctk.CTkFont(size=11), text_color=("gray45", "gray55"), + wraplength=620, anchor="w", justify="left" + ) + self.out_path_lbl.pack(anchor="w", padx=14, pady=(6, 10)) + + for v in (self.out_name_var, self.out_ext_var, self.out_dir_var): + v.trace_add("write", lambda *_: self._refresh_out_label()) + self._refresh_out_label() + + # ── Section: Options ────────────────────────────────────────────────────── + + def _build_sec_options(self, parent): + sec = self._section(parent, "4 · Options") + sec.pack(fill="x", pady=(0, 12)) + + ctk.CTkCheckBox( + sec, text="Delete original files after successful concatenation", + variable=self.del_orig_var + ).pack(anchor="w", padx=14, pady=(10, 2)) + + ctk.CTkLabel( + sec, + text="⚠ Deletion is permanent — originals cannot be recovered.", + text_color="#F97316", font=ctk.CTkFont(size=11) + ).pack(anchor="w", padx=40, pady=(0, 10)) + + # ── Section: Run ────────────────────────────────────────────────────────── + + def _build_sec_run(self, parent): + sec = self._section(parent, "5 · Run") + sec.pack(fill="x", pady=(0, 14)) + + body = ctk.CTkFrame(sec, fg_color="transparent") + body.pack(fill="x", padx=14, pady=(12, 6)) + + # Buttons + btn_row = ctk.CTkFrame(body, fg_color="transparent") + btn_row.pack(fill="x", pady=(0, 10)) + + self.start_btn = ctk.CTkButton( + btn_row, text="▶ Start Concatenation", + width=230, height=42, + font=ctk.CTkFont(size=14, weight="bold"), + command=self.start_concat + ) + self.start_btn.pack(side="left") + + self.stop_btn = ctk.CTkButton( + btn_row, text="■ Stop", width=106, height=42, + fg_color=("gray80", "gray25"), + text_color=("gray10", "gray90"), + hover_color=("#FCA5A5", "#7F1D1D"), + command=self.stop_concat, state="disabled" + ) + self.stop_btn.pack(side="left", padx=(12, 0)) + + # Progress bar row + prog_row = ctk.CTkFrame(body, fg_color="transparent") + prog_row.pack(fill="x", pady=(0, 6)) + + self.progress = ctk.CTkProgressBar(prog_row, height=16, corner_radius=8) + self.progress.set(0) + self.progress.pack(side="left", fill="x", expand=True, padx=(0, 10)) + + self.pct_lbl = ctk.CTkLabel( + prog_row, text="—", width=46, anchor="e", + font=ctk.CTkFont(size=12, weight="bold") + ) + self.pct_lbl.pack(side="right") + + # Status + timing row + info_row = ctk.CTkFrame(body, fg_color="transparent") + info_row.pack(fill="x") + + self.status_lbl = ctk.CTkLabel(info_row, text="", anchor="w", + font=ctk.CTkFont(size=12)) + self.status_lbl.pack(side="left", fill="x", expand=True) + + self.timing_lbl = ctk.CTkLabel( + info_row, text="", anchor="e", + font=ctk.CTkFont(size=11), text_color=("gray45", "gray55") + ) + self.timing_lbl.pack(side="right") + + # Collapsible log + self._log_open = False + self._log_btn = ctk.CTkButton( + sec, text="Log ▾", width=80, + fg_color="transparent", + text_color=("gray40", "gray60"), + hover_color=("gray88", "gray22"), + font=ctk.CTkFont(size=12), + command=self._toggle_log + ) + self._log_btn.pack(anchor="w", padx=14, pady=(4, 0)) + + self._log_box = ctk.CTkTextbox( + sec, height=110, corner_radius=8, + font=ctk.CTkFont(size=11, family="Courier") + ) + self._log_box.insert("1.0", "") + self._log_box.configure(state="disabled") + self._log_box.pack_forget() + ctk.CTkFrame(sec, height=6, fg_color="transparent").pack() + + # ══════════════════════════════════════════════════════════════════════════ + # UI helpers + # ══════════════════════════════════════════════════════════════════════════ + + def _section(self, parent, title: str) -> ctk.CTkFrame: + f = ctk.CTkFrame(parent, corner_radius=12) + hdr = ctk.CTkFrame(f, fg_color="transparent") + hdr.pack(fill="x", padx=14, pady=(10, 0)) + ctk.CTkLabel(hdr, text=title, + font=ctk.CTkFont(size=13, weight="bold")).pack(side="left") + ctk.CTkFrame(f, height=1, fg_color=("gray78", "gray32")).pack( + fill="x", padx=14, pady=(6, 0) + ) + return f + + def _side_btn(self, parent, text: str, cmd) -> ctk.CTkButton: + b = ctk.CTkButton( + parent, text=text, width=152, height=32, + fg_color=("gray82", "gray22"), + text_color=("gray10", "gray90"), + hover_color=("gray72", "gray32"), + font=ctk.CTkFont(size=12), + command=cmd + ) + b.pack(pady=4) + return b + + def _set_status(self, text: str, error: bool = False): + color = "#DC2626" if error else ("gray10", "gray90") + self.status_lbl.configure(text=text, text_color=color) + + def _set_timing(self, text: str): + self.timing_lbl.configure(text=text) + + def _toggle_theme(self): + mode = ctk.get_appearance_mode().lower() + ctk.set_appearance_mode("light" if mode == "dark" else "dark") + + def _toggle_log(self): + self._log_open = not self._log_open + if self._log_open: + self._log_btn.configure(text="Log ▴") + self._log_box.pack(fill="x", padx=14, pady=(0, 6)) + else: + self._log_btn.configure(text="Log ▾") + self._log_box.pack_forget() + + def _append_log(self, text: str): + if not self._log_open: + return + self._log_box.configure(state="normal") + self._log_box.insert("end", text + "\n") + self._log_box.see("end") + self._log_box.configure(state="disabled") + + def _refresh_out_label(self): + out = self._out_path() + self.out_path_lbl.configure( + text=f"Output path: {out}" if out else "Output path: —" + ) + self._refresh_start() + + def _refresh_start(self): + if not hasattr(self, "start_btn"): + return # UI not fully built yet + ok = bool( + self.ffmpeg + and not self._is_running() + and self._checked_paths() + and self.out_dir_var.get().strip() + and self.out_name_var.get().strip() + ) + self.start_btn.configure(state="normal" if ok else "disabled") + + def _is_running(self) -> bool: + return self._proc is not None and self._proc.poll() is None + + def _out_path(self) -> str: + d = self.out_dir_var.get().strip() + name = self.out_name_var.get().strip() + ext = self.out_ext_var.get().strip() + if not d or not name: + return "" + if not ext.startswith("."): + ext = "." + ext + return str(Path(d) / f"{name}{ext}") + + def _checked_paths(self) -> list[str]: + return [it["path"] for it in self.items if it["checked"].get()] + + # ══════════════════════════════════════════════════════════════════════════ + # Drag-and-drop + # ══════════════════════════════════════════════════════════════════════════ + + def _on_drop(self, event): + # tkinterdnd2 wraps paths that contain spaces in {braces} + raw = event.data + paths = [a or b for a, b in re.findall(r'\{([^}]+)\}|(\S+)', raw)] + self._add_files(paths) + + # ══════════════════════════════════════════════════════════════════════════ + # Data model + # ══════════════════════════════════════════════════════════════════════════ + + def _add_files(self, paths: list[str]): + existing = {it["path"] for it in self.items} + new: list[dict] = [] + for p in paths: + p = p.strip() + if not p or Path(p).suffix.lower() not in self.VIDEO_EXTS: + continue + if p not in existing: + item: dict = { + "path": p, "checked": tk.BooleanVar(value=True), + "duration": -1.0, "dur_label": None, "row_widget": None + } + self.items.append(item) + new.append(item) + + if not new: + return + + self.active_index = 0 + + # Auto-fill output dir / name + if not self.out_dir_var.get().strip(): + self.out_dir_var.set(str(Path(self.items[0]["path"]).parent)) + if self.out_name_var.get() in ("", "output_concat"): + self.out_name_var.set(Path(self.items[0]["path"]).stem + "_concat") + + self._rebuild_list() + self._update_count() + self._refresh_out_label() + + # Background duration scanning (new items only) + for item in new: + threading.Thread(target=self._scan_dur, args=(item,), daemon=True).start() + + def clear_all(self): + self.items.clear() + self.active_index = None + self._rebuild_list() + self._update_count() + self._update_total() + self._refresh_start() + + def _update_count(self): + n = len(self.items) + self.count_lbl.configure(text=f"{n} file{'s' if n != 1 else ''}") + + def _update_total(self): + with self._dur_lock: + known = [it["duration"] for it in self.items if it["duration"] >= 0] + pending = sum(1 for it in self.items if it["duration"] < 0) + total = sum(known) + if not self.items: + self.total_lbl.configure(text="Total: —") + elif pending: + self.total_lbl.configure(text=f"Total: {_fmt_dur(total)} (scanning {pending}…)") + else: + self.total_lbl.configure(text=f"Total: {_fmt_dur(total)}") + + # ── Duration scanning ───────────────────────────────────────────────────── + + def _scan_dur(self, item: dict): + dur = self._probe_dur(item["path"]) + with self._dur_lock: + item["duration"] = dur + self.after(0, lambda: self._paint_dur(item)) + self.after(0, self._update_total) + + def _paint_dur(self, item: dict): + lbl = item.get("dur_label") + if lbl: + try: + lbl.configure(text=_fmt_dur(item["duration"])) + except Exception: + pass + + # ── List rendering ──────────────────────────────────────────────────────── + + def _rebuild_list(self): + for it in self.items: + it["dur_label"] = None + it["row_widget"] = None + for w in list(self.list_area.winfo_children()): + w.destroy() + for idx, it in enumerate(self.items): + self._make_row(idx, it) + self._paint_highlights() + self._refresh_start() + + def _make_row(self, idx: int, item: dict): + row = ctk.CTkFrame(self.list_area, corner_radius=7, height=36) + row.pack(fill="x", pady=3) + row.pack_propagate(False) + + def activate(_=None, i=idx): + self.active_index = i + self._paint_highlights() + + # Index + ctk.CTkLabel(row, text=str(idx + 1), width=30, anchor="center", + font=ctk.CTkFont(size=11), + text_color=("gray50", "gray50")).pack(side="left", padx=(8, 0)) + + # Checkbox + cb = ctk.CTkCheckBox(row, text="", variable=item["checked"], width=26) + cb.pack(side="left", padx=(4, 2)) + cb.configure(command=lambda: (self._refresh_start(), self._update_total())) + cb.bind("", lambda e, i=idx: activate()) + + # File name label + name_lbl = ctk.CTkLabel(row, text=Path(item["path"]).name, + anchor="w", font=ctk.CTkFont(size=12)) + name_lbl.pack(side="left", fill="x", expand=True, padx=(2, 4)) + + # Duration label + dur_text = _fmt_dur(item["duration"]) if item["duration"] >= 0 else "…" + dur_lbl = ctk.CTkLabel(row, text=dur_text, width=72, anchor="center", + font=ctk.CTkFont(size=11), + text_color=("gray45", "gray55")) + dur_lbl.pack(side="right", padx=(0, 10)) + item["dur_label"] = dur_lbl + + for widget in (row, name_lbl): + widget.bind("", activate) + item["row_widget"] = row + + def _paint_highlights(self): + active_bg = ("#DBEAFE", "#1E3A5F") + normal_bg = ("transparent", "transparent") + for i, it in enumerate(self.items): + row = it.get("row_widget") + if not row: + continue + try: + row.configure(fg_color=active_bg if self.active_index == i else normal_bg) + except Exception: + pass + + # ══════════════════════════════════════════════════════════════════════════ + # File actions + # ══════════════════════════════════════════════════════════════════════════ + + def pick_files(self): + files = filedialog.askopenfilenames( + title="Select video files", + filetypes=[ + ("Video files", "*.mp4 *.mov *.mkv *.avi *.flv *.ts *.wmv *.m4v *.webm *.mts"), + ("All files", "*.*"), + ] + ) + if files: + self._add_files(list(files)) + + def pick_dir(self): + d = filedialog.askdirectory(title="Choose output folder") + if d: + self.out_dir_var.set(d) + + def move_up(self): + if self.active_index is None or self.active_index <= 0: + return + i = self.active_index + self.items[i - 1], self.items[i] = self.items[i], self.items[i - 1] + self.active_index = i - 1 + self._rebuild_list() + + def move_down(self): + if self.active_index is None or self.active_index >= len(self.items) - 1: + return + i = self.active_index + self.items[i + 1], self.items[i] = self.items[i], self.items[i + 1] + self.active_index = i + 1 + self._rebuild_list() + + def remove_active(self): + if self.active_index is None or not self.items: + return + i = self.active_index + self.items.pop(i) + self.active_index = min(i, len(self.items) - 1) if self.items else None + self._rebuild_list() + self._update_count() + self._update_total() + + def select_all(self): + for it in self.items: + it["checked"].set(True) + self._refresh_start() + + def select_none(self): + for it in self.items: + it["checked"].set(False) + self._refresh_start() + + # ══════════════════════════════════════════════════════════════════════════ + # ffprobe (always CREATE_NO_WINDOW) + # ══════════════════════════════════════════════════════════════════════════ + + def _probe_dur(self, path: str) -> float: + if not self.ffprobe: + return 0.0 + try: + r = subprocess.run( + [self.ffprobe, "-v", "error", + "-show_entries", "format=duration", + "-of", "json", path], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, timeout=20, + **_NO_WINDOW + ) + if r.returncode != 0: + return 0.0 + data = json.loads(r.stdout or "{}") + return max(0.0, float(data.get("format", {}).get("duration", 0) or 0)) + except Exception: + return 0.0 + + def _total_dur(self, files: list[str]) -> float: + total = 0.0 + for path in files: + item = next((it for it in self.items if it["path"] == path), None) + if item and item["duration"] >= 0: + total += item["duration"] + else: + total += self._probe_dur(path) + return total + + # ══════════════════════════════════════════════════════════════════════════ + # Concatenation + # ══════════════════════════════════════════════════════════════════════════ + + def start_concat(self): + files = self._checked_paths() + if not files: + messagebox.showerror("No files selected", "Please check at least one video.") + return + + if len(files) == 1: + if not messagebox.askyesno( + "Only one file", + "Only one file is selected.\nThis will effectively copy it. Continue?" + ): + return + + out = self._out_path() + if not out: + messagebox.showerror("Missing output", "Set an output name and folder.") + return + + # Name conflict guard + out_stem = Path(out).stem + if any(Path(f).stem == out_stem for f in files): + messagebox.showerror( + "Name conflict", + "Output name matches a source file.\nChoose a different output name." + ) + return + + # Overwrite guard + if Path(out).exists(): + if not messagebox.askyesno("Overwrite?", + f"{Path(out).name} already exists. Overwrite?"): + return + + # Reset UI + self._stop_req = False + self.start_btn.configure(state="disabled") + self.stop_btn.configure(state="normal") + self.progress.set(0) + self.pct_lbl.configure(text="0%") + self._set_status("Starting…") + self._set_timing("") + + if self._log_open: + self._log_box.configure(state="normal") + self._log_box.delete("1.0", "end") + self._log_box.configure(state="disabled") + + threading.Thread(target=self._worker, args=(files, out), daemon=True).start() + + def stop_concat(self): + self._stop_req = True + if self._proc and self._is_running(): + try: + self._proc.terminate() + except Exception: + pass + self._set_status("Stopping…") + + # ── Worker thread ───────────────────────────────────────────────────────── + + def _worker(self, files: list[str], out_file: str): + out_dir = str(Path(out_file).parent) + + # Write concat list file + try: + tmp = tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", newline="\n", + delete=False, dir=out_dir, suffix=".txt" + ) + list_path = tmp.name + for f in files: + s = str(Path(f).resolve()).replace("\\", "/").replace("'", r"'\''") + tmp.write(f"file '{s}'\n") + tmp.close() + except Exception as e: + self.after(0, lambda: messagebox.showerror("Error", f"Could not write temp file:\n{e}")) + self.after(0, lambda: self._finish(False)) + return + + # Get total duration (uses cache; may do live probes for uncached files) + self.after(0, lambda: self._set_status("Scanning durations…")) + total_secs = self._total_dur(files) + + # Build ffmpeg command + cmd = [ + self.ffmpeg, + "-y", + "-f", "concat", "-safe", "0", + "-i", list_path, + "-c:v", "copy", "-c:a", "copy", + "-movflags", "+faststart", + "-nostdin", "-hide_banner", + "-loglevel", "error", + "-progress", "pipe:1", + out_file, + ] + self.after(0, lambda: self._append_log("CMD: " + " ".join(str(x) for x in cmd))) + self.after(0, lambda: self._set_status("Concatenating…")) + + t0 = time.time() + rc = -1 + stderr_out = "" + + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=out_dir, + bufsize=1, + **_NO_WINDOW # ← THE FIX: no console window ever + ) + self._proc = proc + + cur_ms = 0 + + while True: + if self._stop_req: + break + + line = proc.stdout.readline() if proc.stdout else "" + if not line: + if proc.poll() is not None: + break + time.sleep(0.04) + continue + + line = line.strip() + + if line.startswith("out_time_ms="): + try: + cur_ms = int(line.split("=", 1)[1]) + except ValueError: + pass + + elif line == "progress=end": + # ffmpeg finished writing — snap to 100% RIGHT NOW + elapsed = time.time() - t0 + self.after(0, lambda e=elapsed: ( + self.progress.set(1.0), + self.pct_lbl.configure(text="100%"), + self._set_status("Finalising output file…"), + self._set_timing(f"Elapsed: {_fmt_elapsed(e)}") + )) + + elif line.startswith("progress=continue"): + if total_secs > 0 and cur_ms > 0: + played = cur_ms / 1_000_000.0 + pct = max(0.0, min(played / total_secs, 0.99)) # cap at 99 until end + elapsed = time.time() - t0 + speed = played / max(elapsed, 1e-9) + eta = (total_secs - played) / max(speed, 1e-9) + + def _ui(p=pct, e=elapsed, r=eta, sp=speed): + self.progress.set(p) + self.pct_lbl.configure(text=f"{p*100:.0f}%") + self._set_timing( + f"Elapsed {_fmt_elapsed(e)} · ETA {_fmt_elapsed(r)} · {sp:.1f}×" + ) + self.after(0, _ui) + + try: + _, stderr_out = proc.communicate(timeout=10) + except Exception: + stderr_out = "" + rc = proc.returncode + + except Exception as exc: + rc = -1 + stderr_out = str(exc) + + # Cleanup temp file + try: + os.remove(list_path) + except Exception: + pass + + # ── Handle result ────────────────────────────────────────────────────── + if self._stop_req: + try: + Path(out_file).unlink(missing_ok=True) + except Exception: + pass + self.after(0, lambda: self._set_status("Stopped by user.")) + self.after(0, lambda: self._finish(False)) + return + + if rc != 0: + err = (stderr_out or "").strip() or "Unknown ffmpeg error." + self.after(0, lambda: self._set_status("Concatenation failed.", True)) + self.after(0, lambda: messagebox.showerror("FFmpeg error", err)) + self.after(0, lambda: self._finish(False)) + return + + # Success + elapsed_total = time.time() - t0 + try: + size_mb = Path(out_file).stat().st_size / (1024 * 1024) + except Exception: + size_mb = 0.0 + + self.after(0, lambda: self.progress.set(1.0)) + self.after(0, lambda: self.pct_lbl.configure(text="100%")) + self.after(0, lambda: self._set_status("✓ Done!")) + self.after(0, lambda: self._set_timing( + f"Finished in {_fmt_elapsed(elapsed_total)} · {size_mb:.1f} MB" + )) + self.after(0, lambda: messagebox.showinfo( + "Concatenation complete", + f"Saved to:\n{out_file}\n\n" + f"Size: {size_mb:.1f} MB | Time taken: {_fmt_elapsed(elapsed_total)}" + )) + + if self.del_orig_var.get(): + deleted = sum(1 for f in files if self._try_delete(f)) + if deleted: + self.after(0, lambda d=deleted: messagebox.showinfo( + "Originals deleted", f"{d} original file(s) removed." + )) + + self.after(0, lambda: self._finish(True)) + + @staticmethod + def _try_delete(path: str) -> bool: + try: + os.remove(path) + return True + except OSError: + return False + + def _finish(self, _ok: bool): + self._proc = None + self._stop_req = False + self.stop_btn.configure(state="disabled") + self._refresh_out_label() + self._refresh_start() + + +# ══════════════════════════════════════════════════════════════════════════════ + +if __name__ == "__main__": + app = App() + app.mainloop()