사진 폴더, 교가 mp3, 타이틀화면, 유지시간, 해상도, 가사 캡션 입력할 수 있다.
클로드에 요청해서 만든 코드.
"""
photo_to_mp4.py (moviepy 2.x 호환 / 타이틀 + 자막 오버레이 + 전환효과)
========================================================================
사용법 (파워셀):
python .\photo_to_mp4.py `
--folder ".\사진들\사용" `
--audio ".\교가1절.mp3" `
--output "교가1절_26년_1920x1080.mp4" `
--title ".\처음화면_16vs9_text2.jpg" `
--title-duration 14 `
--width 1920 `
--height 1080 `
--caption "15,8,40,가사" `
--caption "24,8,40,가사" `
--caption "32.5,9.5,40,가사" `
--caption "42,9,40,가사" `
--caption "51,8,40,가사" `
--caption "59,8,40,가사"
자막 형식: 시작초,지속초,하단여백px,텍스트
의존 패키지:
pip install moviepy pillow mutagen numpy pydub
"""
import argparse
import os
import random
import sys
from pathlib import Path
import numpy as np
# ── moviepy import (2.x / 1.x 자동 감지) ────────────────────────────────────
try:
from moviepy import ImageClip, AudioFileClip, concatenate_videoclips, CompositeVideoClip, VideoClip
MOVIEPY_V2 = True
except ImportError:
try:
from moviepy.editor import ImageClip, AudioFileClip, concatenate_videoclips, CompositeVideoClip, VideoClip
MOVIEPY_V2 = False
except ImportError:
print("❌ moviepy 가 없습니다. pip install moviepy 후 다시 실행하세요.")
sys.exit(1)
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
print("❌ Pillow 가 없습니다. pip install pillow 후 다시 실행하세요.")
sys.exit(1)
try:
from mutagen import File as MutagenFile
except ImportError:
print("❌ mutagen 이 없습니다. pip install mutagen 후 다시 실행하세요.")
sys.exit(1)
# ── 상수 ─────────────────────────────────────────────────────────────────────
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".gif", ".tiff", ".webp"}
TRANSITION_DURATION = 1 # crossfade 전환 길이 (초)
CAPTION_FONT_SIZE = 36*2
CAPTION_BG_ALPHA = 160 # 자막 배경 투명도 (0=완전투명, 255=불투명)
CAPTION_PAD = 12 # 자막 박스 내부 패딩 (px)
# ─────────────────────────────────────────────────────────────────────────────
# 유틸
# ─────────────────────────────────────────────────────────────────────────────
def get_audio_duration(audio_path: str) -> float:
audio = MutagenFile(audio_path)
if audio is None or not hasattr(audio, "info"):
raise ValueError(f"오디오 파일을 읽을 수 없습니다: {audio_path}")
return float(audio.info.length)
def collect_subfolders(root: str) -> list:
root_path = Path(root)
folders = []
for dirpath, dirnames, filenames in os.walk(root_path):
dirnames.sort()
if any(Path(f).suffix.lower() in IMAGE_EXTS for f in filenames):
folders.append(Path(dirpath))
return folders
def pick_all_images_from_folders(folders: list) -> list:
"""각 폴더의 모든 이미지를 수집합니다. shuffle=True 이면 랜덤 순서로 반환합니다."""
all_images = []
for folder in folders:
images = sorted(p for p in folder.iterdir()
if p.is_file() and p.suffix.lower() in IMAGE_EXTS)
all_images.extend(images)
return all_images
def find_font(size: int):
candidates = [
"C:/Windows/Fonts/malgun.ttf",
"C:/Windows/Fonts/malgunbd.ttf",
"C:/Windows/Fonts/gulim.ttc",
"C:/Windows/Fonts/batang.ttc",
"/System/Library/Fonts/AppleSDGothicNeo.ttc",
"/Library/Fonts/AppleGothic.ttf",
"/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
for path in candidates:
if os.path.exists(path):
try:
return ImageFont.truetype(path, size)
except Exception:
continue
return ImageFont.load_default()
def prepare_image(img_path: Path, target_size: tuple, tmp_dir: Path, suffix: str = "") -> Path:
"""이미지를 target_size 캔버스에 중앙 배치 후 임시 JPEG로 저장합니다."""
with open(img_path, "rb") as f:
raw = Image.open(f)
raw.load()
im = raw.copy()
im = im.convert("RGB")
im.thumbnail(target_size, Image.LANCZOS)
canvas = Image.new("RGB", target_size, (0, 0, 0))
offset = ((target_size[0] - im.width) // 2,
(target_size[1] - im.height) // 2)
canvas.paste(im, offset)
name = f"__tmp_{img_path.stem}_{img_path.parent.name}{suffix}.jpg"
tmp_path = tmp_dir / name
canvas.save(tmp_path, "JPEG", quality=95)
return tmp_path
# ─────────────────────────────────────────────────────────────────────────────
# 자막 오버레이 (RGB + alpha 마스크 분리 방식)
# ─────────────────────────────────────────────────────────────────────────────
def render_caption_rgba(text: str, target_size: tuple, bottom_margin: int) -> np.ndarray:
"""자막을 RGBA numpy 배열로 렌더링합니다 (중앙 정렬, 하단 여백 지정)."""
W, H = target_size
font = find_font(CAPTION_FONT_SIZE)
# 텍스트 크기 측정
dummy = Image.new("RGBA", (1, 1))
draw = ImageDraw.Draw(dummy)
bbox = draw.textbbox((0, 0), text, font=font)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
pad = CAPTION_PAD
bw = tw + pad * 2
bh = th + pad * 2
# 수평 중앙, 하단에서 bottom_margin px 위
bx = (W - bw) // 2
by = H - bh - bottom_margin
overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
draw.rounded_rectangle(
[bx, by, bx + bw, by + bh],
radius=8,
fill=(0, 0, 0, CAPTION_BG_ALPHA),
)
draw.text(
(bx + pad - bbox[0], by + pad - bbox[1]),
text,
font=font,
fill=(255, 255, 255, 255),
)
return np.array(overlay) # shape: (H, W, 4)
def make_caption_clip(text: str, duration: float, start: float,
target_size: tuple, bottom_margin: int = 40):
"""
자막을 동영상 위에 올릴 RGB VideoClip으로 생성합니다.
배경은 투명(검정 0,0,0)이고 마스크로 알파를 전달하여
CompositeVideoClip에서 올바르게 overlay됩니다.
"""
rgba = render_caption_rgba(text, target_size, bottom_margin)
rgb_arr = rgba[..., :3].astype(np.uint8) # (H,W,3)
alpha_arr = (rgba[..., 3] / 255.0).astype(np.float32) # (H,W) 0~1
FADE = 0.3 # 등장/퇴장 페이드 시간
def make_rgb_frame(t):
return rgb_arr
def make_mask_frame(t):
scale = 1.0
if t < FADE:
scale = t / FADE
elif t > duration - FADE:
scale = max(0.0, (duration - t) / FADE)
return (alpha_arr * scale) # (H,W) float 0~1, moviepy mask 형식
# RGB 클립
rgb_clip = VideoClip(make_rgb_frame, duration=duration)
# 마스크 클립 (is_mask=True → 0~1 float 단채널)
mask_clip = VideoClip(make_mask_frame, duration=duration, is_mask=True)
caption_clip = rgb_clip.with_mask(mask_clip) if MOVIEPY_V2 else rgb_clip.set_mask(mask_clip)
caption_clip = caption_clip.with_start(start) if MOVIEPY_V2 else caption_clip.set_start(start)
return caption_clip
# ─────────────────────────────────────────────────────────────────────────────
# CrossFade (numpy 기반 fade-in / fade-out)
# ─────────────────────────────────────────────────────────────────────────────
def apply_crossfade(clips: list, transition: float):
"""
클립 사이를 진짜 dissolve로 연결합니다.
각 클립은 독립적으로 배치하지 않고,
전환 구간에서 앞/뒤 프레임을 numpy로 직접 weighted blend합니다.
결과는 단일 VideoClip 하나로 반환됩니다.
"""
if len(clips) == 0:
return [], 0.0
# 각 클립의 시작/종료 시간 계산 (겹침 없이 순수 표시 시간 기준)
# 실제 타임라인상 시작점
starts = []
t = 0.0
for i, clip in enumerate(clips):
starts.append(t)
t += clip.duration - (transition if i < len(clips) - 1 else 0)
total_duration = t
def make_frame(current_t):
# current_t 가 속하는 클립 찾기
# 전환 구간이면 두 클립을 blend
frame = None
for i, clip in enumerate(clips):
clip_start = starts[i]
clip_end = clip_start + clip.duration
# 이 클립의 로컬 시간
local_t = current_t - clip_start
if local_t < 0 or local_t > clip.duration:
continue
# 이 클립에서의 프레임
local_t_clamped = min(max(local_t, 0), clip.duration - 1/30)
try:
f = clip.get_frame(local_t_clamped).astype(float)
except Exception:
continue
# 전환 구간 alpha 계산
# 뒤쪽 전환: 클립 끝 transition초
if i < len(clips) - 1 and local_t > clip.duration - transition:
progress = (local_t - (clip.duration - transition)) / transition # 0→1
alpha = 1.0 - progress
# 앞쪽 전환: 클립 시작 transition초
elif i > 0 and local_t < transition:
progress = local_t / transition # 0→1
alpha = progress
else:
alpha = 1.0
if frame is None:
frame = f * alpha
else:
frame = frame + f * alpha
if frame is None:
# 혹시 빈 구간이면 검정
h, w = clips[0].size[1], clips[0].size[0]
return np.zeros((h, w, 3), dtype=np.uint8)
return frame.clip(0, 255).astype(np.uint8)
if MOVIEPY_V2:
result_clip = VideoClip(make_frame, duration=total_duration)
else:
result_clip = VideoClip(make_frame, duration=total_duration)
return [result_clip], total_duration
# ─────────────────────────────────────────────────────────────────────────────
# 헬퍼
# ─────────────────────────────────────────────────────────────────────────────
def set_duration(clip, dur):
return clip.with_duration(dur) if MOVIEPY_V2 else clip.set_duration(dur)
def set_audio(clip, audio):
return clip.with_audio(audio) if MOVIEPY_V2 else clip.set_audio(audio)
# ─────────────────────────────────────────────────────────────────────────────
# 핵심 빌드
# ─────────────────────────────────────────────────────────────────────────────
def build_video(image_paths: list,
audio_path: str,
output_path: str,
captions: list, # [(start_sec, dur_sec, bottom_margin, text), ...]
title_path=None,
title_duration: float = 5.0,
fps: int = 24,
target_size: tuple = (1280, 720)) -> None:
if not image_paths and not title_path:
raise ValueError("선택된 이미지가 없습니다.")
T = TRANSITION_DURATION
total_audio = get_audio_duration(audio_path)
# 타이틀 시간을 제외한 나머지를 일반 사진에 균등 배분
if title_path:
body_audio = max(total_audio - title_duration + T, 0)
else:
body_audio = total_audio
n = len(image_paths)
if n > 1:
duration_each = (body_audio + (n - 1) * T) / n
elif n == 1:
duration_each = body_audio
else:
duration_each = 0
print(f"📸 일반 사진 수 : {n}")
print(f"🎵 오디오 총 길이 : {total_audio:.2f} 초")
if title_path:
print(f"🎬 타이틀 이미지 : {title_path} ({title_duration:.1f}초)")
print(f"⏱️ 사진당 시간 : {duration_each:.2f} 초")
if captions:
print(f"💬 자막 수 : {len(captions)}")
tmp_dir = Path(output_path).parent / "__tmp_frames"
tmp_dir.mkdir(exist_ok=True)
tmp_files = []
try:
all_clips = []
# ── 타이틀 클립 ─────────────────────────────────────────────────────
if title_path:
print(f"\n🎬 타이틀 이미지 처리 중: {title_path}")
t_tmp = prepare_image(Path(title_path), target_size, tmp_dir, suffix="_title")
tmp_files.append(t_tmp)
all_clips.append(ImageClip(str(t_tmp), duration=title_duration))
# ── 일반 이미지 클립 ─────────────────────────────────────────────────
if n > 0:
print(f"\n🖼️ 이미지 클립 생성 중...")
for i, img_path in enumerate(image_paths, 1):
print(f" [{i}/{n}] {img_path.name}")
tmp_path = prepare_image(img_path, target_size, tmp_dir)
tmp_files.append(tmp_path)
all_clips.append(ImageClip(str(tmp_path), duration=duration_each))
# ── CrossFade 배치 ──────────────────────────────────────────────────
print("\n🔀 전환 효과 적용 중...")
positioned, _ = apply_crossfade(all_clips, T)
base_video = CompositeVideoClip(positioned, size=target_size)
base_video = set_duration(base_video, total_audio)
# ── 자막 오버레이 ───────────────────────────────────────────────────
layers = [base_video]
if captions:
print("\n💬 자막 생성 중...")
for (start_sec, dur_sec, bottom_margin, text) in captions:
if start_sec >= total_audio:
print(f" ⚠️ [{start_sec}s] 영상 길이 초과 → 건너뜀: {text}")
continue
actual_dur = min(dur_sec, total_audio - start_sec)
print(f" [{start_sec:.1f}s ~ {start_sec+actual_dur:.1f}s] margin={bottom_margin}px {text}")
layers.append(
make_caption_clip(text, actual_dur, start_sec, target_size, bottom_margin)
)
final_video = CompositeVideoClip(layers, size=target_size) if len(layers) > 1 else base_video
final_video = set_duration(final_video, total_audio)
# ── 오디오 ─────────────────────────────────────────────────────────
print("\n🎵 오디오 합치는 중...")
audio = AudioFileClip(audio_path)
try:
audio = audio.subclipped(0, total_audio)
except AttributeError:
audio = audio.subclip(0, total_audio)
final_video = set_audio(final_video, audio)
# ── MP4 저장 ────────────────────────────────────────────────────────
print(f"\n🎬 MP4 저장 중: {output_path}")
final_video.write_videofile(
output_path,
fps=fps,
codec="libx264",
audio_codec="aac",
logger="bar",
)
print(f"\n✅ 완료! → {output_path}")
finally:
for tmp in tmp_files:
if Path(tmp).exists():
Path(tmp).unlink()
try:
tmp_dir.rmdir()
except OSError:
pass
# ─────────────────────────────────────────────────────────────────────────────
# CLI
# ─────────────────────────────────────────────────────────────────────────────
def parse_caption(s: str):
"""'시작초,지속초,하단여백px,텍스트' 또는 '시작초,지속초,텍스트' 형식 파싱."""
parts = s.split(",", 3)
if len(parts) == 4:
try:
start = float(parts[0])
dur = float(parts[1])
margin = int(parts[2])
text = parts[3]
except ValueError:
raise argparse.ArgumentTypeError(f"자막 형식 오류: '{s}'")
elif len(parts) == 3:
try:
start = float(parts[0])
dur = float(parts[1])
margin = 40
text = parts[2]
except ValueError:
raise argparse.ArgumentTypeError(f"자막 형식 오류: '{s}'")
else:
raise argparse.ArgumentTypeError(
f"자막 형식 오류: '{s}' → 올바른 형식: 시작초,지속초,하단여백px,텍스트"
)
return (start, dur, margin, text)
def main():
parser = argparse.ArgumentParser(
description="하위 폴더 사진 랜덤 1장씩 + 타이틀 + WMA → 전환효과·자막 오버레이 MP4",
formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument("--folder", required=True)
parser.add_argument("--audio", required=True)
parser.add_argument("--output", default="output.mp4")
parser.add_argument("--fps", type=int, default=24)
parser.add_argument("--width", type=int, default=1280)
parser.add_argument("--height", type=int, default=720)
parser.add_argument("--title", default=None)
parser.add_argument("--title-duration", type=float, default=5.0)
parser.add_argument(
"--caption", dest="captions", action="append", type=parse_caption, default=[],
metavar="시작초,지속초,하단여백px,텍스트",
help="자막 (여러 번 사용 가능)\n 예) --caption \"5,3,40,환영합니다\"",
)
args, unknown = parser.parse_known_args()
if unknown:
print(f"⚠️ 인식되지 않은 인수 (무시됨): {unknown}")
if not os.path.isdir(args.folder):
print(f"❌ 폴더를 찾을 수 없습니다: {args.folder}"); sys.exit(1)
if not os.path.isfile(args.audio):
print(f"❌ 오디오 파일을 찾을 수 없습니다: {args.audio}"); sys.exit(1)
if args.title and not os.path.isfile(args.title):
print(f"❌ 타이틀 이미지를 찾을 수 없습니다: {args.title}"); sys.exit(1)
folders = collect_subfolders(args.folder)
if not folders:
print("❌ 이미지가 있는 하위 폴더가 없습니다."); sys.exit(1)
print(f"📂 발견된 폴더 수: {len(folders)}")
image_paths = pick_all_images_from_folders(folders)
print(f"\n선택된 이미지:")
for i, p in enumerate(image_paths, 1):
print(f" [{i:3d}] {p}")
build_video(
image_paths = image_paths,
audio_path = args.audio,
output_path = args.output,
captions = args.captions,
title_path = args.title,
title_duration = args.title_duration,
fps = args.fps,
target_size = (args.width, args.height),
)
# ─────────────────────────────────────────────────────────────────────────────
# ★ 직접 실행 설정 (USE_DIRECT = True 일 때 아래 값 사용)
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
main()
댓글 영역