File size: 7,173 Bytes
59e4f9e |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 |
# modal_video_processing.py
# Deploy with: modal deploy modal_video_processing.py
import modal
import os
# Create Modal app
app = modal.App("aiquoteclipgenerator")
# Define image with all dependencies
image = modal.Image.debian_slim(python_version="3.11").pip_install(
"moviepy==1.0.3",
"pillow",
"numpy",
"imageio==2.31.1",
"imageio-ffmpeg",
"requests",
"fastapi"
)
@app.function(
image=image,
cpu=4, # 4 CPUs for faster encoding
memory=4096, # 4GB RAM
timeout=300, # 5 minute timeout
)
def process_quote_video(video_url: str, quote_text: str, audio_url: str = None) -> bytes:
"""
Process quote video on Modal's fast infrastructure.
Downloads video, adds text overlay, optionally adds audio, returns video bytes.
Args:
video_url: URL of background video
quote_text: Quote to overlay
audio_url: Optional URL of audio file
Returns:
bytes: Processed video file as bytes
"""
import tempfile
import requests
from moviepy.editor import VideoFileClip, ImageClip, CompositeVideoClip, AudioFileClip
from PIL import Image, ImageDraw, ImageFont
import numpy as np
print(f"π¬ Starting video processing on Modal...")
print(f" Video: {video_url[:50]}...")
print(f" Quote length: {len(quote_text)} chars")
# Download video
print("π₯ Downloading video...")
response = requests.get(video_url, stream=True, timeout=30)
response.raise_for_status()
temp_video = tempfile.NamedTemporaryFile(delete=False, suffix='.mp4')
with open(temp_video.name, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
print("β
Video downloaded")
# Load video
print("π₯ Loading video...")
video = VideoFileClip(temp_video.name)
w, h = video.size
print(f" Dimensions: {w}x{h}")
# Create text overlay using PIL
print("βοΈ Creating text overlay...")
def make_text_frame(t):
img = Image.new('RGBA', (w, h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
font_size = int(h * 0.025)
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size)
except:
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", font_size)
except:
font = ImageFont.load_default()
max_width = int(w * 0.6)
# Wrap text
words = quote_text.split()
lines = []
current_line = []
for word in words:
test_line = ' '.join(current_line + [word])
bbox = draw.textbbox((0, 0), test_line, font=font)
text_width = bbox[2] - bbox[0]
if text_width <= max_width:
current_line.append(word)
else:
if current_line:
lines.append(' '.join(current_line))
current_line = [word]
else:
lines.append(word)
if current_line:
lines.append(' '.join(current_line))
line_spacing = int(font_size * 0.4)
text_block_height = len(lines) * (font_size + line_spacing)
y = (h - text_block_height) // 2
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
text_width = bbox[2] - bbox[0]
x = (w - text_width) // 2
outline_width = max(2, int(font_size * 0.08))
for adj_x in range(-outline_width, outline_width + 1):
for adj_y in range(-outline_width, outline_width + 1):
draw.text((x + adj_x, y + adj_y), line, font=font, fill='black')
draw.text((x, y), line, font=font, fill='white')
y += font_size + line_spacing
return np.array(img)
text_clip = ImageClip(make_text_frame(0), duration=video.duration)
print("β
Text overlay created")
# Composite
print("π¨ Compositing video...")
final_video = CompositeVideoClip([video, text_clip])
# Add audio if provided
if audio_url:
print("π€ Adding voice narration...")
try:
audio_response = requests.get(audio_url, timeout=30)
audio_response.raise_for_status()
temp_audio = tempfile.NamedTemporaryFile(delete=False, suffix='.mp3')
with open(temp_audio.name, 'wb') as f:
f.write(audio_response.content)
audio_clip = AudioFileClip(temp_audio.name)
audio_duration = min(audio_clip.duration, final_video.duration)
audio_clip = audio_clip.subclip(0, audio_duration)
final_video = final_video.set_audio(audio_clip)
print("β
Audio added")
os.unlink(temp_audio.name)
except Exception as e:
print(f"β οΈ Audio failed: {e}")
# Export
print("π¦ Exporting video...")
output_file = tempfile.NamedTemporaryFile(delete=False, suffix='.mp4')
final_video.write_videofile(
output_file.name,
codec='libx264',
audio_codec='aac',
fps=24,
preset='ultrafast',
threads=4,
verbose=False,
logger=None
)
print("β
Video exported")
# Read video bytes
with open(output_file.name, 'rb') as f:
video_bytes = f.read()
# Cleanup
video.close()
final_video.close()
os.unlink(temp_video.name)
os.unlink(output_file.name)
print(f"π Processing complete! Video size: {len(video_bytes) / 1024 / 1024:.2f}MB")
return video_bytes
# Expose as web endpoint for easy calling from Gradio
@app.function(image=image)
@modal.web_endpoint(method="POST")
def process_video_endpoint(data: dict):
"""
Web endpoint to process videos.
Accepts JSON with video_url, quote_text, and optional audio_url.
"""
video_url = data.get("video_url")
quote_text = data.get("quote_text")
audio_url = data.get("audio_url")
if not video_url or not quote_text:
return {"error": "Missing video_url or quote_text"}, 400
try:
video_bytes = process_quote_video.remote(video_url, quote_text, audio_url)
# Return video bytes as base64
import base64
video_b64 = base64.b64encode(video_bytes).decode()
return {
"success": True,
"video": video_b64,
"size_mb": len(video_bytes) / 1024 / 1024
}
except Exception as e:
return {"error": str(e)}, 500
if __name__ == "__main__":
# Test locally
with app.run():
result = process_quote_video.remote(
video_url="https://videos.pexels.com/video-files/3843433/3843433-uhd_2732_1440_25fps.mp4",
quote_text="Test quote for local testing",
audio_url=None
)
print(f"Got video: {len(result)} bytes") |