상세 컨텐츠

본문 제목

[파이썬] 초등학교 교가 동영상 만들기 코드

카테고리 없음

by FDG 2026. 6. 30. 21:07

본문

사진 폴더, 교가 mp3, 타이틀화면, 유지시간, 해상도, 가사 캡션 입력할 수 있다.

 

photo_to_mp4.py
0.02MB

클로드에 요청해서 만든 코드.

 

"""
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()

댓글 영역