๐ŸŽฌ Python ๋ฐ Tkinter๋กœ ๋ฆด๋ž™์Šค ๋น„๋””์˜ค ์ƒ์„ฑ๊ธฐ ๋งŒ๋“ค๊ธฐ (Images + MP3 MP4)

๋ฐœํ–‰: (2026๋…„ 1์›” 11์ผ ์˜คํ›„ 01:48 GMT+9)
5 min read
์›๋ฌธ: Dev.to

Source: Dev.to

๐Ÿงฐ ์šฐ๋ฆฌ๊ฐ€ ์‚ฌ์šฉํ•  ๊ฒƒ

  • Python
  • Tkinter (GUI)
  • ttkbootstrap (๋ชจ๋˜ UI ํ…Œ๋งˆ)
  • Pillow (PIL) ์ด๋ฏธ์ง€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ฅผ ์œ„ํ•ด
  • FFmpeg ๋น„๋””์˜ค ๋ Œ๋”๋ง์„ ์œ„ํ•ด
  • Threading (UI๊ฐ€ ๋ฉˆ์ถ”์ง€ ์•Š๋„๋ก)

๐Ÿ“ฆ ๋‹จ๊ณ„โ€ฏ1: ์š”๊ตฌ ์‚ฌํ•ญ ์„ค์น˜

pip install ttkbootstrap pillow

FFmpeg๊ฐ€ ์„ค์น˜๋˜์–ด ์žˆ๋Š”์ง€ ํ™•์ธํ•˜๊ณ  ๊ฒฝ๋กœ๋ฅผ ๊ธฐ๋กํ•˜์„ธ์š”, ์˜ˆ:

C:\ffmpeg\bin\ffmpeg.exe

๐ŸชŸ ๋‹จ๊ณ„โ€ฏ2: ๋ฉ”์ธ ์•ฑ ์ฐฝ ๋งŒ๋“ค๊ธฐ

๋ชจ๋“  ๊ฒƒ์„ importํ•˜๊ณ  ๋ฉ”์ธ ์ฐฝ์„ ์ƒ์„ฑํ•˜๋Š” ๊ฒƒ์œผ๋กœ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.

import tkinter as tk
from tkinter import filedialog, messagebox
import ttkbootstrap as tb
from PIL import Image, ImageTk
import subprocess
import os
import threading
import time
import re
app = tb.Window(
    title="Relax Video Builder โ€“ Images + MP3 to MP4",
    themename="superhero",
    size=(950, 650),
    resizable=(False, False)
)

ํŒ: ttkbootstrap๋Š” ๊ฑฐ์˜ ์ถ”๊ฐ€ ์ž‘์—… ์—†์ด ํ˜„๋Œ€์ ์ธ ์Šคํƒ€์ผ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

๐Ÿง  Stepโ€ฏ3: ์•ฑ ์ƒํƒœ ๋ณ€์ˆ˜

์ด ๋ณ€์ˆ˜๋“ค์€ ์„ ํƒ๋œ ํŒŒ์ผ๊ณผ ์•ฑ ์ƒํƒœ๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.

# Selected files / user input
image_files = []
mp3_path = tk.StringVar()
output_path = tk.StringVar()
hours_var = tk.IntVar(value=10)   # Desired video length in hours

# Rendering state
process = None
rendering = False
total_seconds = 0

# FFmpeg path (โš ๏ธ change this if yours is different)
FFMPEG_PATH = r"C:\ffmpeg\bin\ffmpeg.exe"

๐Ÿ–ผ Stepโ€ฏ4: ์ด๋ฏธ์ง€ ์„ ํƒ ๋ฐ ๊ด€๋ฆฌ

์ด๋ฏธ์ง€ ์„ ํƒ

def select_images():
    files = filedialog.askopenfilenames(
        filetypes=[("Images", "*.jpg *.png")]
    )
    if files:
        image_files.extend(files)
        refresh_images()

์ด๋ฏธ์ง€ ๋ชฉ๋ก ์ƒˆ๋กœ ๊ณ ์นจ

def refresh_images():
    image_listbox.delete(0, tk.END)
    for img in image_files:
        image_listbox.insert(tk.END, os.path.basename(img))
    image_count_label.config(text=f"{len(image_files)} image(s) selected")

์ด๋ฏธ์ง€ ์ œ๊ฑฐ

def remove_selected_images():
    sel = image_listbox.curselection()
    for i in reversed(sel):
        del image_files[i]
    refresh_images()

def remove_all_images():
    image_files.clear()
    refresh_images()
    preview_label.config(image="")

๐Ÿ‘€ Stepโ€ฏ5: ์ด๋ฏธ์ง€ ํด๋ฆญ ์‹œ ๋ฏธ๋ฆฌ๋ณด๊ธฐ

์ด๋ฏธ์ง€๋ฅผ ํด๋ฆญํ•˜๋ฉด ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ฅผ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค.

def on_image_select(event):
    sel = image_listbox.curselection()
    if not sel:
        return

    img = Image.open(image_files[sel[0]])
    img.thumbnail((350, 250))
    tk_img = ImageTk.PhotoImage(img)

    preview_label.config(image=tk_img)
    preview_label.image = tk_img   # keep a reference

๐ŸŽต ๋‹จ๊ณ„โ€ฏ6: MP3 ์„ ํƒ

def select_mp3():
    mp3 = filedialog.askopenfilename(
        filetypes=[("MP3", "*.mp3")]
    )
    if mp3:
        mp3_path.set(mp3)

def remove_mp3():
    mp3_path.set("")

๐Ÿ“ ๋‹จ๊ณ„โ€ฏ7: ์ถœ๋ ฅ ํŒŒ์ผ ์„ ํƒ

def select_output():
    out = filedialog.asksaveasfilename(
        defaultextension=".mp4",
        filetypes=[("MP4", "*.mp4")]
    )
    if out:
        output_path.set(out)

โ–ถ๏ธ Stepโ€ฏ8: ์‹œ์ž‘ / ์ค‘์ง€ ๋ Œ๋”๋ง

์‹œ์ž‘ ๋ฒ„ํŠผ ๋กœ์ง

def build_video():
    if rendering:
        return

    if not image_files or not mp3_path.get() or not output_path.get():
        messagebox.showerror("Error", "Missing images, MP3, or output file.")
        return

    threading.Thread(
        target=run_ffmpeg,
        daemon=True
    ).start()

์ค‘์ง€ ๋ฒ„ํŠผ

def stop_video():
    global process, rendering
    if process:
        process.terminate()
        process = None
        rendering = False
        status_label.config(text="Rendering stopped.")
        resume_btn.config(state="normal")

๐ŸŽž Stepโ€ฏ9: FFmpeg ๋ Œ๋”๋ง ๋กœ์ง

์ด๋ฏธ์ง€๋‹น ์ง€์† ์‹œ๊ฐ„ ๊ณ„์‚ฐ

total_seconds = hours_var.get() * 3600
seconds_per_image = total_seconds / len(image_files)

FFmpeg ์ด๋ฏธ์ง€ ๋ฆฌ์ŠคํŠธ ์ƒ์„ฑ

list_file = "images.txt"
with open(list_file, "w", encoding="utf-8") as f:
    for img in image_files:
        f.write(f"file '{img}'\n")
        f.write(f"duration {seconds_per_image}\n")
    # repeat last image to avoid a short freeze at the end
    f.write(f"file '{image_files[-1]}'\n")

FFmpeg ๋ช…๋ น

cmd = [
    FFMPEG_PATH, "-y",
    "-stream_loop", "-1",
    "-i", mp3_path.get(),
    "-f", "concat", "-safe", "0",
    "-i", list_file,
    "-t", str(total_seconds),
    "-vf", "scale=1920:1080",
    "-c:v", "libx264",
    "-pix_fmt", "yuv420p",
    "-preset", "slow",
    "-crf", "18",
    "-c:a", "aac",
    "-b:a", "192k",
    output_path.get()
]

๐Ÿ“Š Stepโ€ฏ10: ์ง„ํ–‰๋ฅ  ํ‘œ์‹œ์ค„ ์ถ”์ 

FFmpeg์˜ ์ถœ๋ ฅ์„ ํŒŒ์‹ฑํ•˜์—ฌ ์ง„ํ–‰๋ฅ ์„ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค.

time_pattern = re.compile(r"time=(\d+):(\d+):(\d+)")

for line in process.stderr:
    match = time_pattern.search(line)
    if match:
        h, m, s = map(int, match.groups())
        current = h * 3600 + m * 60 + s
        percent = (current / total_seconds) * 100

        progress_bar['value'] = percent
        status_label.config(
            text=f"Rendering... {int(percent)}%"
        )

๐ŸŽ‰ ์™„๋ฃŒ!

์Šคํฌ๋ฆฝํŠธ๋ฅผ ์‹คํ–‰ํ•˜๊ณ  ์ด๋ฏธ์ง€, MP3, ์ถœ๋ ฅ ์œ„์น˜๋ฅผ ์„ ํƒํ•œ ๋’ค ์›ํ•˜๋Š” ๋น„๋””์˜ค ๊ธธ์ด๋ฅผ ์„ค์ •ํ•˜๊ณ  Build Video ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜์„ธ์š”. ์•ฑ์ด ๋ถ€๋“œ๋Ÿฝ๊ฒŒ ๋ฐ˜๋ณต๋˜๋Š” MP4 ํŒŒ์ผ์„ ์ƒ์„ฑํ•ด ์ฃผ๋ฉฐ, ์ด๋ฅผ YouTube์— ์—…๋กœ๋“œํ•˜๊ฑฐ๋‚˜ ๋ช…์ƒ์— ์‚ฌ์šฉํ•˜๊ฑฐ๋‚˜ ์นœ๊ตฌ์™€ ๊ณต์œ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ฆ๊ฑฐ์šด ์ฝ”๋”ฉ ๋˜์„ธ์š”!

๐Ÿงฑ ๋‹จ๊ณ„โ€ฏ11: UI ๋ ˆ์ด์•„์›ƒ ๊ตฌ์ถ•

# Main container
main = tb.Frame(app, padding=15)
main.pack(fill="both", expand=True)

# Left panel (images)
left = tb.Labelframe(main, text="Images", padding=10)
left.pack(side="left", fill="y")

# Center preview
center = tb.Labelframe(main, text="Preview", padding=10)
center.pack(side="left", fill="both", expand=True)

# Right settings panel
right = tb.Labelframe(main, text="Audio & Settings", padding=10)
right.pack(side="right", fill="y")

๐Ÿš€ Stepโ€ฏ12: ์•ฑ ์‹คํ–‰

app.mainloop()

โœ… ์ตœ์ข… ๊ฒฐ๊ณผ

  • ์ด๋ฏธ์ง€โ€ฏ+โ€ฏMP3๋ฅผ ๊ฒฐํ•ฉํ•ฉ๋‹ˆ๋‹ค
  • ๊ธด ํŽธ์•ˆํ•œ ๋น„๋””์˜ค๋ฅผ ์ œ์ž‘ํ•ฉ๋‹ˆ๋‹ค
  • ์‹ค์‹œ๊ฐ„์œผ๋กœ ์ง„ํ–‰ ์ƒํ™ฉ์„ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค
  • ํ˜„๋Œ€์ ์ธ UI๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค
  • ์•ˆ์ „ํ•˜๊ฒŒ ์ค‘์ง€ํ•˜๊ณ  ๋‹ค์‹œ ์‹œ์ž‘ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค

๐Ÿ’ก ํ™•์žฅ ์•„์ด๋””์–ด

  • ํŽ˜์ด๋“œโ€‘์ธ/์•„์›ƒ ์ „ํ™˜ ์ถ”๊ฐ€
  • ์ด๋ฏธ์ง€ ์ˆœ์„œ ๋ฌด์ž‘์œ„ํ™”
  • ํ…์ŠคํŠธ ์˜ค๋ฒ„๋ ˆ์ด ์ถ”๊ฐ€
  • ๋งˆ์ง€๋ง‰์œผ๋กœ ์‚ฌ์šฉํ•œ ํด๋” ๊ธฐ์–ต
  • YouTube์šฉ ํ”„๋ฆฌ์…‹ ๋‚ด๋ณด๋‚ด๊ธฐ

Relax Video Builder โ€“ Images + MP3 to MP4

Back to Blog

๊ด€๋ จ ๊ธ€

๋” ๋ณด๊ธฐ ยป

Python, Tkinter, MSS๋ฅผ ์‚ฌ์šฉํ•œ ํ™”๋ฉด ์บก์ฒ˜ ๋ฐ ์Šค์ฝ”ํ”„ ๋„๊ตฌ ๋งŒ๋“ค๊ธฐ

์‹ค์‹œ๊ฐ„ ํ™”๋ฉด ์บก์ฒ˜ GUI์™€ ์Šค์ฝ”ํ”„: ์ด ํŠœํ† ๋ฆฌ์–ผ์—์„œ๋Š” ์‹ค์‹œ๊ฐ„์œผ๋กœ ํ™”๋ฉด์„ ์บก์ฒ˜ํ•˜๊ณ  ๋น„๋””์˜ค ์Šค์ฝ”ํ”„ ๋ฒกํ„ฐ๋ฅผ ํ‘œ์‹œํ•˜๋Š” ์ž‘์€ GUI ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค ๊ฒƒ์ž…๋‹ˆ๋‹ค.

Python์œผ๋กœ Google ์ž๋™์™„์„ฑ ํ‚ค์›Œ๋“œ ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค (์†Œ์Šค ์ฝ”๋“œ ํฌํ•จ)

๋Œ€๋ถ€๋ถ„์˜ ํ‚ค์›Œ๋“œ ๋„๊ตฌ๋Š” Google Autocomplete์„ ๊ฐ์‹ธ๋Š” ์–‡์€ ๋ž˜ํผ์— ๋ถˆ๊ณผํ•˜์ง€๋งŒ, ๊ตฌ๋…, API, ๊ทธ๋ฆฌ๊ณ  ์‚ฌ์šฉ์ž๊ฐ€ ์ ‘๊ทผํ•˜๊ธฐ ์–ด๋ ค์šด ๋Œ€์‹œ๋ณด๋“œ ๋’ค์— ๊ตฌํ˜„์„ ์ˆจ๊น๋‹ˆ๋‹ค.

Python๊ณผ Tkinter๋กœ ๊ฐ„๋‹จํ•œ ํŒŒ์ผ ํƒ์ƒ‰๊ธฐ ๋งŒ๋“ค๊ธฐ โ€“ FileMate Explorer

๐Ÿ“‚ FileMate Explorer โ€“ ๊ฐ€๋ฒผ์šด ํŒŒ์ด์ฌ ํŒŒ์ผ ๊ด€๋ฆฌ์ž. ์™„์ „ํžˆ ํŒŒ์ด์ฌ์œผ๋กœ ๋งŒ๋“  ๊ฐ€๋ฒผ์šด ํŒŒ์ผ ํƒ์ƒ‰๊ธฐ๋ฅผ ์›ํ•˜์…จ๋‚˜์š”? Tkinter ๊ธฐ๋ฐ˜ FileMate Explorer๋ฅผ ๋งŒ๋‚˜๋ณด์„ธ์š”.