Upload 26 files
Browse files- renderer/core/config.py +10 -5
- renderer/core/ingest.py +118 -0
- renderer/core/render_engine.py +51 -25
renderer/core/config.py
CHANGED
|
@@ -4,21 +4,26 @@ import os
|
|
| 4 |
from dataclasses import dataclass
|
| 5 |
from pathlib import Path
|
| 6 |
|
|
|
|
|
|
|
| 7 |
|
| 8 |
@dataclass(frozen=True)
|
| 9 |
class Settings:
|
| 10 |
"""Runtime settings tuned for small CPU-only Hugging Face Spaces."""
|
| 11 |
|
| 12 |
-
base_dir: Path = Path(os.getenv("BASYX_BASE_DIR",
|
| 13 |
-
temp_dir: Path = Path(os.getenv("TEMP_DIR",
|
| 14 |
-
exports_dir: Path = Path(os.getenv("EXPORTS_DIR",
|
| 15 |
-
jobs_dir: Path = Path(os.getenv("JOBS_DIR",
|
| 16 |
-
metadata_cache: Path = Path(os.getenv("METADATA_CACHE",
|
| 17 |
font_path: Path = Path(os.getenv("FONT_PATH", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"))
|
| 18 |
output_width: int = int(os.getenv("OUTPUT_WIDTH", "1080"))
|
| 19 |
output_height: int = int(os.getenv("OUTPUT_HEIGHT", "1920"))
|
| 20 |
output_fps: int = int(os.getenv("OUTPUT_FPS", "30"))
|
| 21 |
ffmpeg_timeout_seconds: int = int(os.getenv("FFMPEG_TIMEOUT_SECONDS", "900"))
|
|
|
|
|
|
|
|
|
|
| 22 |
max_retries: int = int(os.getenv("MAX_RETRIES", "3"))
|
| 23 |
max_workers: int = int(os.getenv("MAX_RENDER_WORKERS", "1"))
|
| 24 |
crf: int = int(os.getenv("OUTPUT_CRF", "23"))
|
|
|
|
| 4 |
from dataclasses import dataclass
|
| 5 |
from pathlib import Path
|
| 6 |
|
| 7 |
+
DEFAULT_ROOT = Path.cwd()
|
| 8 |
+
|
| 9 |
|
| 10 |
@dataclass(frozen=True)
|
| 11 |
class Settings:
|
| 12 |
"""Runtime settings tuned for small CPU-only Hugging Face Spaces."""
|
| 13 |
|
| 14 |
+
base_dir: Path = Path(os.getenv("BASYX_BASE_DIR", str(DEFAULT_ROOT)))
|
| 15 |
+
temp_dir: Path = Path(os.getenv("TEMP_DIR", str(DEFAULT_ROOT / "temp")))
|
| 16 |
+
exports_dir: Path = Path(os.getenv("EXPORTS_DIR", str(DEFAULT_ROOT / "exports")))
|
| 17 |
+
jobs_dir: Path = Path(os.getenv("JOBS_DIR", str(DEFAULT_ROOT / "jobs")))
|
| 18 |
+
metadata_cache: Path = Path(os.getenv("METADATA_CACHE", str(DEFAULT_ROOT / "temp" / "metadata_cache.json")))
|
| 19 |
font_path: Path = Path(os.getenv("FONT_PATH", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"))
|
| 20 |
output_width: int = int(os.getenv("OUTPUT_WIDTH", "1080"))
|
| 21 |
output_height: int = int(os.getenv("OUTPUT_HEIGHT", "1920"))
|
| 22 |
output_fps: int = int(os.getenv("OUTPUT_FPS", "30"))
|
| 23 |
ffmpeg_timeout_seconds: int = int(os.getenv("FFMPEG_TIMEOUT_SECONDS", "900"))
|
| 24 |
+
download_timeout_seconds: int = int(os.getenv("DOWNLOAD_TIMEOUT_SECONDS", "60"))
|
| 25 |
+
max_download_bytes: int = int(os.getenv("MAX_DOWNLOAD_BYTES", str(500 * 1024 * 1024)))
|
| 26 |
+
allow_private_asset_urls: bool = os.getenv("ALLOW_PRIVATE_ASSET_URLS", "false").lower() == "true"
|
| 27 |
max_retries: int = int(os.getenv("MAX_RETRIES", "3"))
|
| 28 |
max_workers: int = int(os.getenv("MAX_RENDER_WORKERS", "1"))
|
| 29 |
crf: int = int(os.getenv("OUTPUT_CRF", "23"))
|
renderer/core/ingest.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import ipaddress
|
| 4 |
+
import mimetypes
|
| 5 |
+
import shutil
|
| 6 |
+
import socket
|
| 7 |
+
from dataclasses import replace
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from urllib.parse import unquote, urlparse
|
| 10 |
+
from urllib.request import Request, urlopen
|
| 11 |
+
|
| 12 |
+
from renderer.core.config import Settings
|
| 13 |
+
from renderer.core.models import AIReelsRequest, RenderRequest, Scene
|
| 14 |
+
from renderer.core.utils import safe_filename
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class IngestError(ValueError):
|
| 18 |
+
pass
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class AssetIngestor:
|
| 22 |
+
"""Resolve local paths and remote URLs into job-local files."""
|
| 23 |
+
|
| 24 |
+
def __init__(self, settings: Settings) -> None:
|
| 25 |
+
self.settings = settings
|
| 26 |
+
|
| 27 |
+
def resolve_render_request(self, request: RenderRequest, workdir: Path) -> RenderRequest:
|
| 28 |
+
return replace(
|
| 29 |
+
request,
|
| 30 |
+
scenes=[
|
| 31 |
+
replace(scene, media=str(self.resolve(scene.media, workdir / "inputs", f"scene_{idx:03d}")))
|
| 32 |
+
for idx, scene in enumerate(request.scenes)
|
| 33 |
+
],
|
| 34 |
+
voiceover=self.resolve_optional(request.voiceover, workdir / "inputs", "voiceover"),
|
| 35 |
+
background_music=self.resolve_optional(request.background_music, workdir / "inputs", "music"),
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
def resolve_ai_reels_request(self, request: AIReelsRequest, workdir: Path) -> AIReelsRequest:
|
| 39 |
+
return replace(
|
| 40 |
+
request,
|
| 41 |
+
voiceover=str(self.resolve(request.voiceover, workdir / "inputs", "voiceover")),
|
| 42 |
+
assets=[str(self.resolve(asset, workdir / "inputs", f"asset_{idx:03d}")) for idx, asset in enumerate(request.assets)],
|
| 43 |
+
background_music=self.resolve_optional(request.background_music, workdir / "inputs", "music"),
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
def resolve_optional(self, value: str | None, directory: Path, stem: str) -> str | None:
|
| 47 |
+
if not value:
|
| 48 |
+
return None
|
| 49 |
+
return str(self.resolve(value, directory, stem))
|
| 50 |
+
|
| 51 |
+
def resolve(self, value: str, directory: Path, stem: str) -> Path:
|
| 52 |
+
directory.mkdir(parents=True, exist_ok=True)
|
| 53 |
+
if is_remote_url(value):
|
| 54 |
+
return self.download(value, directory, stem)
|
| 55 |
+
path = Path(value)
|
| 56 |
+
if not path.exists():
|
| 57 |
+
raise IngestError(f"Asset does not exist: {value}")
|
| 58 |
+
return path
|
| 59 |
+
|
| 60 |
+
def download(self, url: str, directory: Path, stem: str) -> Path:
|
| 61 |
+
parsed = urlparse(url)
|
| 62 |
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
| 63 |
+
raise IngestError("Only http and https asset URLs are supported")
|
| 64 |
+
if not self.settings.allow_private_asset_urls:
|
| 65 |
+
_reject_private_host(parsed.hostname)
|
| 66 |
+
request = Request(url, headers={"User-Agent": "basyx-ffmpeg-renderer/1.0"})
|
| 67 |
+
with urlopen(request, timeout=self.settings.download_timeout_seconds) as response:
|
| 68 |
+
content_length = response.headers.get("Content-Length")
|
| 69 |
+
if content_length and int(content_length) > self.settings.max_download_bytes:
|
| 70 |
+
raise IngestError("Remote asset exceeds MAX_DOWNLOAD_BYTES")
|
| 71 |
+
suffix = _suffix_from_response(url, response.headers.get("Content-Type"))
|
| 72 |
+
target = directory / safe_filename(f"{stem}{suffix}")
|
| 73 |
+
total = 0
|
| 74 |
+
with target.open("wb") as output:
|
| 75 |
+
while True:
|
| 76 |
+
chunk = response.read(1024 * 1024)
|
| 77 |
+
if not chunk:
|
| 78 |
+
break
|
| 79 |
+
total += len(chunk)
|
| 80 |
+
if total > self.settings.max_download_bytes:
|
| 81 |
+
target.unlink(missing_ok=True)
|
| 82 |
+
raise IngestError("Remote asset exceeds MAX_DOWNLOAD_BYTES")
|
| 83 |
+
output.write(chunk)
|
| 84 |
+
return target
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def stage_upload(source: Path, uploads_dir: Path, filename: str) -> Path:
|
| 88 |
+
uploads_dir.mkdir(parents=True, exist_ok=True)
|
| 89 |
+
target = uploads_dir / safe_filename(filename)
|
| 90 |
+
if source.resolve() != target.resolve():
|
| 91 |
+
shutil.copy2(source, target)
|
| 92 |
+
return target
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def is_remote_url(value: str) -> bool:
|
| 96 |
+
return urlparse(value).scheme in {"http", "https"}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _suffix_from_response(url: str, content_type: str | None) -> str:
|
| 100 |
+
path_suffix = Path(unquote(urlparse(url).path)).suffix
|
| 101 |
+
if path_suffix:
|
| 102 |
+
return path_suffix[:16]
|
| 103 |
+
if content_type:
|
| 104 |
+
guessed = mimetypes.guess_extension(content_type.split(";", 1)[0].strip())
|
| 105 |
+
if guessed:
|
| 106 |
+
return guessed
|
| 107 |
+
return ".bin"
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _reject_private_host(hostname: str) -> None:
|
| 111 |
+
try:
|
| 112 |
+
addresses = socket.getaddrinfo(hostname, None)
|
| 113 |
+
except socket.gaierror as exc:
|
| 114 |
+
raise IngestError(f"Could not resolve host: {hostname}") from exc
|
| 115 |
+
for address in addresses:
|
| 116 |
+
ip = ipaddress.ip_address(address[4][0])
|
| 117 |
+
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast:
|
| 118 |
+
raise IngestError("Private, loopback, link-local, and multicast asset hosts are not allowed")
|
renderer/core/render_engine.py
CHANGED
|
@@ -7,6 +7,7 @@ from pathlib import Path
|
|
| 7 |
|
| 8 |
from renderer.audio import AudioMixer
|
| 9 |
from renderer.core.config import Settings
|
|
|
|
| 10 |
from renderer.core.models import AIReelsRequest, RenderRequest, RenderResult
|
| 11 |
from renderer.core.utils import cleanup_directory, temp_workdir
|
| 12 |
from renderer.exports import ExportManager
|
|
@@ -26,6 +27,7 @@ class RenderEngine:
|
|
| 26 |
self._commands: list[list[str]] = []
|
| 27 |
self._logs: list[str] = []
|
| 28 |
self.runner = FFmpegRunner(self.settings.ffmpeg_timeout_seconds, log=log, on_command=self._record_command)
|
|
|
|
| 29 |
self.assets = AssetProbe(self.settings.metadata_cache)
|
| 30 |
self.normalizer = Normalizer(self.settings, self.runner)
|
| 31 |
self.subtitles = SubtitleGenerator()
|
|
@@ -40,10 +42,12 @@ class RenderEngine:
|
|
| 40 |
timeline = Timeline(request.scenes)
|
| 41 |
with temp_workdir(self.settings.temp_dir, job_id) as work:
|
| 42 |
workdir = Path(work)
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
| 47 |
output = self.exports.save(mixed, job_id, request.output_name)
|
| 48 |
cleanup_directory(workdir, keep={mixed})
|
| 49 |
metrics = {
|
|
@@ -56,31 +60,53 @@ class RenderEngine:
|
|
| 56 |
def ai_reels(self, request: AIReelsRequest, job_id: str) -> RenderResult:
|
| 57 |
if not request.voiceover:
|
| 58 |
raise ValueError("AI reels v1 requires a provided voiceover path; TTS is pluggable but not bundled.")
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
def inspect_asset(self, path: str | Path) -> dict:
|
| 82 |
return self.assets.probe(path).__dict__
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
def _prepare_scene_media(self, request: RenderRequest, workdir: Path) -> list[Path]:
|
| 85 |
prepared: list[Path] = []
|
| 86 |
for idx, scene in enumerate(request.scenes):
|
|
|
|
| 7 |
|
| 8 |
from renderer.audio import AudioMixer
|
| 9 |
from renderer.core.config import Settings
|
| 10 |
+
from renderer.core.ingest import AssetIngestor
|
| 11 |
from renderer.core.models import AIReelsRequest, RenderRequest, RenderResult
|
| 12 |
from renderer.core.utils import cleanup_directory, temp_workdir
|
| 13 |
from renderer.exports import ExportManager
|
|
|
|
| 27 |
self._commands: list[list[str]] = []
|
| 28 |
self._logs: list[str] = []
|
| 29 |
self.runner = FFmpegRunner(self.settings.ffmpeg_timeout_seconds, log=log, on_command=self._record_command)
|
| 30 |
+
self.ingest = AssetIngestor(self.settings)
|
| 31 |
self.assets = AssetProbe(self.settings.metadata_cache)
|
| 32 |
self.normalizer = Normalizer(self.settings, self.runner)
|
| 33 |
self.subtitles = SubtitleGenerator()
|
|
|
|
| 42 |
timeline = Timeline(request.scenes)
|
| 43 |
with temp_workdir(self.settings.temp_dir, job_id) as work:
|
| 44 |
workdir = Path(work)
|
| 45 |
+
resolved = self.ingest.resolve_render_request(request, workdir)
|
| 46 |
+
timeline = Timeline(resolved.scenes)
|
| 47 |
+
prepared = self._prepare_scene_media(resolved, workdir)
|
| 48 |
+
subtitles = self._write_subtitles(resolved, timeline, workdir)
|
| 49 |
+
video = self._compose_video(prepared, resolved, subtitles, workdir)
|
| 50 |
+
mixed = self.audio.mix(video, resolved.background_music, resolved.voiceover, workdir / "mixed.mp4")
|
| 51 |
output = self.exports.save(mixed, job_id, request.output_name)
|
| 52 |
cleanup_directory(workdir, keep={mixed})
|
| 53 |
metrics = {
|
|
|
|
| 60 |
def ai_reels(self, request: AIReelsRequest, job_id: str) -> RenderResult:
|
| 61 |
if not request.voiceover:
|
| 62 |
raise ValueError("AI reels v1 requires a provided voiceover path; TTS is pluggable but not bundled.")
|
| 63 |
+
if not request.assets:
|
| 64 |
+
raise ValueError("AI reels rendering requires at least one visual asset.")
|
| 65 |
+
with temp_workdir(self.settings.temp_dir, job_id) as work:
|
| 66 |
+
workdir = Path(work)
|
| 67 |
+
resolved = self.ingest.resolve_ai_reels_request(request, workdir)
|
| 68 |
+
voice_meta = self.assets.probe(resolved.voiceover)
|
| 69 |
+
duration = max(voice_meta.duration, len(resolved.script.split()) * 0.35, 3.0)
|
| 70 |
+
per_scene = duration / max(1, len(resolved.assets))
|
| 71 |
+
captions = _split_script(resolved.script, len(resolved.assets))
|
| 72 |
+
scenes = [
|
| 73 |
+
{
|
| 74 |
+
"start": round(idx * per_scene, 3),
|
| 75 |
+
"duration": round(per_scene, 3),
|
| 76 |
+
"media": asset,
|
| 77 |
+
"caption": captions[idx] if idx < len(captions) else "",
|
| 78 |
+
}
|
| 79 |
+
for idx, asset in enumerate(resolved.assets)
|
| 80 |
+
]
|
| 81 |
+
render_request = RenderRequest(
|
| 82 |
+
scenes=Timeline.request_from_payload({"scenes": scenes}).scenes,
|
| 83 |
+
template=resolved.template,
|
| 84 |
+
output_name=resolved.output_name,
|
| 85 |
+
voiceover=resolved.voiceover,
|
| 86 |
+
background_music=resolved.background_music,
|
| 87 |
+
)
|
| 88 |
+
return self._render_resolved(render_request, job_id, workdir)
|
| 89 |
|
| 90 |
def inspect_asset(self, path: str | Path) -> dict:
|
| 91 |
return self.assets.probe(path).__dict__
|
| 92 |
|
| 93 |
+
def _render_resolved(self, request: RenderRequest, job_id: str, workdir: Path) -> RenderResult:
|
| 94 |
+
self._commands = []
|
| 95 |
+
self._logs = []
|
| 96 |
+
started = time.time()
|
| 97 |
+
timeline = Timeline(request.scenes)
|
| 98 |
+
prepared = self._prepare_scene_media(request, workdir)
|
| 99 |
+
subtitles = self._write_subtitles(request, timeline, workdir)
|
| 100 |
+
video = self._compose_video(prepared, request, subtitles, workdir)
|
| 101 |
+
mixed = self.audio.mix(video, request.background_music, request.voiceover, workdir / "mixed.mp4")
|
| 102 |
+
output = self.exports.save(mixed, job_id, request.output_name)
|
| 103 |
+
metrics = {
|
| 104 |
+
"render_time_seconds": round(time.time() - started, 3),
|
| 105 |
+
"output_size_bytes": output.stat().st_size,
|
| 106 |
+
"scene_count": len(request.scenes),
|
| 107 |
+
}
|
| 108 |
+
return RenderResult(output_path=output, commands=list(self._commands), metrics=metrics, logs=list(self._logs))
|
| 109 |
+
|
| 110 |
def _prepare_scene_media(self, request: RenderRequest, workdir: Path) -> list[Path]:
|
| 111 |
prepared: list[Path] = []
|
| 112 |
for idx, scene in enumerate(request.scenes):
|