Cara Membuat Aplikasi Desktop Python: Rotasi & Kompres Video Otomatis dengan Tkinter dan FFmpeg Standalone

Admin

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_video

2. Simpan skrip ke folder itu

  • Unduh file rotate_video.py File ada di akhir artikel
  • Pindahkan atau salin ke C:\rotate_video.
  • Cek dengan perintah ini. Nama rotate_video.py harus muncul di daftar:

3. Pasang paket yang dibutuhkan

pip install imageio-ffmpeg pyinstaller

Tunggu 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.py

Penjelasan 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-nya VideoRotator.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()




Instagram

Ikuti di Instagram
Tempat kode HTML / iklan (rasio 4:5). Ganti isi widget "Iklan 4:5" lewat menu Tata Letak.