๐ฌ Python ๋ฐ Tkinter๋ก ๋ฆด๋์ค ๋น๋์ค ์์ฑ๊ธฐ ๋ง๋ค๊ธฐ (Images + MP3 MP4)
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์ฉ ํ๋ฆฌ์ ๋ด๋ณด๋ด๊ธฐ
