import os import uuid from PIL import Image, ImageOps # Pillow 최신 버전 호환 패치 if not hasattr(Image, "ANTIALIAS"): Image.ANTIALIAS = Image.Resampling.LANCZOS import gradio as gr from moviepy.editor import ImageClip, concatenate_videoclips OUTPUT_DIR = "outputs" os.makedirs(OUTPUT_DIR, exist_ok=True) WIDTH, HEIGHT = 1080, 1920 def get_file_path(file_obj): if file_obj is None: return None if isinstance(file_obj, str): return file_obj if hasattr(file_obj, "name"): return file_obj.name return str(file_obj) def resize_crop_image(image_path): img = Image.open(image_path).convert("RGB") img = ImageOps.exif_transpose(img) target_ratio = WIDTH / HEIGHT img_ratio = img.width / img.height if img_ratio > target_ratio: new_h = HEIGHT new_w = int(new_h * img_ratio) else: new_w = WIDTH new_h = int(new_w / img_ratio) img = img.resize((new_w, new_h), Image.LANCZOS) left = (new_w - WIDTH) // 2 top = (new_h - HEIGHT) // 2 return img.crop((left, top, left + WIDTH, top + HEIGHT)) def save_scene_image(file_obj): image_path = get_file_path(file_obj) img = resize_crop_image(image_path) path = os.path.join(OUTPUT_DIR, f"scene_{uuid.uuid4()}.jpg") img.save(path, quality=95) return path def apply_motion(clip, motion_type, duration): if motion_type == "자동 추천": motion_type = "천천히 줌인" if motion_type == "천천히 줌인": clip = clip.resize(lambda t: 1 + 0.14 * (t / duration)) clip = clip.crop( x_center=WIDTH / 2, y_center=HEIGHT / 2, width=WIDTH, height=HEIGHT ) elif motion_type == "천천히 줌아웃": clip = clip.resize(1.16) clip = clip.resize(lambda t: 1.16 - 0.14 * (t / duration)) clip = clip.crop( x_center=WIDTH / 2, y_center=HEIGHT / 2, width=WIDTH, height=HEIGHT ) elif motion_type == "좌우 이동": clip = clip.resize(1.20) clip = clip.crop( x1=lambda t: int(180 * (t / duration)), y_center=HEIGHT / 2, width=WIDTH, height=HEIGHT ) elif motion_type == "위에서 아래로 이동": clip = clip.resize(1.20) clip = clip.crop( x_center=WIDTH / 2, y1=lambda t: int(200 * (t / duration)), width=WIDTH, height=HEIGHT ) elif motion_type == "아래에서 위로 이동": clip = clip.resize(1.20) clip = clip.crop( x_center=WIDTH / 2, y1=lambda t: int(200 - 200 * (t / duration)), width=WIDTH, height=HEIGHT ) elif motion_type == "하늘에서 내려다보기": clip = clip.resize(lambda t: 1.22 - 0.12 * (t / duration)) clip = clip.crop( x_center=WIDTH / 2, y_center=lambda t: HEIGHT / 2 - 160 + 240 * (t / duration), width=WIDTH, height=HEIGHT ) return clip def make_video(img1, img2, img3, img4, motion_type, seconds_per_scene): images = [img1, img2, img3, img4] images = [img for img in images if img is not None] if not images: raise gr.Error("사진을 1장 이상 업로드해주세요.") clips = [] for img_file in images: scene_path = save_scene_image(img_file) clip = ImageClip(scene_path).set_duration(int(seconds_per_scene)) clip = apply_motion(clip, motion_type, int(seconds_per_scene)) clips.append(clip) final = concatenate_videoclips(clips, method="compose") output_path = os.path.join(OUTPUT_DIR, f"shorts_{uuid.uuid4()}.mp4") final.write_videofile( output_path, fps=24, codec="libx264", audio=False, preset="ultrafast", threads=2 ) return output_path, output_path with gr.Blocks(title="ShortsVideoGenerater") as demo: gr.Markdown("# ShortsVideoGenerater") gr.Markdown( """ 사진 4장을 업로드하면 **유튜브 쇼츠용 9:16 영상**으로 변환합니다. - 사진은 한 장씩 순서대로 재생됩니다. - 기본값: 사진 1장당 5초 - 사진 4장 업로드 시 총 20초 영상 - 자막 없이 사진 영상만 생성됩니다. """ ) with gr.Row(): img1 = gr.File(label="장면 1 사진", file_types=["image"]) img2 = gr.File(label="장면 2 사진", file_types=["image"]) with gr.Row(): img3 = gr.File(label="장면 3 사진", file_types=["image"]) img4 = gr.File(label="장면 4 사진", file_types=["image"]) with gr.Row(): motion_type = gr.Radio( choices=[ "자동 추천", "천천히 줌인", "천천히 줌아웃", "좌우 이동", "위에서 아래로 이동", "아래에서 위로 이동", "하늘에서 내려다보기", ], value="자동 추천", label="화면 움직임" ) seconds_per_scene = gr.Radio( choices=[4, 5, 6, 7], value=5, label="사진 1장당 시간" ) create_btn = gr.Button("유튜브 쇼츠 생성하기", variant="primary") output_video = gr.Video(label="생성된 쇼츠 영상") download_file = gr.File(label="MP4 다운로드") create_btn.click( fn=make_video, inputs=[ img1, img2, img3, img4, motion_type, seconds_per_scene ], outputs=[ output_video, download_file ] ) if __name__ == "__main__": demo.launch()