CreatorPlus / creatorplus /services /render_service.py
Habib-HF's picture
Deploy CreatorPlus Docker Space
3311408 verified
Raw
History Blame Contribute Delete
5.21 kB
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,
}