auravision-api / render.py
AuraVision Deployer
Deploying AuraVision backend engine to Hugging Face Spaces
c109c49
Raw History Blame Contribute Delete
4.49 kB
import subprocess
import os
import time
import shutil
import logging
import httpx
import subtitles
from security_utils import is_safe_url
logger = logging.getLogger(__name__)
# Configurable limits
FFMPEG_TIMEOUT = int(os.getenv("FFMPEG_TIMEOUT_SECONDS", "120"))
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # Max 10MB image
def download_image(url: str, dest_path: str):
"""
Downloads an image safely with size limits and timeouts using HTTPX.
"""
if not is_safe_url(url):
raise ValueError(f"Forbidden URL target: {url}")
with httpx.Client(timeout=10.0, follow_redirects=True) as client:
# Stream response to check size before reading full content into memory
with client.stream("GET", url) as response:
if response.status_code != 200:
raise IOError(f"Failed to fetch image, status: {response.status_code}")
content_length = response.headers.get("Content-Length")
if content_length and int(content_length) > MAX_IMAGE_SIZE:
raise ValueError("Remote image size exceeds limit.")
total_bytes = 0
with open(dest_path, "wb") as out:
for chunk in response.iter_bytes(chunk_size=8192):
total_bytes += len(chunk)
if total_bytes > MAX_IMAGE_SIZE:
raise ValueError("Remote image size exceeds limit during streaming.")
out.write(chunk)
def render_video(audio_path, image_url, script_data, output_path, preset="horizontal"):
config = {
"vertical": (1080, 1920, 1.25),
"square": (1080, 1080, 1.15),
"horizontal": (1920, 1080, 1.0)
}
width, height, fg_scale = config.get(preset, config["horizontal"])
# Generate unique random tokens for temp files to prevent collision
rand_token = os.urandom(8).hex()
img_path = f"temp_bg_{rand_token}.jpg"
subs_path = f"temp_subs_{rand_token}.ass"
try:
# 1. Download visual assets safely
download_image(image_url, img_path)
# 2. Compile subtitles safely
subtitles.create_ass_subtitles(script_data, subs_path, width, height)
# 3. Escape subs path for FFmpeg filter argument
# FFmpeg's ass filter requires escaping backslashes and single quotes.
escaped_subs_path = subs_path.replace("\\", "\\\\").replace("'", "\\'").replace(":", "\\:")
bg_f = f"scale=400:-1,boxblur=10:5,colorchannelmixer=rr=0.5:gg=0.5:bb=0.5,zoompan=z='min(zoom+0.0005,1.2)':d=1:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s={width}x{height}"
fg_f = f"scale={int(width*fg_scale)}:-1" if preset != "horizontal" else f"scale=-1:{height}"
ov_f = f"[0:v]{bg_f}[bg];[1:v]{fg_f}[fg];[bg][fg]overlay=(W-w)/2:(H-h)/2,ass='{escaped_subs_path}'"
cmd = [
"ffmpeg", "-y", "-loop", "1", "-i", img_path, "-loop", "1", "-i", img_path, "-i", audio_path,
"-filter_complex", ov_f, "-c:v", "libx264", "-preset", "ultrafast", "-crf", "20",
"-c:a", "aac", "-b:a", "192k", "-pix_fmt", "yuv420p", "-shortest", output_path
]
logger.info(f"Executing FFmpeg with timeout={FFMPEG_TIMEOUT}s...")
# Execute subprocess with timeout to prevent infinite loops / orphaned processes
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=FFMPEG_TIMEOUT, check=True)
logger.info("FFmpeg execution completed successfully.")
return True
except subprocess.TimeoutExpired as te:
logger.error("FFmpeg compilation timed out.")
raise RuntimeError("Video rendering timed out.") from te
except subprocess.CalledProcessError as cpe:
stderr_output = cpe.stderr.decode("utf-8", errors="replace") if cpe.stderr else ""
logger.error(f"FFmpeg failed with error code {cpe.returncode}. Stderr: {stderr_output}")
raise RuntimeError("Video rendering engine failed.") from cpe
except Exception as e:
logger.error(f"Render engine error: {e}", exc_info=True)
raise
finally:
# Guaranteed cleanup of intermediate temporary files
for p in [img_path, subs_path]:
if os.path.exists(p):
try:
os.remove(p)
except Exception as cleanup_err:
logger.warning(f"Could not remove temp file {p}: {cleanup_err}")