Spaces:
Sleeping
Sleeping
| import random | |
| from pathlib import Path | |
| import numpy as np | |
| from PIL import Image, ImageDraw, ImageFont | |
| from moviepy.editor import AudioFileClip, ColorClip, CompositeVideoClip, ImageClip, VideoFileClip | |
| def split_script(script_text, target_scenes=3): | |
| words = [w for w in script_text.split() if w.strip()] | |
| if not words: | |
| return [] | |
| chunk = max(1, len(words) // max(1, target_scenes)) | |
| scenes = [] | |
| idx = 0 | |
| while idx < len(words): | |
| scene_words = words[idx:idx + chunk] | |
| scenes.append(" ".join(scene_words)) | |
| idx += chunk | |
| return scenes | |
| def _resolve_dimensions(aspect_ratio): | |
| if aspect_ratio == "16:9": | |
| return 1280, 720 | |
| if aspect_ratio == "1:1": | |
| return 1080, 1080 | |
| return 720, 1280 | |
| def _build_caption_clip(text, width, height, start_time, duration): | |
| img = Image.new("RGBA", (width, height), (0, 0, 0, 0)) | |
| draw = ImageDraw.Draw(img) | |
| font = ImageFont.load_default() | |
| wrap_width = 42 | |
| words = text.split() | |
| lines = [] | |
| buf = [] | |
| count = 0 | |
| for word in words: | |
| buf.append(word) | |
| count += len(word) + 1 | |
| if count >= wrap_width: | |
| lines.append(" ".join(buf)) | |
| buf = [] | |
| count = 0 | |
| if buf: | |
| lines.append(" ".join(buf)) | |
| caption = "\n".join(lines[:4]) if lines else text | |
| bbox = draw.multiline_textbbox((0, 0), caption, font=font, spacing=6) | |
| tw = bbox[2] - bbox[0] | |
| th = bbox[3] - bbox[1] | |
| x = (width - tw) // 2 | |
| y = int(height * 0.75) - (th // 2) | |
| draw.rounded_rectangle((x - 16, y - 12, x + tw + 16, y + th + 12), radius=14, fill=(0, 0, 0, 150)) | |
| draw.multiline_text((x, y), caption, font=font, fill=(255, 255, 255, 235), spacing=6, align="center") | |
| return ImageClip(np.array(img)).set_start(start_time).set_duration(duration) | |
| def _fit_clip_to_canvas(clip, width, height): | |
| if clip.w == 0 or clip.h == 0: | |
| return clip.set_duration(clip.duration) | |
| scale = max(width / clip.w, height / clip.h) | |
| resized = clip.resize(scale) | |
| return resized.crop(width=width, height=height, x_center=resized.w / 2, y_center=resized.h / 2) | |
| def _build_background_clip(job_id, background_asset, width, height, duration): | |
| source = (background_asset or {}).get("source", "procedural") | |
| asset_path = (background_asset or {}).get("path") | |
| asset_type = (background_asset or {}).get("type") | |
| color_sets = [ | |
| (22, 58, 95), | |
| (13, 91, 67), | |
| (88, 46, 21), | |
| (66, 36, 110), | |
| (17, 48, 75), | |
| ] | |
| if asset_path and asset_type == "video": | |
| try: | |
| clip = VideoFileClip(str(asset_path)).without_audio() | |
| fitted = _fit_clip_to_canvas(clip, width, height) | |
| if fitted.duration < duration: | |
| loops = int(duration // fitted.duration) + 1 if fitted.duration > 0 else 1 | |
| fitted = fitted.loop(n=loops) | |
| return fitted.subclip(0, duration).set_duration(duration), source | |
| except Exception: | |
| pass | |
| if asset_path and asset_type == "image": | |
| try: | |
| clip = ImageClip(str(asset_path)).set_duration(duration) | |
| fitted = _fit_clip_to_canvas(clip, width, height) | |
| return fitted.set_duration(duration), source | |
| except Exception: | |
| pass | |
| random.seed(job_id) | |
| base_color = random.choice(color_sets) | |
| fallback = ColorClip((width, height), color=base_color).set_duration(duration) | |
| return fallback, "procedural" | |
| def render_video(job_id, script_text, settings, outputs_dir, mixed_audio_path, background_asset=None): | |
| """Render a real MP4 file with audio and optional subtitle overlays.""" | |
| outputs_dir = Path(outputs_dir) | |
| outputs_dir.mkdir(parents=True, exist_ok=True) | |
| scenes = split_script(script_text, target_scenes=4) | |
| audio_clip = AudioFileClip(str(mixed_audio_path)) | |
| duration = float(audio_clip.duration) | |
| width, height = _resolve_dimensions(str(settings.get("aspectRatio", "9:16"))) | |
| bg, resolved_source = _build_background_clip(job_id, background_asset, width, height, duration) | |
| dim = ColorClip((width, height), color=(0, 0, 0)).set_opacity(0.2).set_duration(duration) | |
| layers = [bg, dim] | |
| if settings.get("includeSubtitles", True): | |
| if scenes: | |
| seg_dur = duration / len(scenes) | |
| for i, text in enumerate(scenes): | |
| layers.append(_build_caption_clip(text, width, height, i * seg_dur, seg_dur)) | |
| final_clip = CompositeVideoClip(layers).set_audio(audio_clip) | |
| out_path = outputs_dir / f"{job_id}.mp4" | |
| final_clip.write_videofile( | |
| str(out_path), | |
| fps=24, | |
| codec="libx264", | |
| audio_codec="aac", | |
| preset="medium", | |
| threads=2, | |
| verbose=False, | |
| logger=None, | |
| ) | |
| final_clip.close() | |
| audio_clip.close() | |
| for layer in layers: | |
| try: | |
| layer.close() | |
| except Exception: | |
| pass | |
| return { | |
| "video_path": str(out_path), | |
| "background_source": resolved_source, | |
| } | |