ffmpeg / renderer /core /ingest.py
Ava2lon's picture
Upload 26 files
48ded4d verified
Raw
History Blame Contribute Delete
4.88 kB
from __future__ import annotations
import ipaddress
import mimetypes
import shutil
import socket
from dataclasses import replace
from pathlib import Path
from urllib.parse import unquote, urlparse
from urllib.request import Request, urlopen
from renderer.core.config import Settings
from renderer.core.models import AIReelsRequest, RenderRequest, Scene
from renderer.core.utils import safe_filename
class IngestError(ValueError):
pass
class AssetIngestor:
"""Resolve local paths and remote URLs into job-local files."""
def __init__(self, settings: Settings) -> None:
self.settings = settings
def resolve_render_request(self, request: RenderRequest, workdir: Path) -> RenderRequest:
return replace(
request,
scenes=[
replace(scene, media=str(self.resolve(scene.media, workdir / "inputs", f"scene_{idx:03d}")))
for idx, scene in enumerate(request.scenes)
],
voiceover=self.resolve_optional(request.voiceover, workdir / "inputs", "voiceover"),
background_music=self.resolve_optional(request.background_music, workdir / "inputs", "music"),
)
def resolve_ai_reels_request(self, request: AIReelsRequest, workdir: Path) -> AIReelsRequest:
return replace(
request,
voiceover=str(self.resolve(request.voiceover, workdir / "inputs", "voiceover")),
assets=[str(self.resolve(asset, workdir / "inputs", f"asset_{idx:03d}")) for idx, asset in enumerate(request.assets)],
background_music=self.resolve_optional(request.background_music, workdir / "inputs", "music"),
)
def resolve_optional(self, value: str | None, directory: Path, stem: str) -> str | None:
if not value:
return None
return str(self.resolve(value, directory, stem))
def resolve(self, value: str, directory: Path, stem: str) -> Path:
directory.mkdir(parents=True, exist_ok=True)
if is_remote_url(value):
return self.download(value, directory, stem)
path = Path(value)
if not path.exists():
raise IngestError(f"Asset does not exist: {value}")
return path
def download(self, url: str, directory: Path, stem: str) -> Path:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise IngestError("Only http and https asset URLs are supported")
if not self.settings.allow_private_asset_urls:
_reject_private_host(parsed.hostname)
request = Request(url, headers={"User-Agent": "basyx-ffmpeg-renderer/1.0"})
with urlopen(request, timeout=self.settings.download_timeout_seconds) as response:
content_length = response.headers.get("Content-Length")
if content_length and int(content_length) > self.settings.max_download_bytes:
raise IngestError("Remote asset exceeds MAX_DOWNLOAD_BYTES")
suffix = _suffix_from_response(url, response.headers.get("Content-Type"))
target = directory / safe_filename(f"{stem}{suffix}")
total = 0
with target.open("wb") as output:
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > self.settings.max_download_bytes:
target.unlink(missing_ok=True)
raise IngestError("Remote asset exceeds MAX_DOWNLOAD_BYTES")
output.write(chunk)
return target
def stage_upload(source: Path, uploads_dir: Path, filename: str) -> Path:
uploads_dir.mkdir(parents=True, exist_ok=True)
target = uploads_dir / safe_filename(filename)
if source.resolve() != target.resolve():
shutil.copy2(source, target)
return target
def is_remote_url(value: str) -> bool:
return urlparse(value).scheme in {"http", "https"}
def _suffix_from_response(url: str, content_type: str | None) -> str:
path_suffix = Path(unquote(urlparse(url).path)).suffix
if path_suffix:
return path_suffix[:16]
if content_type:
guessed = mimetypes.guess_extension(content_type.split(";", 1)[0].strip())
if guessed:
return guessed
return ".bin"
def _reject_private_host(hostname: str) -> None:
try:
addresses = socket.getaddrinfo(hostname, None)
except socket.gaierror as exc:
raise IngestError(f"Could not resolve host: {hostname}") from exc
for address in addresses:
ip = ipaddress.ip_address(address[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast:
raise IngestError("Private, loopback, link-local, and multicast asset hosts are not allowed")