Ava2lon commited on
Commit
48ded4d
·
verified ·
1 Parent(s): 6268049

Upload 26 files

Browse files
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", "/app"))
13
- temp_dir: Path = Path(os.getenv("TEMP_DIR", "/app/temp"))
14
- exports_dir: Path = Path(os.getenv("EXPORTS_DIR", "/app/exports"))
15
- jobs_dir: Path = Path(os.getenv("JOBS_DIR", "/app/jobs"))
16
- metadata_cache: Path = Path(os.getenv("METADATA_CACHE", "/app/temp/metadata_cache.json"))
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
- prepared = self._prepare_scene_media(request, workdir)
44
- subtitles = self._write_subtitles(request, timeline, workdir)
45
- video = self._compose_video(prepared, request, subtitles, workdir)
46
- mixed = self.audio.mix(video, request.background_music, request.voiceover, workdir / "mixed.mp4")
 
 
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
- voice_meta = self.assets.probe(request.voiceover)
60
- duration = max(voice_meta.duration, len(request.script.split()) * 0.35, 3.0)
61
- per_scene = duration / max(1, len(request.assets))
62
- captions = _split_script(request.script, len(request.assets))
63
- scenes = [
64
- {
65
- "start": round(idx * per_scene, 3),
66
- "duration": round(per_scene, 3),
67
- "media": asset,
68
- "caption": captions[idx] if idx < len(captions) else "",
69
- }
70
- for idx, asset in enumerate(request.assets)
71
- ]
72
- render_request = RenderRequest(
73
- scenes=Timeline.request_from_payload({"scenes": scenes}).scenes,
74
- template=request.template,
75
- output_name=request.output_name,
76
- voiceover=request.voiceover,
77
- background_music=request.background_music,
78
- )
79
- return self.render(render_request, job_id)
 
 
 
 
 
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):