Files
video_concat/video_concat.py
youfu a35b2c6498 v4: production rewrite — real progress, durations, richer UX
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 <noreply@anthropic.com>
2026-06-26 13:27:37 +00:00

1025 lines
41 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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("<<Drop>>", 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("<Button-1>", 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("<Button-1>", 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()