Cara Membuat Aplikasi Desktop Python: Rotasi & Kompres Video Otomatis dengan Tkinter dan FFmpeg Standalone
Pernahkah Anda ingin membuat aplikasi utilitas desktop sendiri untuk memutar (rotate) dan mengompres ukuran video, tetapi terkendala kerumitan antarmuka baris perintah (CLI) FFmpeg?
Dalam artikel ini, kita akan membahas cara membangun aplikasi desktop profesional berbasis Python 3.13 dan Tkinter dengan antarmuka grafis (GUI) bertema gelap yang elegan. Aplikasi ini juga memanfaatkan pustaka imageio-ffmpeg sehingga pengguna akhir tidak perlu menginstal FFmpeg secara terpisah di komputer mereka.
Kita mulai dari instalasi Python di Windows.
1. Unduh Python
Buka python.org/downloads.
Pilih versi 3.12 atau 3.13. Versi ini stabil dan paling aman dengan PyInstaller. Versi terbaru kadang belum didukung penuh oleh PyInstaller.
Klik tombol unduh untuk Windows (installer 64-bit).
2. Jalankan installer
Buka file .exe yang sudah diunduh.
Di layar pertama, centang “Add python.exe to PATH” di bagian bawah. Langkah ini penting, kalau terlewat perintah python dan pip tidak akan dikenali.
Klik Install Now dan tunggu sampai selesai.
Setelah muncul “Setup was successful”, klik Close.
3. Cek instalasi
Tekan tombol Windows, ketik cmd, lalu tekan Enter.
Ketik perintah ini: python --version
Kalau muncul tulisan seperti Python 3.13.x Instalasi berhasil.
4. Cek pip
- Masih di Command Prompt, ketik: pip --version
- Kalau muncul nomor versi pip, berarti siap dipakai.
Kalau muncul “python is not recognized”
- Kemungkinan besar PATH belum tercentang saat instalasi.
- Solusi termudah: jalankan lagi installer, pilih Modify, lalu Next, centang Add Python to environment variables, dan klik Install. Setelah itu tutup Command Prompt dan buka lagi.
Kalau sudah muncul versi Python di langkah 3, kita lanjut ke langkah berikutnya: menyiapkan folder proyek dan memasang imageio-ffmpeg serta pyinstaller.
Python sudah terpasang dengan benar. Versi 3.13 juga didukung oleh PyInstaller versi terbaru, jadi aman dipakai.
1. Buat folder proyek
Di Command Prompt, jalankan:
mkdir C:\rotate_video
cd C:\rotate_video2. Simpan skrip ke folder itu
- Unduh file
rotate_video.pyFile ada di akhir artikel - Pindahkan atau salin ke
C:\rotate_video. - Cek dengan perintah ini. Nama
rotate_video.pyharus muncul di daftar:
3. Pasang paket yang dibutuhkan
pip install imageio-ffmpeg pyinstallerTunggu sampai selesai dan muncul tulisan Successfully installed .... Proses ini butuh internet dan bisa memakan waktu beberapa menit, karena imageio-ffmpeg membawa file ffmpeg yang ukurannya cukup besar.
4. Coba jalankan skrip dulu
python rotate_video.py
Jendela “Video Rotator” akan muncul. Coba pilih satu video pendek, pilih mode rotasi, lalu klik Proses. Pastikan hasilnya benar sebelum lanjut ke build exe.
5. Setelah itu kita lanjut build menjadi exe dengan perintah ini:
pyinstaller --onefile --windowed --collect-all imageio_ffmpeg rotate_video.pyPenjelasan opsinya:
--onefile: hasilnya satu file exe saja.--windowed: tidak ada jendela hitam Command Prompt saat aplikasi dibuka.--collect-all imageio_ffmpeg: ffmpeg ikut dibawa ke dalam exe.--name "VideoRotator": nama exe-nyaVideoRotator.exe.
Ok selamat Mencoba
Script File rotate.py
import os
import subprocess
import sys
import threading
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
import imageio_ffmpeg
VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"}
FILTERS = {
"Putar 90° searah jarum jam": "transpose=1",
"Putar 90° berlawanan jarum jam": "transpose=2",
"Putar 180°": "transpose=1,transpose=1",
"Balik horizontal (mirror)": "hflip",
"Balik vertikal": "vflip",
}
# nama pilihan -> (CRF / kualitas, bitrate audio kbps). Angka CRF makin besar = file makin kecil.
QUALITY = {
"Tinggi (file besar)": (20, 160),
"Seimbang (disarankan)": (24, 128),
"Kecil": (28, 96),
"Sangat kecil": (32, 64),
}
# nama pilihan -> batas sisi terpendek video (None = tidak diubah)
RESOLUTION = {
"Sama dengan asli": None,
"1080p": 1080,
"720p": 720,
"480p": 480,
"360p": 360,
}
GPU_ENCODERS = [
("h264_nvenc", "NVIDIA"),
("h264_qsv", "Intel"),
("h264_amf", "AMD"),
]
# Palet warna: hitam, merah, kuning
BG = "#0e0e0e"
CARD = "#181818"
ENTRY = "#232323"
LINE = "#333333"
FG = "#f2f2f2"
MUTED = "#8c8c8c"
RED = "#e63946"
RED_H = "#ff5262"
YELLOW = "#ffc300"
YELLOW_H = "#ffd23f"
FONT = ("Segoe UI", 10)
FONT_B = ("Segoe UI", 10, "bold")
FONT_S = ("Segoe UI", 8, "bold")
# ---------- Logika video ----------
def output_name(src):
stem, _ = os.path.splitext(os.path.basename(src))
return f"{stem}_R.mp4"
def fmt_size(n):
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
n /= 1024
def build_vf(rotate_filter, max_short_side):
parts = []
if max_short_side:
h = max_short_side
# perkecil dulu (lebih sedikit piksel yang diputar), tidak pernah memperbesar
parts.append(
f"scale=w='if(gt(iw,ih),-2,min({h},iw))':h='if(gt(iw,ih),min({h},ih),-2)'")
parts.append(rotate_filter)
parts.append("scale=trunc(iw/2)*2:trunc(ih/2)*2") # dimensi harus genap
parts.append("format=yuv420p")
return ",".join(parts)
def video_args(crf, encoder=None):
if encoder == "h264_nvenc":
return ["-c:v", "h264_nvenc", "-preset", "p4", "-rc", "vbr",
"-cq", str(crf), "-b:v", "0"]
if encoder == "h264_qsv":
return ["-c:v", "h264_qsv", "-preset", "veryfast", "-global_quality", str(crf)]
if encoder == "h264_amf":
return ["-c:v", "h264_amf", "-quality", "speed", "-rc", "cqp",
"-qp_i", str(crf), "-qp_p", str(crf)]
return ["-c:v", "libx264", "-preset", "veryfast", "-crf", str(crf)]
def build_cmd(ffmpeg, src, dst, vf, crf, audio_kbps, encoder=None):
return ([ffmpeg, "-y", "-hide_banner", "-i", src,
"-map", "0:v:0", "-map", "0:a:0?", "-vf", vf]
+ video_args(crf, encoder)
+ ["-c:a", "aac", "-b:a", f"{audio_kbps}k",
"-movflags", "+faststart", "-metadata:s:v", "rotate=0", dst])
def popen_flags():
return subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
def detect_gpu_encoder(ffmpeg):
"""Uji encode singkat untuk tiap encoder GPU. Mengembalikan (encoder, label) atau (None, None)."""
for enc, label in GPU_ENCODERS:
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "color=c=black:s=320x240:d=0.2",
"-pix_fmt", "yuv420p"] + video_args(24, enc) + ["-f", "null", "-"]
try:
r = subprocess.run(cmd, capture_output=True, timeout=20,
creationflags=popen_flags())
if r.returncode == 0:
return enc, label
except Exception: # noqa: BLE001
continue
return None, None
def open_folder(path):
try:
if sys.platform == "win32":
os.startfile(path)
elif sys.platform == "darwin":
subprocess.Popen(["open", path])
else:
subprocess.Popen(["xdg-open", path])
except Exception as e: # noqa: BLE001
messagebox.showerror("Error", f"Tidak bisa membuka folder:\n{e}")
# ---------- Tampilan ----------
def make_button(parent, text, command, kind="primary", width=None):
palette = {
"primary": (RED, RED_H, "#ffffff"),
"accent": (YELLOW, YELLOW_H, "#111111"),
"ghost": ("#2a2a2a", "#3a3a3a", FG),
}
bg, hover, fg = palette[kind]
btn = tk.Button(
parent, text=text, command=command, bg=bg, fg=fg,
activebackground=hover, activeforeground=fg, disabledforeground="#666666",
relief="flat", bd=0, font=FONT_B, cursor="hand2", padx=16, pady=7,
width=width,
)
def on_enter(_):
if str(btn["state"]) == "normal":
btn.config(bg=hover)
def on_leave(_):
btn.config(bg=bg)
btn.bind("<Enter>", on_enter)
btn.bind("<Leave>", on_leave)
return btn
def make_entry(parent, var):
return tk.Entry(
parent, textvariable=var, bg=ENTRY, fg=FG, insertbackground=YELLOW,
relief="flat", font=FONT, highlightthickness=1,
highlightbackground=LINE, highlightcolor=YELLOW,
)
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("Video Rotator")
self.geometry("720x760")
self.minsize(680, 680)
self.configure(bg=BG)
self.source_type = tk.StringVar(value="file")
self.input_path = tk.StringVar()
self.output_dir = tk.StringVar()
self.mode = tk.StringVar(value="Putar 90° berlawanan jarum jam")
self.quality = tk.StringVar(value="Seimbang (disarankan)")
self.resolution = tk.StringVar(value="Sama dengan asli")
self.include_sub = tk.BooleanVar(value=True)
self.use_gpu = tk.BooleanVar(value=False)
self.status = tk.StringVar(value="Pilih video atau folder terlebih dahulu.")
self.gpu_encoder = None
self.cancel_flag = False
self.proc = None
self.setup_style()
self.build_ui()
self.on_type_change()
threading.Thread(target=self.detect_gpu, daemon=True).start()
def setup_style(self):
s = ttk.Style(self)
s.theme_use("clam")
s.configure("TCombobox", fieldbackground=ENTRY, background="#2a2a2a",
foreground=FG, arrowcolor=YELLOW, bordercolor=LINE,
lightcolor=ENTRY, darkcolor=ENTRY, padding=5)
s.map("TCombobox",
fieldbackground=[("readonly", ENTRY)],
foreground=[("readonly", FG)],
selectbackground=[("readonly", ENTRY)],
selectforeground=[("readonly", FG)],
bordercolor=[("focus", YELLOW)])
self.option_add("*TCombobox*Listbox.background", ENTRY)
self.option_add("*TCombobox*Listbox.foreground", FG)
self.option_add("*TCombobox*Listbox.selectBackground", RED)
self.option_add("*TCombobox*Listbox.selectForeground", "#ffffff")
self.option_add("*TCombobox*Listbox.font", FONT)
for name in ("TRadiobutton", "TCheckbutton"):
s.configure(name, background=CARD, foreground=FG, focuscolor=CARD,
font=FONT)
s.map(name,
background=[("active", CARD)],
foreground=[("disabled", "#666666")],
indicatorcolor=[("selected", YELLOW), ("!selected", "#3a3a3a")],
indicatorforeground=[("selected", "#111111")])
s.configure("Red.Horizontal.TProgressbar", troughcolor="#232323",
background=RED, bordercolor=BG, lightcolor=RED,
darkcolor=RED, thickness=8)
def card(self, title):
outer = tk.Frame(self, bg=CARD)
outer.pack(fill="x", padx=24, pady=(12, 0))
inner = tk.Frame(outer, bg=CARD)
inner.pack(fill="both", expand=True, padx=18, pady=14)
tk.Label(inner, text=title, bg=CARD, fg=YELLOW, font=FONT_S).grid(
row=0, column=0, columnspan=3, sticky="w", pady=(0, 8))
inner.columnconfigure(1, weight=1)
return inner
def build_ui(self):
# Header
header = tk.Frame(self, bg=BG)
header.pack(fill="x", padx=24, pady=(20, 6))
tk.Label(header, text="Video ", bg=BG, fg=FG,
font=("Segoe UI", 20, "bold")).pack(side="left")
tk.Label(header, text="Rotator", bg=BG, fg=YELLOW,
font=("Segoe UI", 20, "bold")).pack(side="left")
tk.Label(header, text="putar & kompres video",
bg=BG, fg=MUTED, font=FONT).pack(side="left", padx=(14, 0), pady=(8, 0))
tk.Frame(self, bg=RED, height=3).pack(fill="x", padx=24)
# Kartu file
a = self.card("FILE")
radios = tk.Frame(a, bg=CARD)
radios.grid(row=1, column=0, columnspan=3, sticky="w", pady=(0, 8))
ttk.Radiobutton(radios, text="Satu video", variable=self.source_type,
value="file", command=self.on_type_change).pack(side="left", padx=(0, 18))
ttk.Radiobutton(radios, text="Satu folder (semua video)", variable=self.source_type,
value="folder", command=self.on_type_change).pack(side="left")
self.input_label = tk.Label(a, text="Video", bg=CARD, fg=MUTED, font=FONT,
width=12, anchor="w")
self.input_label.grid(row=2, column=0, sticky="w", pady=4)
make_entry(a, self.input_path).grid(row=2, column=1, sticky="ew", ipady=6,
padx=(0, 10), pady=4)
make_button(a, "Browse", self.pick_input, "ghost").grid(row=2, column=2, pady=4)
tk.Label(a, text="Folder simpan", bg=CARD, fg=MUTED, font=FONT,
width=12, anchor="w").grid(row=3, column=0, sticky="w", pady=4)
make_entry(a, self.output_dir).grid(row=3, column=1, sticky="ew", ipady=6,
padx=(0, 10), pady=4)
make_button(a, "Pilih Folder", self.pick_output_dir, "ghost").grid(
row=3, column=2, pady=4)
self.chk_sub = ttk.Checkbutton(
a, text="Sertakan subfolder (struktur folder ikut dibuat di folder simpan)",
variable=self.include_sub)
self.chk_sub.grid(row=4, column=0, columnspan=3, sticky="w", pady=(8, 0))
# Kartu pengaturan
b = self.card("PENGATURAN")
tk.Label(b, text="Mode", bg=CARD, fg=MUTED, font=FONT, width=12,
anchor="w").grid(row=1, column=0, sticky="w", pady=4)
ttk.Combobox(b, textvariable=self.mode, values=list(FILTERS),
state="readonly", font=FONT).grid(
row=1, column=1, columnspan=2, sticky="ew", pady=4)
tk.Label(b, text="Ukuran file", bg=CARD, fg=MUTED, font=FONT, width=12,
anchor="w").grid(row=2, column=0, sticky="w", pady=4)
row2 = tk.Frame(b, bg=CARD)
row2.grid(row=2, column=1, columnspan=2, sticky="ew", pady=4)
row2.columnconfigure(0, weight=3)
row2.columnconfigure(2, weight=2)
ttk.Combobox(row2, textvariable=self.quality, values=list(QUALITY),
state="readonly", font=FONT).grid(row=0, column=0, sticky="ew")
tk.Label(row2, text="Resolusi", bg=CARD, fg=MUTED, font=FONT).grid(
row=0, column=1, padx=(14, 8))
ttk.Combobox(row2, textvariable=self.resolution, values=list(RESOLUTION),
state="readonly", font=FONT).grid(row=0, column=2, sticky="ew")
self.chk_gpu = ttk.Checkbutton(b, text="Mendeteksi GPU...", variable=self.use_gpu)
self.chk_gpu.grid(row=3, column=0, columnspan=3, sticky="w", pady=(8, 0))
self.chk_gpu.state(["disabled"])
tk.Label(b, text="Hasil disimpan sebagai namafile_R.mp4",
bg=CARD, fg=MUTED, font=("Segoe UI", 9)).grid(
row=4, column=0, columnspan=3, sticky="w", pady=(6, 0))
# Tombol aksi
actions = tk.Frame(self, bg=BG)
actions.pack(fill="x", padx=24, pady=(16, 8))
self.btn = make_button(actions, "PROSES", self.start, "primary", width=18)
self.btn.pack(side="left")
self.btn_cancel = make_button(actions, "Batal", self.cancel, "ghost", width=10)
self.btn_cancel.config(state="disabled")
self.btn_cancel.pack(side="left", padx=10)
# Progress + status
self.bar = ttk.Progressbar(self, mode="determinate",
style="Red.Horizontal.TProgressbar")
self.bar.pack(fill="x", padx=24)
tk.Label(self, textvariable=self.status, bg=BG, fg=MUTED, font=FONT,
anchor="w").pack(fill="x", padx=24, pady=(6, 6))
# Log
log_wrap = tk.Frame(self, bg=CARD)
log_wrap.pack(fill="both", expand=True, padx=24, pady=(0, 20))
self.log = tk.Text(log_wrap, height=6, state="disabled", bg=CARD, fg=FG,
relief="flat", font=("Consolas", 9), padx=12, pady=10,
highlightthickness=0, wrap="none")
scroll = tk.Scrollbar(log_wrap, command=self.log.yview, bg=CARD,
troughcolor=CARD, relief="flat", bd=0)
self.log.config(yscrollcommand=scroll.set)
scroll.pack(side="right", fill="y")
self.log.pack(side="left", fill="both", expand=True)
self.log.tag_config("ok", foreground=YELLOW)
self.log.tag_config("fail", foreground=RED)
self.log.tag_config("muted", foreground=MUTED)
# ---------- Helper UI ----------
def detect_gpu(self):
enc, label = detect_gpu_encoder(imageio_ffmpeg.get_ffmpeg_exe())
self.after(0, self.on_gpu_detected, enc, label)
def on_gpu_detected(self, enc, label):
self.gpu_encoder = enc
if enc:
self.chk_gpu.config(text=f"Gunakan GPU {label} (lebih cepat)")
self.chk_gpu.state(["!disabled"])
self.use_gpu.set(True)
else:
self.chk_gpu.config(text="GPU tidak terdeteksi, memakai CPU")
def on_type_change(self):
self.input_path.set("")
if self.source_type.get() == "file":
self.input_label.config(text="Video input")
self.chk_sub.state(["disabled"])
else:
self.input_label.config(text="Folder input")
self.chk_sub.state(["!disabled"])
def write_log(self, text, tag=None):
self.log.config(state="normal")
self.log.insert("end", text + "\n", tag)
self.log.see("end")
self.log.config(state="disabled")
def pick_input(self):
if self.source_type.get() == "file":
path = filedialog.askopenfilename(
filetypes=[("Video", "*.mp4 *.mov *.mkv *.avi *.webm *.m4v"),
("Semua file", "*.*")])
base = os.path.dirname(path) if path else ""
else:
path = filedialog.askdirectory(title="Pilih folder berisi video")
base = path
if path:
self.input_path.set(os.path.normpath(path))
if not self.output_dir.get():
self.output_dir.set(os.path.normpath(base))
def pick_output_dir(self):
folder = filedialog.askdirectory(
title="Pilih folder tempat menyimpan",
initialdir=self.output_dir.get() or None)
if folder:
self.output_dir.set(os.path.normpath(folder))
# ---------- Proses ----------
def is_video(self, name):
stem, ext = os.path.splitext(name)
return ext.lower() in VIDEO_EXTS and not stem.endswith("_R")
def collect_sources(self):
"""Mengembalikan daftar (path_video, folder_akar)."""
path = self.input_path.get().strip()
if self.source_type.get() == "file":
return [(path, os.path.dirname(path))] if os.path.isfile(path) else []
if not os.path.isdir(path):
return []
results = []
if self.include_sub.get():
for dirpath, dirnames, filenames in os.walk(path):
dirnames.sort()
for n in sorted(filenames):
if self.is_video(n):
results.append((os.path.join(dirpath, n), path))
else:
for n in sorted(os.listdir(path)):
full = os.path.join(path, n)
if os.path.isfile(full) and self.is_video(n):
results.append((full, path))
return results
def start(self):
sources = self.collect_sources()
folder = self.output_dir.get().strip()
if not sources:
messagebox.showerror(
"Error", "Tidak ada video yang ditemukan. Periksa input yang dipilih.")
return
if not folder or not os.path.isdir(folder):
messagebox.showerror("Error", "Pilih folder simpan yang valid.")
return
jobs = []
for src, root in sources:
rel_dir = os.path.relpath(os.path.dirname(src), root)
dst = os.path.normpath(os.path.join(folder, rel_dir, output_name(src)))
label = os.path.relpath(src, root)
jobs.append((src, dst, label))
existing = [d for _, d, _ in jobs if os.path.exists(d)]
if existing:
ans = messagebox.askyesnocancel(
"File sudah ada",
f"{len(existing)} file hasil sudah ada di folder tujuan.\n\n"
"Ya = timpa semua\nTidak = lewati yang sudah ada\nBatal = hentikan")
if ans is None:
return
if ans is False:
jobs = [j for j in jobs if not os.path.exists(j[1])]
if not jobs:
messagebox.showinfo("Info", "Semua file sudah ada, tidak ada yang diproses.")
return
crf, audio_kbps = QUALITY[self.quality.get()]
settings = {
"vf": build_vf(FILTERS[self.mode.get()], RESOLUTION[self.resolution.get()]),
"crf": crf,
"audio": audio_kbps,
"gpu": self.gpu_encoder if self.use_gpu.get() else None,
}
self.cancel_flag = False
self.btn.config(state="disabled")
self.btn_cancel.config(state="normal")
self.log.config(state="normal")
self.log.delete("1.0", "end")
self.log.config(state="disabled")
if len(jobs) == 1:
self.bar.config(mode="indeterminate")
self.bar.start(12)
else:
self.bar.config(mode="determinate", maximum=len(jobs), value=0)
engine = "GPU" if settings["gpu"] else "CPU"
self.write_log(f"{len(jobs)} video akan diproses ({engine})", "muted")
threading.Thread(target=self.worker, args=(jobs, settings), daemon=True).start()
def cancel(self):
self.cancel_flag = True
if self.proc and self.proc.poll() is None:
self.proc.terminate()
self.status.set("Membatalkan...")
def run_cmd(self, cmd):
try:
self.proc = subprocess.Popen(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
text=True, creationflags=popen_flags())
_, err = self.proc.communicate()
return self.proc.returncode, err
except Exception as e: # noqa: BLE001
return 1, str(e)
def worker(self, jobs, st):
ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
total = len(jobs)
ok_count, fail_count = 0, 0
in_total, out_total = 0, 0
for i, (src, dst, label) in enumerate(jobs, 1):
if self.cancel_flag:
break
self.after(0, self.status.set, f"Memproses {i}/{total}: {label}")
os.makedirs(os.path.dirname(dst), exist_ok=True)
code, err = 1, ""
if st["gpu"]:
code, err = self.run_cmd(build_cmd(
ffmpeg, src, dst, st["vf"], st["crf"], st["audio"], st["gpu"]))
if code != 0 and not self.cancel_flag:
self.after(0, self.write_log,
f"[INFO] GPU gagal untuk {label}, mencoba CPU", "muted")
if code != 0 and not self.cancel_flag:
code, err = self.run_cmd(build_cmd(
ffmpeg, src, dst, st["vf"], st["crf"], st["audio"], None))
if self.cancel_flag:
if os.path.exists(dst):
try:
os.remove(dst)
except OSError:
pass
self.after(0, self.write_log, f"[BATAL] {label}", "muted")
break
if code == 0:
ok_count += 1
try:
s_in, s_out = os.path.getsize(src), os.path.getsize(dst)
in_total += s_in
out_total += s_out
info = f" ({fmt_size(s_in)} -> {fmt_size(s_out)})"
except OSError:
info = ""
self.after(0, self.write_log, f"[OK] {label}{info}", "ok")
else:
fail_count += 1
last = (err or "").strip().splitlines()[-1:] or ["error tidak diketahui"]
self.after(0, self.write_log, f"[GAGAL] {label}: {last[0]}", "fail")
self.after(0, lambda v=i: self.bar.configure(value=v) if
str(self.bar.cget("mode")) == "determinate" else None)
self.after(0, self.finish, ok_count, fail_count, total, in_total, out_total)
def finish(self, ok, fail, total, in_total, out_total):
self.bar.stop()
self.bar.config(mode="determinate", maximum=max(total, 1),
value=0 if self.cancel_flag else total)
self.btn.config(state="normal")
self.btn_cancel.config(state="disabled")
if self.cancel_flag:
self.status.set(f"Dibatalkan. Berhasil {ok} dari {total}.")
return
size_text = ""
if in_total and out_total:
size_text = f"Ukuran: {fmt_size(in_total)} -> {fmt_size(out_total)}"
self.write_log(f"Total: {fmt_size(in_total)} -> {fmt_size(out_total)}", "muted")
self.status.set(f"Selesai. Berhasil {ok}, gagal {fail}, dari {total} video.")
self.show_done(ok, fail, size_text)
def show_done(self, ok, fail, size_text):
folder = self.output_dir.get()
dlg = tk.Toplevel(self)
dlg.title("Selesai")
dlg.configure(bg=CARD)
dlg.transient(self)
dlg.resizable(False, False)
body = tk.Frame(dlg, bg=CARD)
body.pack(padx=28, pady=(22, 8), anchor="w")
tk.Label(body, text="Selesai!", bg=CARD, fg=YELLOW,
font=("Segoe UI", 16, "bold")).pack(anchor="w")
stats = tk.Frame(body, bg=CARD)
stats.pack(anchor="w", pady=(10, 0))
tk.Label(stats, text=f"Berhasil: {ok}", bg=CARD, fg=FG, font=FONT).pack(side="left")
tk.Label(stats, text=f"Gagal: {fail}", bg=CARD, fg=RED if fail else MUTED,
font=FONT).pack(side="left", padx=(18, 0))
if size_text:
tk.Label(body, text=size_text, bg=CARD, fg=MUTED, font=FONT).pack(
anchor="w", pady=(4, 0))
tk.Label(body, text="Disimpan di (klik untuk membuka):", bg=CARD, fg=MUTED,
font=("Segoe UI", 9)).pack(anchor="w", pady=(14, 2))
link = tk.Label(body, text=folder, bg=CARD, fg=YELLOW, cursor="hand2",
font=("Segoe UI", 10, "underline"), wraplength=420, justify="left")
link.pack(anchor="w")
def do_open(_event=None):
open_folder(folder)
dlg.destroy()
link.bind("<Button-1>", do_open)
btns = tk.Frame(dlg, bg=CARD)
btns.pack(fill="x", padx=28, pady=(14, 22))
make_button(btns, "Buka Folder", do_open, "primary", width=16).pack(side="left")
make_button(btns, "Tutup", dlg.destroy, "ghost", width=10).pack(side="left", padx=10)
dlg.update_idletasks()
x = self.winfo_x() + (self.winfo_width() - dlg.winfo_width()) // 2
y = self.winfo_y() + (self.winfo_height() - dlg.winfo_height()) // 2
dlg.geometry(f"+{max(x, 0)}+{max(y, 0)}")
dlg.grab_set()
dlg.focus_set()
if __name__ == "__main__":
App().mainloop()