Spaces:
Sleeping
Sleeping
Download app.py from devorah-ai-2016/CCS: direct link, hf CLI and curl.
- Browser
- Download file 27.4 kB
-
https://huggingface.co/spaces/devorah-ai-2016/CCS/resolve/main/app.py
- Command line
-
hf download hf://spaces/devorah-ai-2016/CCS/app.py
-
curl -L -o app.py https://huggingface.co/spaces/devorah-ai-2016/CCS/resolve/main/app.py
27.4 kB
| 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( | |
| """ | |
| <div class="header-box"> | |
| <h1>π¬ Care Cinema Studio (CCS)</h1> | |
| <p>Turn Photos Into Stories | μ¬μ§μ μ΄μΌκΈ°λ‘ λ§λλ AI μμ μμ±κΈ°</p> | |
| </div> | |
| """ | |
| ) | |
| 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) | |