import os import re import urllib.request import random import math import tempfile import uuid import numpy as np from PIL import Image, ImageDraw, ImageFont import gradio as gr # Try to import moviepy; we will fall back gracefully if import fails try: from moviepy.editor import AudioFileClip, VideoFileClip from moviepy.audio.fx.all import audio_loop HAS_MOVIEPY = True except ImportError: HAS_MOVIEPY = False print("Warning: moviepy not installed. Audio mixing will be disabled.") # Font setup for Korean text rendering FONT_PATH = "NotoSansKR-Regular.ttf" def download_font(): """Downloads a Korean font if it doesn't exist to render subtitles properly.""" if not os.path.exists(FONT_PATH): print("Downloading NotoSansKR-Regular.ttf...") url = "https://github.com/google/fonts/raw/main/ofl/notosanskr/NotoSansKR-Regular.ttf" try: # User-Agent header to avoid block from Github req = urllib.request.Request( url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'} ) with urllib.request.urlopen(req) as response, open(FONT_PATH, 'wb') as out_file: out_file.write(response.read()) print("Font download complete.") except Exception as e: print(f"Error downloading font: {e}") # We will use default PIL font if download fails, though Korean won't render. # Helper function to resize and pad an image to 1080x1920 def fit_image_to_canvas(pil_img, target_w=1080, target_h=1920): w, h = pil_img.size scale = min(target_w / w, target_h / h) new_w = int(w * scale) new_h = int(h * scale) resized = pil_img.resize((new_w, new_h), Image.Resampling.LANCZOS) # Create black canvas canvas = Image.new("RGB", (target_w, target_h), (0, 0, 0)) # Paste centered x = (target_w - new_w) // 2 y = (target_h - new_h) // 2 canvas.paste(resized, (x, y)) return canvas # Effect 1: Camera Panning and Zooming on original image def apply_camera_effect(original_img, effect_name, frame_idx, total_frames, target_w=1080, target_h=1920): w, h = original_img.size # Normalize progress (0.0 to 1.0) progress = frame_idx / (total_frames - 1) if total_frames > 1 else 0 if effect_name == "천천히 줌인": # Crop from full size down to 85% size crop_ratio = 1.0 - 0.15 * progress cw = int(w * crop_ratio) ch = int(h * crop_ratio) cx1 = (w - cw) // 2 cy1 = (h - ch) // 2 cropped = original_img.crop((cx1, cy1, cx1 + cw, cy1 + ch)) elif effect_name == "천천히 줌아웃": # Crop from 85% size up to full size crop_ratio = 0.85 + 0.15 * progress cw = int(w * crop_ratio) ch = int(h * crop_ratio) cx1 = (w - cw) // 2 cy1 = (h - ch) // 2 cropped = original_img.crop((cx1, cy1, cx1 + cw, cy1 + ch)) elif effect_name == "좌 -> 우 이동": # Crop 85% window, move crop x from left to right crop_ratio = 0.85 cw = int(w * crop_ratio) ch = int(h * crop_ratio) max_shift = w - cw cx1 = int(max_shift * progress) cy1 = (h - ch) // 2 cropped = original_img.crop((cx1, cy1, cx1 + cw, cy1 + ch)) elif effect_name == "우 -> 좌 이동": # Crop 85% window, move crop x from right to left crop_ratio = 0.85 cw = int(w * crop_ratio) ch = int(h * crop_ratio) max_shift = w - cw cx1 = int(max_shift * (1.0 - progress)) cy1 = (h - ch) // 2 cropped = original_img.crop((cx1, cy1, cx1 + cw, cy1 + ch)) elif effect_name == "위 -> 아래 이동": # Crop 85% window, move crop y from top to bottom crop_ratio = 0.85 cw = int(w * crop_ratio) ch = int(h * crop_ratio) max_shift = h - ch cx1 = (w - cw) // 2 cy1 = int(max_shift * progress) cropped = original_img.crop((cx1, cy1, cx1 + cw, cy1 + ch)) elif effect_name == "아래 -> 위 이동": # Crop 85% window, move crop y from bottom to top crop_ratio = 0.85 cw = int(w * crop_ratio) ch = int(h * crop_ratio) max_shift = h - ch cx1 = (w - cw) // 2 cy1 = int(max_shift * (1.0 - progress)) cropped = original_img.crop((cx1, cy1, cx1 + cw, cy1 + ch)) else: # Fallback / None cropped = original_img return fit_image_to_canvas(cropped, target_w, target_h) # Particle class for weather effects class ParticleSystem: def __init__(self, count, p_type, canvas_w=1080, canvas_h=1920): self.p_type = p_type self.canvas_w = canvas_w self.canvas_h = canvas_h self.particles = [] for _ in range(count): self.particles.append(self._reset_particle(init=True)) def _reset_particle(self, init=False): x = random.randint(0, self.canvas_w) y = random.randint(0, self.canvas_h) if init else random.randint(-50, 0) if self.p_type == "눈 내림": size = random.uniform(2, 6) speed = random.uniform(2, 6) drift = random.uniform(-0.5, 0.5) color = (255, 255, 255, random.randint(150, 230)) elif self.p_type == "비 내림": size = random.uniform(10, 25) # length speed = random.uniform(15, 25) drift = 1 # slight wind slant color = (180, 200, 255, random.randint(80, 150)) elif self.p_type == "꽃잎 흩날림": size = random.uniform(8, 15) speed = random.uniform(3, 7) drift = random.uniform(1, 3) # Cherry blossom pink tones color = (255, random.randint(160, 200), random.randint(180, 220), random.randint(120, 200)) elif self.p_type == "나뭇잎 흔들림": size = random.uniform(10, 18) speed = random.uniform(2, 5) drift = random.uniform(-1, 2) color = (random.randint(80, 130), random.randint(130, 190), random.randint(60, 100), random.randint(120, 200)) elif self.p_type == "바람 효과": size = random.uniform(30, 70) speed = random.uniform(1, 3) drift = random.uniform(4, 9) color = (230, 240, 255, random.randint(90, 150)) else: # Default/Sunshine size = 0 speed = 0 drift = 0 color = (0,0,0,0) return {"x": x, "y": y, "size": size, "speed": speed, "drift": drift, "color": color, "angle": random.uniform(0, 2*math.pi)} def update_and_draw(self, draw, frame_idx): if self.p_type == "없음": return if self.p_type == "햇살 반짝임": # Draw overlay light leak/shimmer overlay = Image.new("RGBA", (self.canvas_w, self.canvas_h), (0,0,0,0)) overlay_draw = ImageDraw.Draw(overlay) # Oscillate sunshine center and opacity pulse = math.sin(frame_idx * 0.08) * 15 + 60 center_x = 900 + int(math.sin(frame_idx * 0.05) * 30) center_y = 100 + int(math.cos(frame_idx * 0.05) * 20) # Draw concentric warm soft circles for r in range(500, 0, -50): alpha = int(pulse * (1 - r / 500) * 0.2) overlay_draw.ellipse( (center_x - r, center_y - r, center_x + r, center_y + r), fill=(255, 245, 200, alpha) ) draw.bitmap((0, 0), overlay.convert("L"), fill=(255, 245, 200)) # Simple paste helper or blend return if self.p_type == "구름 천천히 이동": shift = int((frame_idx * 0.6) % (self.canvas_w + 400)) - 300 for base_x, base_y, scale in [(shift, 180, 1.0), (shift + 430, 320, 0.75), (shift - 520, 520, 0.9)]: color = (225, 232, 242) draw.ellipse((base_x, base_y, base_x + int(260 * scale), base_y + int(90 * scale)), fill=color) draw.ellipse((base_x + int(90 * scale), base_y - int(35 * scale), base_x + int(340 * scale), base_y + int(95 * scale)), fill=color) return # Standard particle types for p in self.particles: p["y"] += p["speed"] p["x"] += p["drift"] if self.p_type in ["꽃잎 흩날림", "나뭇잎 흔들림"]: p["x"] += math.sin(frame_idx * 0.1 + p["angle"]) * 2 # Swaying path # If off screen, reset if p["y"] > self.canvas_h or p["x"] < -20 or p["x"] > self.canvas_w + 20: p.update(self._reset_particle(init=False)) # Draw particle x, y = p["x"], p["y"] sz = p["size"] col = p["color"] if self.p_type == "눈 내림": draw.ellipse((x - sz, y - sz, x + sz, y + sz), fill=col[:3]) elif self.p_type == "비 내림": draw.line((x, y, x + p["drift"] * 3, y + sz), fill=col[:3], width=1) elif self.p_type == "꽃잎 흩날림": p["angle"] += 0.05 draw.ellipse((x - sz//2, y - sz//3, x + sz//2, y + sz//3), fill=col[:3]) elif self.p_type == "나뭇잎 흔들림": p["angle"] += 0.04 draw.ellipse((x - sz//2, y - sz//3, x + sz//2, y + sz//3), fill=col[:3]) draw.line((x, y, x + sz//2, y + sz//3), fill=(70, 110, 55), width=1) elif self.p_type == "바람 효과": draw.arc((x, y, x + sz, y + sz // 2), start=190, end=350, fill=col[:3], width=2) # Helper function to wrap text for subtitles def wrap_text(text, font, max_width): lines = [] words = list(text) # In Korean, wrap by character rather than space is often preferred if long, but we can do space or chars. # We will split by spaces first space_words = text.split(' ') current_line = "" for word in space_words: test_line = current_line + " " + word if current_line else word # Calculate width w = font.getbbox(test_line)[2] if w <= max_width: current_line = test_line else: if current_line: lines.append(current_line) current_line = word if current_line: lines.append(current_line) return lines # Function to draw subtitles with custom styles def draw_subtitles(canvas, text, font_size_style, font_color_style, use_bg): draw = ImageDraw.Draw(canvas, "RGBA") # Choose font size size = 46 if font_size_style == "큰 글씨" else 32 try: download_font() font = ImageFont.truetype(FONT_PATH, size) except IOError: font = ImageFont.load_default() color = (255, 255, 255) if font_color_style == "흰색" else (255, 242, 0) # Wrap text max_w = 960 # 1080 - 120 padding wrapped_lines = wrap_text(text, font, max_w) # Calculate height and positions line_spacing = 8 total_height = sum([font.getbbox(line)[3] - font.getbbox(line)[1] for line in wrapped_lines]) + (len(wrapped_lines) - 1) * line_spacing start_y = 1750 - total_height // 2 # Draw background box if enabled if use_bg and wrapped_lines: bg_padding = 15 box_y1 = start_y - bg_padding box_y2 = start_y + total_height + bg_padding # Transparent background box # Create a temp transparent layer to blend alpha box overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) overlay_draw = ImageDraw.Draw(overlay) overlay_draw.rectangle((60, box_y1, 1020, box_y2), fill=(0, 0, 0, 140)) canvas.paste(overlay, (0, 0), overlay) # Draw text lines centered current_y = start_y for line in wrapped_lines: w = font.getbbox(line)[2] x = (1080 - w) // 2 draw.text((x, current_y), line, fill=color, font=font) current_y += (font.getbbox(line)[3] - font.getbbox(line)[1]) + line_spacing def generate_default_subtitles(video_type, description, num_images): desc = description if description else "프로그램 활동" templates = { "기관홍보형": [ f"오늘도 활기차게 시작하는 {desc}", "서로 돕고 웃음꽃 피는 따뜻한 돌봄 현장", "어르신들의 안전하고 든든한 보금자리", "가족의 마음으로 함께 동행하겠습니다", "늘 곁에서 사랑과 정성으로 보살핍니다" ], "감동다큐형": [ "주름진 손끝에서 피어나는 이야기", "함께 나누는 미소 속에 흐르는 따뜻한 시간", "그 시절 그 고운 마음 그대로", "어르신의 소중한 인생 여정을 응원합니다", "기억 속에 담긴 따뜻한 추억 한 조각" ], "생신축하형": [ "오늘 세상에서 가장 특별하고 기쁜 날", "어르신의 소중한 생신을 온 마음으로 축하드립니다", "늘 건강하시고 꽃길만 걸으소서", "함께해주셔서 참 고맙고 행복합니다", "언제나 건강하고 평온하시기를 기도합니다" ], "창작활동형": [ "손끝의 정성으로 빚어내는 창작 활동", "집중하시는 눈빛 속 피어나는 열정", "어르신들의 창의력이 가득 담긴 귀한 작품", "만족스러운 미소와 성취감의 순간", "자신감과 행복이 자라나는 미술 교실" ], "작품전시형": [ "세상에 단 하나뿐인 고귀한 예술 작품", "한 땀 한 땀 흘린 정성과 손길", "마음의 색깔로 그려낸 어르신의 우주", "모두가 감탄한 오늘의 명작 전시회", "예술로 소통하고 치유받는 특별한 시간" ], "추억회상형": [ "아련히 떠오르는 그 시절 우리들의 추억", "사진첩 속에 깃든 그리운 이야기", "가슴속 깊이 묻어둔 고운 기억들", "다시금 꺼내어 웃음 지어보는 날", "흘러간 세월마저 아름다운 인생의 조각" ], "자서전형": [ "빛나는 청춘을 지나 도달한 아름다운 계절", "지혜와 사랑으로 일구어온 삶의 궤적", "가족들의 든든한 뿌리가 되어주신 고마운 분", "어르신의 삶 자체가 가장 위대한 소설입니다", "따뜻한 발자취를 온 마음으로 기록합니다" ] } sub_list = templates.get(video_type, templates["감동다큐형"]) # If the user put description, let's format it dynamically result = [] for i in range(num_images): idx = i % len(sub_list) result.append(sub_list[idx]) return result def get_file_path(file_value): if isinstance(file_value, str): return file_value if hasattr(file_value, "path"): return file_value.path if isinstance(file_value, dict): return file_value.get("path") or file_value.get("name") return file_value # Core generator function for Gradio interface def make_image_drama_video(image_files, video_type, camera_effect, natural_effect, activity_desc, subtitle_mode, subtitle_input, font_size, font_color, use_bg, music_file): if not image_files or len(image_files) < 3: return None, "최소 3장의 사진을 업로드해 주세요. v1은 3~5장을 권장합니다." if len(image_files) > 5: image_files = image_files[:5] num_images = len(image_files) # Step 1: Handle subtitles. Institutions either enter subtitles directly or create a clean video without text. use_subtitles = subtitle_mode == "직접입력" subtitles = [] if use_subtitles: if not subtitle_input or not subtitle_input.strip(): return None, "자막 방식을 직접입력으로 선택한 경우, 자막 내용을 입력해 주세요." subtitles = [s.strip() for s in re.split(r"[,\n]+", subtitle_input) if s.strip()] if len(subtitles) < num_images: subtitles += [""] * (num_images - len(subtitles)) subtitles = subtitles[:num_images] # Step 2: Render Frames fps = 24 frames_per_image = 96 # 4 seconds per image at 24fps total_frames = num_images * frames_per_image # Initialize particle system for natural effects p_count = 0 if natural_effect in ["눈 내림", "비 내림"]: p_count = 50 elif natural_effect == "꽃잎 흩날림": p_count = 35 elif natural_effect == "나뭇잎 흔들림": p_count = 30 elif natural_effect == "바람 효과": p_count = 18 particles = ParticleSystem(p_count, natural_effect) import cv2 run_id = uuid.uuid4().hex output_video_path = os.path.join(tempfile.gettempdir(), f"care_cinema_studio_image_drama_{run_id}.mp4") silent_video_path = os.path.join(tempfile.gettempdir(), f"care_cinema_studio_image_drama_silent_{run_id}.mp4") for path in [output_video_path, silent_video_path]: if os.path.exists(path): try: os.remove(path) except Exception: pass fourcc = cv2.VideoWriter_fourcc(*"mp4v") video_writer = cv2.VideoWriter(silent_video_path, fourcc, fps, (1080, 1920)) if not video_writer.isOpened(): return None, "영상 파일을 준비하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." for i, img_file in enumerate(image_files): # Open and load image img_path = get_file_path(img_file) try: original_img = Image.open(img_path).convert("RGB") except Exception as e: print(f"Error opening image {img_path}: {e}") original_img = Image.new("RGB", (1080, 1920), (50, 50, 50)) current_subtitle = subtitles[i] if use_subtitles else "" # Select current camera effect eff = camera_effect if camera_effect == "랜덤 시네마 효과": random.seed(i) # Fixed random per photo segment eff = random.choice([ "천천히 줌인", "천천히 줌아웃", "좌 -> 우 이동", "우 -> 좌 이동", "위 -> 아래 이동", "아래 -> 위 이동" ]) for f in range(frames_per_image): # Apply camera motion frame = apply_camera_effect(original_img, eff, f, frames_per_image) # Apply natural overlay effects frame_draw = ImageDraw.Draw(frame) global_frame_idx = i * frames_per_image + f particles.update_and_draw(frame_draw, global_frame_idx) if use_subtitles and current_subtitle: draw_subtitles(frame, current_subtitle, font_size, font_color, use_bg) video_writer.write(cv2.cvtColor(np.array(frame), cv2.COLOR_RGB2BGR)) video_writer.release() # Step 3: Add optional uploaded music. Silent output remains the safe default. music_path = get_file_path(music_file) if music_file else None if music_path and HAS_MOVIEPY: clip = None audio_clip = None try: clip = VideoFileClip(silent_video_path) audio_clip = AudioFileClip(music_path) if audio_clip.duration < clip.duration: audio_clip = audio_loop(audio_clip, duration=clip.duration) else: audio_clip = audio_clip.subclip(0, clip.duration) clip = clip.set_audio(audio_clip) clip.write_videofile( output_video_path, codec="libx264", audio_codec="aac", temp_audiofile=os.path.join(tempfile.gettempdir(), f"ccs-temp-audio-{run_id}.m4a"), remove_temp=True, fps=fps, ) if os.path.exists(silent_video_path): os.remove(silent_video_path) except Exception as ve: print(f"Audio mixing failed, returning silent video: {ve}") os.replace(silent_video_path, output_video_path) finally: if audio_clip is not None: audio_clip.close() if clip is not None: clip.close() else: os.replace(silent_video_path, output_video_path) status = ( "영상 생성이 완료되었습니다. " f"{num_images}장의 사진으로 9:16 쇼츠 형식의 Image Drama 영상을 만들었습니다. " f"자막 방식: {subtitle_mode}" ) return output_video_path, status # CSS for Care Cinema Studio. custom_css = """ .gradio-container { background-color: #0b111e; color: #e5e7eb; font-family: 'Outfit', 'Inter', -apple-system, sans-serif; } .header-box { background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #d946ef 100%); padding: 2.5rem; border-radius: 8px; text-align: center; margin-bottom: 2rem; box-shadow: 0 10px 30px -5px rgba(139, 92, 246, 0.4); border: 1px solid rgba(255, 255, 255, 0.1); } .header-box h1 { color: #ffffff; font-size: 2.8rem; font-weight: 800; margin-bottom: 0.5rem; letter-spacing: -0.03em; text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); } .header-box p { color: #e0e7ff; font-size: 1.2rem; font-weight: 300; } .primary-btn { background: linear-gradient(90deg, #3b82f6 0%, #8b5cf6 100%) !important; border: none !important; color: white !important; font-weight: 600 !important; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important; border-radius: 8px !important; box-shadow: 0 4px 12px rgba(139, 92, 246, 0.2) !important; } .primary-btn:hover { transform: translateY(-2px); box-shadow: 0 8px 25px rgba(139, 92, 246, 0.45) !important; } .glass-panel { background: rgba(17, 24, 39, 0.65) !important; backdrop-filter: blur(12px) !important; border: 1px solid rgba(255, 255, 255, 0.08) !important; border-radius: 8px !important; padding: 1.5rem !important; } """ # Build Gradio UI with gr.Blocks( title="Care Cinema Studio - Image Drama Generator", css=custom_css, theme=gr.themes.Soft(primary_hue="violet", secondary_hue="blue"), ) as demo: # Top Banner Header gr.HTML( """

🎬 Care Cinema Studio (CCS)

Turn Photos Into Stories | 사진을 이야기로 만드는 AI 영상 생성기

""" ) with gr.Row(): with gr.Column(scale=1, elem_classes="glass-panel"): gr.Markdown("### 📸 사진 및 비디오 설정") gr.Markdown("무료 v1 기준: 하루 2회 생성, 사진 3~5장, 최대 20초 MP4 쇼츠") image_input = gr.File( file_count="multiple", file_types=["image"], type="filepath", label="사진 업로드 (3~5장 권장)" ) video_type_dropdown = gr.Dropdown( choices=["기관홍보형", "감동다큐형", "생신축하형", "창작활동형", "작품전시형", "추억회상형"], value="창작활동형", label="영상 유형" ) activity_desc_input = gr.Textbox( label="활동 상세 설명 (AI 자막 생성을 돕습니다)", placeholder="예: 어르신들의 인지 발달 및 손가락 운동을 위한 클레이 아트 물고기 만들기 미술 치료 프로그램 진행", value="어르신들의 소근육 자극과 성취감 획득을 위한 색찰흙 창작 활동", lines=2 ) gr.Markdown("### 🎨 시네마틱 효과") camera_effect_dropdown = gr.Dropdown( choices=["천천히 줌인", "천천히 줌아웃", "좌 -> 우 이동", "우 -> 좌 이동", "위 -> 아래 이동", "아래 -> 위 이동", "랜덤 시네마 효과"], value="천천히 줌인", label="카메라 효과" ) natural_effect_dropdown = gr.Dropdown( choices=["없음", "구름 천천히 이동", "바람 효과", "나뭇잎 흔들림", "꽃잎 흩날림", "햇살 반짝임", "비 내림", "눈 내림"], value="없음", label="자연 효과 (오버레이)" ) gr.Markdown("### 💬 자막") subtitle_mode_radio = gr.Radio( choices=["직접입력", "자막없음"], value="직접입력", label="자막 방식" ) subtitle_input_field = gr.Textbox( label="기관 입력 자막 (쉼표 또는 줄바꿈 구분)", placeholder="예: 오늘도 즐거운 미술 시간\n손끝에서 피어난 예쁜 작품\n함께 웃는 따뜻한 하루" ) with gr.Row(): font_size_radio = gr.Radio(choices=["큰 글씨", "작은 글씨"], value="큰 글씨", label="글자 크기") font_color_radio = gr.Radio(choices=["흰색", "노란색"], value="흰색", label="글자 색상") use_bg_checkbox = gr.Checkbox(label="검정 반투명 자막 배경 적용", value=True) gr.Markdown("### 🎵 사운드") audio_input = gr.Audio( sources=["upload"], type="filepath", label="배경 음악 (MP3 업로드, 선택)" ) submit_btn = gr.Button("Image Drama 영상 생성", elem_classes="primary-btn") with gr.Column(scale=1, elem_classes="glass-panel"): gr.Markdown("### 🎥 생성된 시네마틱 쇼츠 (9:16)") video_output = gr.Video(label="완료된 영상") gr.Markdown("### 생성 상태") status_output = gr.Markdown(value="사진을 업로드하고 생성을 시작하면 이곳에 완료 안내가 표시됩니다.") # Connect trigger submit_btn.click( fn=make_image_drama_video, inputs=[ image_input, video_type_dropdown, camera_effect_dropdown, natural_effect_dropdown, activity_desc_input, subtitle_mode_radio, subtitle_input_field, font_size_radio, font_color_radio, use_bg_checkbox, audio_input ], outputs=[video_output, status_output] ) if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860)