Ava2lon commited on
Commit
3e3d7b1
·
verified ·
1 Parent(s): 5dc16d9

Upload 25 files

Browse files
renderer/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """CPU-first video rendering engine for Basyx FFmpeg."""
2
+
3
+ from renderer.core.config import Settings
4
+ from renderer.core.models import RenderRequest, RenderResult
5
+ from renderer.core.render_engine import RenderEngine
6
+
7
+ __all__ = ["RenderEngine", "RenderRequest", "RenderResult", "Settings"]
renderer/audio/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from renderer.audio.mixer import AudioMixer
2
+
3
+ __all__ = ["AudioMixer"]
renderer/audio/mixer.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from renderer.ffmpeg.command import FFmpegCommand
6
+ from renderer.ffmpeg.runner import FFmpegRunner
7
+
8
+
9
+ class AudioMixer:
10
+ def __init__(self, runner: FFmpegRunner) -> None:
11
+ self.runner = runner
12
+
13
+ def ducking_filter(self) -> str:
14
+ # sidechaincompress lowers background music while voiceover is active.
15
+ return (
16
+ "[1:a]volume=0.316[music_quiet];"
17
+ "[music_quiet][2:a]sidechaincompress=threshold=0.02:ratio=8:attack=30:release=600[ducked];"
18
+ "[2:a]volume=1.0[voice];"
19
+ "[ducked][voice]amix=inputs=2:duration=first:dropout_transition=2[aout]"
20
+ )
21
+
22
+ def mix(self, video: Path, music: str | None, voiceover: str | None, output: Path) -> Path:
23
+ if not music and not voiceover:
24
+ command = FFmpegCommand().add("-hide_banner").input(video).add("-c", "copy").overwrite().add(output).build()
25
+ self.runner.run(command)
26
+ return output
27
+ if voiceover and music:
28
+ command = (
29
+ FFmpegCommand()
30
+ .add("-hide_banner")
31
+ .input(video)
32
+ .input(music)
33
+ .input(voiceover)
34
+ .add("-filter_complex", self.ducking_filter())
35
+ .add("-map", "0:v", "-map", "[aout]", "-c:v", "copy", "-c:a", "aac", "-shortest")
36
+ .overwrite()
37
+ .add(output)
38
+ .build()
39
+ )
40
+ else:
41
+ audio = voiceover or music
42
+ volume = "1.0" if voiceover else "0.316"
43
+ command = (
44
+ FFmpegCommand()
45
+ .add("-hide_banner")
46
+ .input(video)
47
+ .input(audio)
48
+ .add("-filter_complex", f"[1:a]volume={volume}[aout]")
49
+ .add("-map", "0:v", "-map", "[aout]", "-c:v", "copy", "-c:a", "aac", "-shortest")
50
+ .overwrite()
51
+ .add(output)
52
+ .build()
53
+ )
54
+ self.runner.run(command)
55
+ return output
renderer/core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Core configuration, models, and orchestration."""
renderer/core/config.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ 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"))
25
+ preset: str = os.getenv("OUTPUT_PRESET", "veryfast")
26
+
27
+ def ensure_dirs(self) -> None:
28
+ for directory in (self.temp_dir, self.exports_dir, self.jobs_dir, self.metadata_cache.parent):
29
+ directory.mkdir(parents=True, exist_ok=True)
renderer/core/models.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any, Literal
6
+
7
+ JobState = Literal["PENDING", "RUNNING", "FAILED", "COMPLETED"]
8
+
9
+
10
+ @dataclass
11
+ class Scene:
12
+ start: float
13
+ duration: float
14
+ media: str
15
+ caption: str = ""
16
+ transition: str = "fade"
17
+ background: str = "blur"
18
+
19
+
20
+ @dataclass
21
+ class RenderRequest:
22
+ scenes: list[Scene]
23
+ template: str = "tiktok_classic"
24
+ output_name: str = "render.mp4"
25
+ voiceover: str | None = None
26
+ background_music: str | None = None
27
+ subtitle_format: Literal["srt", "ass"] = "ass"
28
+ normalize: bool = True
29
+ metadata: dict[str, Any] = field(default_factory=dict)
30
+
31
+
32
+ @dataclass
33
+ class AIReelsRequest:
34
+ script: str
35
+ voiceover: str
36
+ assets: list[str]
37
+ template: str = "tiktok_classic"
38
+ output_name: str = "ai_reel.mp4"
39
+ background_music: str | None = None
40
+
41
+
42
+ @dataclass
43
+ class RenderResult:
44
+ output_path: Path
45
+ commands: list[list[str]]
46
+ metrics: dict[str, Any]
47
+ logs: list[str] = field(default_factory=list)
48
+
49
+
50
+ @dataclass
51
+ class AssetMetadata:
52
+ path: str
53
+ mime_type: str
54
+ size_bytes: int
55
+ mtime: float
56
+ duration: float = 0.0
57
+ width: int | None = None
58
+ height: int | None = None
59
+ fps: float | None = None
60
+ bitrate: int | None = None
61
+ video_codec: str | None = None
62
+ audio_codec: str | None = None
63
+ has_audio: bool = False
64
+ streams: list[dict[str, Any]] = field(default_factory=list)
65
+
66
+
67
+ @dataclass
68
+ class JobRecord:
69
+ job_id: str
70
+ state: JobState
71
+ created_at: float
72
+ updated_at: float
73
+ output_path: str | None = None
74
+ failure_reason: str | None = None
75
+ commands: list[list[str]] = field(default_factory=list)
76
+ logs: list[str] = field(default_factory=list)
77
+ metrics: dict[str, Any] = field(default_factory=dict)
renderer/core/render_engine.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import mimetypes
4
+ import shutil
5
+ import time
6
+ 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
13
+ from renderer.ffmpeg.assets import AssetProbe
14
+ from renderer.ffmpeg.command import FFmpegCommand
15
+ from renderer.ffmpeg.normalize import Normalizer
16
+ from renderer.ffmpeg.runner import FFmpegRunner
17
+ from renderer.scenes import Timeline
18
+ from renderer.subtitles import SubtitleGenerator
19
+ from renderer.transitions import TransitionBuilder
20
+
21
+
22
+ class RenderEngine:
23
+ def __init__(self, settings: Settings | None = None, log=None) -> None:
24
+ self.settings = settings or Settings()
25
+ self.settings.ensure_dirs()
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()
32
+ self.transitions = TransitionBuilder()
33
+ self.audio = AudioMixer(self.runner)
34
+ self.exports = ExportManager(self.settings.exports_dir)
35
+
36
+ def render(self, request: RenderRequest, job_id: str) -> RenderResult:
37
+ self._commands = []
38
+ self._logs = []
39
+ started = time.time()
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 = {
50
+ "render_time_seconds": round(time.time() - started, 3),
51
+ "output_size_bytes": output.stat().st_size,
52
+ "scene_count": len(request.scenes),
53
+ }
54
+ return RenderResult(output_path=output, commands=list(self._commands), metrics=metrics, logs=list(self._logs))
55
+
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):
87
+ source = Path(scene.media)
88
+ metadata = self.assets.probe(source)
89
+ target = workdir / f"scene_{idx:03d}.mp4"
90
+ mime_type = metadata.mime_type or mimetypes.guess_type(str(source))[0] or ""
91
+ if mime_type.startswith("image/") or source.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp", ".gif"}:
92
+ self.normalizer.image_to_video(source, target, scene.duration)
93
+ elif request.normalize and self.normalizer.needs_normalization(metadata):
94
+ self.normalizer.normalize(source, target, scene.duration)
95
+ else:
96
+ shutil.copy2(source, target)
97
+ prepared.append(target)
98
+ return prepared
99
+
100
+ def _write_subtitles(self, request: RenderRequest, timeline: Timeline, workdir: Path) -> Path | None:
101
+ events = self.subtitles.from_scenes(request.scenes, timeline.total_duration)
102
+ if not events:
103
+ return None
104
+ if request.subtitle_format == "srt":
105
+ return self.subtitles.write_srt(events, workdir / "captions.srt")
106
+ return self.subtitles.write_ass(events, workdir / "captions.ass", request.template)
107
+
108
+ def _compose_video(self, scenes: list[Path], request: RenderRequest, subtitle_path: Path | None, workdir: Path) -> Path:
109
+ if len(scenes) == 1:
110
+ composed = workdir / "composed.mp4"
111
+ shutil.copy2(scenes[0], composed)
112
+ else:
113
+ composed = workdir / "composed.mp4"
114
+ durations = [scene.duration for scene in request.scenes]
115
+ transitions = [scene.transition for scene in request.scenes]
116
+ filter_graph, final_stream = self.transitions.xfade_chain(len(scenes), durations, transitions)
117
+ cmd = FFmpegCommand().add("-hide_banner")
118
+ for scene in scenes:
119
+ cmd.input(scene)
120
+ cmd.add("-filter_complex", filter_graph)
121
+ cmd.add("-map", final_stream, "-an", "-c:v", "libx264", "-preset", self.settings.preset, "-crf", self.settings.crf)
122
+ command = cmd.overwrite().add(composed).build()
123
+ self._run(command)
124
+ if subtitle_path:
125
+ subtitled = workdir / "subtitled.mp4"
126
+ escaped = _ffmpeg_subtitle_path(subtitle_path)
127
+ command = (
128
+ FFmpegCommand()
129
+ .add("-hide_banner")
130
+ .input(composed)
131
+ .add("-vf", f"subtitles='{escaped}'", "-c:a", "copy", "-c:v", "libx264", "-preset", self.settings.preset, "-crf", self.settings.crf)
132
+ .overwrite()
133
+ .add(subtitled)
134
+ .build()
135
+ )
136
+ self._run(command)
137
+ return subtitled
138
+ return composed
139
+
140
+ def _run(self, command: list[str]) -> None:
141
+ result = self.runner.run(command)
142
+ if result.stderr:
143
+ self._logs.append(result.stderr[-4000:])
144
+
145
+ def _record_command(self, command: list[str]) -> None:
146
+ self._commands.append(command)
147
+
148
+
149
+ def _split_script(script: str, chunks: int) -> list[str]:
150
+ words = script.split()
151
+ if chunks <= 0:
152
+ return []
153
+ size = max(1, round(len(words) / chunks))
154
+ return [" ".join(words[i : i + size]) for i in range(0, len(words), size)][:chunks]
155
+
156
+
157
+ def _ffmpeg_subtitle_path(path: Path) -> str:
158
+ return str(path).replace("\\", "/").replace(":", r"\:")
renderer/core/utils.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import shutil
6
+ import tempfile
7
+ import time
8
+ import uuid
9
+ from dataclasses import asdict, is_dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ def new_id(prefix: str = "job") -> str:
15
+ return f"{prefix}_{uuid.uuid4().hex[:12]}"
16
+
17
+
18
+ def safe_filename(name: str) -> str:
19
+ cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("._")
20
+ return cleaned or "render.mp4"
21
+
22
+
23
+ def write_json(path: Path, data: Any) -> None:
24
+ path.parent.mkdir(parents=True, exist_ok=True)
25
+ serializable = asdict(data) if is_dataclass(data) else data
26
+ path.write_text(json.dumps(serializable, indent=2, default=str), encoding="utf-8")
27
+
28
+
29
+ def read_json(path: Path, default: Any) -> Any:
30
+ if not path.exists():
31
+ return default
32
+ return json.loads(path.read_text(encoding="utf-8"))
33
+
34
+
35
+ def now() -> float:
36
+ return time.time()
37
+
38
+
39
+ def temp_workdir(root: Path, prefix: str) -> tempfile.TemporaryDirectory[str]:
40
+ root.mkdir(parents=True, exist_ok=True)
41
+ return tempfile.TemporaryDirectory(prefix=f"{prefix}_", dir=str(root))
42
+
43
+
44
+ def cleanup_directory(path: Path, keep: set[Path] | None = None) -> None:
45
+ keep = {p.resolve() for p in (keep or set())}
46
+ if not path.exists():
47
+ return
48
+ for child in path.iterdir():
49
+ if child.resolve() in keep:
50
+ continue
51
+ if child.is_dir():
52
+ shutil.rmtree(child, ignore_errors=True)
53
+ else:
54
+ child.unlink(missing_ok=True)
renderer/exports/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from renderer.exports.manager import ExportManager
2
+
3
+ __all__ = ["ExportManager"]
renderer/exports/manager.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ from renderer.core.utils import safe_filename
7
+
8
+
9
+ class ExportManager:
10
+ def __init__(self, exports_dir: Path) -> None:
11
+ self.exports_dir = exports_dir
12
+ self.exports_dir.mkdir(parents=True, exist_ok=True)
13
+
14
+ def save(self, source: Path, job_id: str, output_name: str) -> Path:
15
+ filename = safe_filename(output_name)
16
+ if not filename.lower().endswith(".mp4"):
17
+ filename += ".mp4"
18
+ target = self.exports_dir / f"{job_id}_{filename}"
19
+ shutil.copy2(source, target)
20
+ return target
renderer/ffmpeg/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """FFmpeg subprocess and filter graph helpers."""
renderer/ffmpeg/assets.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import mimetypes
5
+ import subprocess
6
+ from dataclasses import asdict
7
+ from pathlib import Path
8
+
9
+ from renderer.core.models import AssetMetadata
10
+ from renderer.core.utils import read_json, write_json
11
+
12
+
13
+ class AssetProbe:
14
+ def __init__(self, cache_path: Path) -> None:
15
+ self.cache_path = cache_path
16
+ self.cache: dict[str, dict] = read_json(cache_path, {})
17
+
18
+ def probe(self, path: str | Path) -> AssetMetadata:
19
+ media = Path(path)
20
+ stat = media.stat()
21
+ key = str(media.resolve())
22
+ cached = self.cache.get(key)
23
+ if cached and cached.get("size_bytes") == stat.st_size and cached.get("mtime") == stat.st_mtime:
24
+ return AssetMetadata(**cached)
25
+
26
+ raw = self._ffprobe(media)
27
+ streams = raw.get("streams", [])
28
+ fmt = raw.get("format", {})
29
+ video_stream = next((s for s in streams if s.get("codec_type") == "video"), {})
30
+ audio_stream = next((s for s in streams if s.get("codec_type") == "audio"), {})
31
+ metadata = AssetMetadata(
32
+ path=str(media),
33
+ mime_type=mimetypes.guess_type(str(media))[0] or "application/octet-stream",
34
+ size_bytes=stat.st_size,
35
+ mtime=stat.st_mtime,
36
+ duration=float(fmt.get("duration") or video_stream.get("duration") or audio_stream.get("duration") or 0),
37
+ width=_int_or_none(video_stream.get("width")),
38
+ height=_int_or_none(video_stream.get("height")),
39
+ fps=_parse_fps(video_stream.get("avg_frame_rate") or video_stream.get("r_frame_rate")),
40
+ bitrate=_int_or_none(fmt.get("bit_rate")),
41
+ video_codec=video_stream.get("codec_name"),
42
+ audio_codec=audio_stream.get("codec_name"),
43
+ has_audio=bool(audio_stream),
44
+ streams=streams,
45
+ )
46
+ self.cache[key] = asdict(metadata)
47
+ write_json(self.cache_path, self.cache)
48
+ return metadata
49
+
50
+ @staticmethod
51
+ def _ffprobe(path: Path) -> dict:
52
+ command = [
53
+ "ffprobe",
54
+ "-v",
55
+ "error",
56
+ "-show_format",
57
+ "-show_streams",
58
+ "-print_format",
59
+ "json",
60
+ str(path),
61
+ ]
62
+ result = subprocess.run(command, check=True, capture_output=True, text=True)
63
+ return json.loads(result.stdout or "{}")
64
+
65
+
66
+ def _parse_fps(value: str | None) -> float | None:
67
+ if not value or value == "0/0":
68
+ return None
69
+ if "/" in value:
70
+ num, den = value.split("/", 1)
71
+ den_f = float(den)
72
+ return float(num) / den_f if den_f else None
73
+ return float(value)
74
+
75
+
76
+ def _int_or_none(value: object) -> int | None:
77
+ if value in (None, ""):
78
+ return None
79
+ return int(value)
renderer/ffmpeg/command.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass
8
+ class FFmpegCommand:
9
+ args: list[str] = field(default_factory=lambda: ["ffmpeg"])
10
+
11
+ def add(self, *args: object) -> "FFmpegCommand":
12
+ self.args.extend(str(arg) for arg in args)
13
+ return self
14
+
15
+ def input(self, path: str | Path, **options: object) -> "FFmpegCommand":
16
+ for key, value in options.items():
17
+ self.add(f"-{key}", value)
18
+ self.add("-i", path)
19
+ return self
20
+
21
+ def overwrite(self) -> "FFmpegCommand":
22
+ self.add("-y")
23
+ return self
24
+
25
+ def build(self) -> list[str]:
26
+ return list(self.args)
renderer/ffmpeg/normalize.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from renderer.core.config import Settings
6
+ from renderer.core.models import AssetMetadata
7
+ from renderer.ffmpeg.command import FFmpegCommand
8
+ from renderer.ffmpeg.runner import FFmpegRunner
9
+
10
+
11
+ class Normalizer:
12
+ def __init__(self, settings: Settings, runner: FFmpegRunner) -> None:
13
+ self.settings = settings
14
+ self.runner = runner
15
+
16
+ def needs_normalization(self, metadata: AssetMetadata) -> bool:
17
+ return not (
18
+ metadata.width == self.settings.output_width
19
+ and metadata.height == self.settings.output_height
20
+ and round(metadata.fps or 0) == self.settings.output_fps
21
+ and metadata.video_codec == "h264"
22
+ and (metadata.audio_codec in ("aac", None))
23
+ )
24
+
25
+ def normalize(self, path: str | Path, output: Path, duration: float | None = None) -> Path:
26
+ vf = (
27
+ f"scale={self.settings.output_width}:{self.settings.output_height}:"
28
+ "force_original_aspect_ratio=increase,"
29
+ f"crop={self.settings.output_width}:{self.settings.output_height},"
30
+ f"fps={self.settings.output_fps},format=yuv420p"
31
+ )
32
+ cmd = (
33
+ FFmpegCommand()
34
+ .add("-hide_banner")
35
+ .input(path)
36
+ .add("-vf", vf, "-c:v", "libx264", "-preset", self.settings.preset, "-crf", self.settings.crf)
37
+ .add("-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart")
38
+ )
39
+ if duration:
40
+ cmd.add("-t", duration)
41
+ command = cmd.overwrite().add(output).build()
42
+ self.runner.run(command)
43
+ return output
44
+
45
+ def image_to_video(self, path: str | Path, output: Path, duration: float) -> Path:
46
+ vf = (
47
+ f"scale={self.settings.output_width}:{self.settings.output_height}:"
48
+ "force_original_aspect_ratio=increase,"
49
+ f"crop={self.settings.output_width}:{self.settings.output_height},"
50
+ f"fps={self.settings.output_fps},format=yuv420p"
51
+ )
52
+ command = (
53
+ FFmpegCommand()
54
+ .add("-hide_banner", "-loop", "1", "-t", duration)
55
+ .input(path)
56
+ .add("-vf", vf, "-an", "-c:v", "libx264", "-preset", self.settings.preset, "-crf", self.settings.crf)
57
+ .overwrite()
58
+ .add(output)
59
+ .build()
60
+ )
61
+ self.runner.run(command)
62
+ return output
renderer/ffmpeg/runner.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import time
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Callable
8
+
9
+ try:
10
+ import psutil
11
+ except Exception: # pragma: no cover - optional runtime dependency fallback
12
+ psutil = None
13
+
14
+
15
+ class FFmpegError(RuntimeError):
16
+ def __init__(self, message: str, command: list[str], stderr: str = "") -> None:
17
+ super().__init__(message)
18
+ self.command = command
19
+ self.stderr = stderr
20
+
21
+
22
+ @dataclass
23
+ class CommandResult:
24
+ command: list[str]
25
+ returncode: int
26
+ duration_seconds: float
27
+ stdout: str = ""
28
+ stderr: str = ""
29
+ metrics: dict[str, float | int] = field(default_factory=dict)
30
+
31
+
32
+ class FFmpegRunner:
33
+ def __init__(
34
+ self,
35
+ timeout_seconds: int = 900,
36
+ log: Callable[[str], None] | None = None,
37
+ on_command: Callable[[list[str]], None] | None = None,
38
+ ) -> None:
39
+ self.timeout_seconds = timeout_seconds
40
+ self.log = log or (lambda _: None)
41
+ self.on_command = on_command or (lambda _: None)
42
+
43
+ def run(self, command: list[str], cwd: Path | None = None) -> CommandResult:
44
+ started = time.time()
45
+ self.on_command(command)
46
+ self.log("$ " + " ".join(command))
47
+ process = subprocess.Popen(
48
+ command,
49
+ cwd=str(cwd) if cwd else None,
50
+ stdout=subprocess.PIPE,
51
+ stderr=subprocess.PIPE,
52
+ text=True,
53
+ encoding="utf-8",
54
+ errors="replace",
55
+ )
56
+ peak_rss = 0
57
+ cpu_percent = 0.0
58
+ proc = psutil.Process(process.pid) if psutil else None
59
+ try:
60
+ stdout, stderr = process.communicate(timeout=self.timeout_seconds)
61
+ if proc:
62
+ try:
63
+ peak_rss = max(peak_rss, proc.memory_info().rss)
64
+ cpu_percent = proc.cpu_percent(interval=None)
65
+ except Exception:
66
+ pass
67
+ except subprocess.TimeoutExpired as exc:
68
+ self._kill_process(process)
69
+ stdout, stderr = process.communicate()
70
+ raise FFmpegError(f"FFmpeg timed out after {self.timeout_seconds}s", command, stderr) from exc
71
+
72
+ duration = time.time() - started
73
+ result = CommandResult(
74
+ command=command,
75
+ returncode=process.returncode,
76
+ duration_seconds=duration,
77
+ stdout=stdout,
78
+ stderr=stderr,
79
+ metrics={"duration_seconds": duration, "peak_rss_bytes": peak_rss, "cpu_percent": cpu_percent},
80
+ )
81
+ if process.returncode != 0:
82
+ raise FFmpegError("FFmpeg failed", command, stderr)
83
+ return result
84
+
85
+ @staticmethod
86
+ def _kill_process(process: subprocess.Popen[str]) -> None:
87
+ if psutil:
88
+ try:
89
+ parent = psutil.Process(process.pid)
90
+ for child in parent.children(recursive=True):
91
+ child.kill()
92
+ parent.kill()
93
+ return
94
+ except Exception:
95
+ pass
96
+ process.kill()
renderer/jobs/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from renderer.jobs.manager import JobManager
2
+
3
+ __all__ = ["JobManager"]
renderer/jobs/manager.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import concurrent.futures
4
+ import threading
5
+ from dataclasses import asdict
6
+ from pathlib import Path
7
+ from typing import Callable
8
+
9
+ from renderer.core.config import Settings
10
+ from renderer.core.models import AIReelsRequest, JobRecord, RenderRequest
11
+ from renderer.core.render_engine import RenderEngine
12
+ from renderer.core.utils import new_id, now, read_json, write_json
13
+
14
+
15
+ class JobManager:
16
+ def __init__(self, settings: Settings | None = None) -> None:
17
+ self.settings = settings or Settings()
18
+ self.settings.ensure_dirs()
19
+ self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=self.settings.max_workers)
20
+ self.lock = threading.Lock()
21
+
22
+ def submit_render(self, request: RenderRequest) -> str:
23
+ return self._submit(lambda job_id, log: RenderEngine(self.settings, log=log).render(request, job_id))
24
+
25
+ def submit_ai_reels(self, request: AIReelsRequest) -> str:
26
+ return self._submit(lambda job_id, log: RenderEngine(self.settings, log=log).ai_reels(request, job_id))
27
+
28
+ def submit_batch(self, requests: list[RenderRequest]) -> list[str]:
29
+ job_ids: list[str] = []
30
+ for request in requests:
31
+ job_ids.append(self.submit_render(request))
32
+ return job_ids
33
+
34
+ def get(self, job_id: str) -> JobRecord:
35
+ data = read_json(self._record_path(job_id), None)
36
+ if data is None:
37
+ raise KeyError(job_id)
38
+ return JobRecord(**data)
39
+
40
+ def _submit(self, handler: Callable[[str, Callable[[str], None]], object]) -> str:
41
+ job_id = new_id()
42
+ record = JobRecord(job_id=job_id, state="PENDING", created_at=now(), updated_at=now())
43
+ self._save(record)
44
+ self.executor.submit(self._run_with_retries, job_id, handler)
45
+ return job_id
46
+
47
+ def _run_with_retries(self, job_id: str, handler: Callable[[str, Callable[[str], None]], object]) -> None:
48
+ attempts = 0
49
+ while attempts < self.settings.max_retries:
50
+ attempts += 1
51
+ self._update(job_id, state="RUNNING", metrics={"attempt": attempts})
52
+ try:
53
+ result = handler(job_id, lambda message: self.append_log(job_id, message))
54
+ self._update(
55
+ job_id,
56
+ state="COMPLETED",
57
+ output_path=str(result.output_path),
58
+ commands=result.commands,
59
+ logs=result.logs,
60
+ metrics=result.metrics | {"attempt": attempts},
61
+ )
62
+ return
63
+ except Exception as exc:
64
+ self.append_log(job_id, f"Attempt {attempts} failed: {exc}")
65
+ if attempts >= self.settings.max_retries:
66
+ self._update(job_id, state="FAILED", failure_reason=str(exc), metrics={"attempt": attempts})
67
+
68
+ def append_log(self, job_id: str, message: str) -> None:
69
+ with self.lock:
70
+ record = self.get(job_id)
71
+ record.logs.append(message)
72
+ record.updated_at = now()
73
+ self._save(record)
74
+
75
+ def _update(self, job_id: str, **changes) -> None:
76
+ with self.lock:
77
+ record = self.get(job_id)
78
+ for key, value in changes.items():
79
+ if key == "metrics" and record.metrics and isinstance(value, dict):
80
+ record.metrics.update(value)
81
+ else:
82
+ setattr(record, key, value)
83
+ record.updated_at = now()
84
+ self._save(record)
85
+
86
+ def _record_path(self, job_id: str) -> Path:
87
+ return self.settings.jobs_dir / f"{job_id}.json"
88
+
89
+ def _save(self, record: JobRecord) -> None:
90
+ write_json(self._record_path(record.job_id), asdict(record))
renderer/scenes/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from renderer.scenes.timeline import Timeline
2
+
3
+ __all__ = ["Timeline"]
renderer/scenes/timeline.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from renderer.core.models import RenderRequest, Scene
4
+
5
+
6
+ class Timeline:
7
+ def __init__(self, scenes: list[Scene]) -> None:
8
+ self.scenes = sorted(scenes, key=lambda scene: scene.start)
9
+ self.validate()
10
+
11
+ @classmethod
12
+ def from_payload(cls, payload: dict) -> "Timeline":
13
+ return cls([Scene(**scene) for scene in payload.get("scenes", [])])
14
+
15
+ @classmethod
16
+ def request_from_payload(cls, payload: dict) -> RenderRequest:
17
+ scenes = [Scene(**scene) for scene in payload.get("scenes", [])]
18
+ return RenderRequest(
19
+ scenes=scenes,
20
+ template=payload.get("template", "tiktok_classic"),
21
+ output_name=payload.get("output_name", "render.mp4"),
22
+ voiceover=payload.get("voiceover"),
23
+ background_music=payload.get("background_music"),
24
+ subtitle_format=payload.get("subtitle_format", "ass"),
25
+ normalize=payload.get("normalize", True),
26
+ metadata=payload.get("metadata", {}),
27
+ )
28
+
29
+ @property
30
+ def total_duration(self) -> float:
31
+ return max((scene.start + scene.duration for scene in self.scenes), default=0.0)
32
+
33
+ def validate(self) -> None:
34
+ if not self.scenes:
35
+ raise ValueError("At least one scene is required")
36
+ for scene in self.scenes:
37
+ if scene.duration <= 0:
38
+ raise ValueError("Scene duration must be greater than zero")
39
+ if scene.start < 0:
40
+ raise ValueError("Scene start must be non-negative")
41
+ if not scene.media:
42
+ raise ValueError("Scene media path is required")
renderer/subtitles/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from renderer.subtitles.generator import SubtitleEvent, SubtitleGenerator
2
+
3
+ __all__ = ["SubtitleEvent", "SubtitleGenerator"]
renderer/subtitles/generator.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from renderer.templates import get_template
8
+
9
+
10
+ @dataclass
11
+ class SubtitleEvent:
12
+ start: float
13
+ end: float
14
+ text: str
15
+
16
+
17
+ class SubtitleGenerator:
18
+ def from_scenes(self, scenes: list, total_duration: float | None = None) -> list[SubtitleEvent]:
19
+ events: list[SubtitleEvent] = []
20
+ for scene in scenes:
21
+ if scene.caption:
22
+ events.append(SubtitleEvent(scene.start, scene.start + scene.duration, scene.caption))
23
+ if not events and total_duration:
24
+ events.append(SubtitleEvent(0, total_duration, ""))
25
+ return events
26
+
27
+ def write_srt(self, events: list[SubtitleEvent], output: Path) -> Path:
28
+ lines: list[str] = []
29
+ for idx, event in enumerate(events, start=1):
30
+ lines.extend([str(idx), f"{_srt_time(event.start)} --> {_srt_time(event.end)}", event.text, ""])
31
+ output.write_text("\n".join(lines), encoding="utf-8")
32
+ return output
33
+
34
+ def write_ass(self, events: list[SubtitleEvent], output: Path, template_key: str) -> Path:
35
+ template = get_template(template_key)
36
+ body = [
37
+ "[Script Info]",
38
+ "ScriptType: v4.00+",
39
+ "PlayResX: 1080",
40
+ "PlayResY: 1920",
41
+ "",
42
+ "[V4+ Styles]",
43
+ "Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,"
44
+ "Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,"
45
+ "Alignment,MarginL,MarginR,MarginV,Encoding",
46
+ template.ass_style(),
47
+ "",
48
+ "[Events]",
49
+ "Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text",
50
+ ]
51
+ for event in events:
52
+ text = _ass_escape(event.text)
53
+ if template.effect == "karaoke":
54
+ text = _karaoke_text(text, event.end - event.start)
55
+ elif template.effect == "zoom":
56
+ text = r"{\t(0,180,\fscx115\fscy115)\t(180,360,\fscx100\fscy100)}" + text
57
+ elif template.effect == "bounce":
58
+ text = r"{\t(0,120,\frz-2)\t(120,240,\frz2)\t(240,360,\frz0)}" + text
59
+ body.append(f"Dialogue: 0,{_ass_time(event.start)},{_ass_time(event.end)},Default,,0,0,0,,{text}")
60
+ output.write_text("\n".join(body), encoding="utf-8")
61
+ return output
62
+
63
+
64
+ def _karaoke_text(text: str, duration: float) -> str:
65
+ words = text.split()
66
+ if not words:
67
+ return text
68
+ centiseconds = max(1, int(duration * 100 / len(words)))
69
+ return "".join(f"{{\\k{centiseconds}}}{word} " for word in words).strip()
70
+
71
+
72
+ def _srt_time(seconds: float) -> str:
73
+ ms = int(round(seconds * 1000))
74
+ h, rem = divmod(ms, 3600000)
75
+ m, rem = divmod(rem, 60000)
76
+ s, ms = divmod(rem, 1000)
77
+ return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
78
+
79
+
80
+ def _ass_time(seconds: float) -> str:
81
+ cs = int(round(seconds * 100))
82
+ h, rem = divmod(cs, 360000)
83
+ m, rem = divmod(rem, 6000)
84
+ s, cs = divmod(rem, 100)
85
+ return f"{h}:{m:02d}:{s:02d}.{cs:02d}"
86
+
87
+
88
+ def _ass_escape(text: str) -> str:
89
+ return html.escape(text).replace("\n", r"\N").replace("{", r"\{").replace("}", r"\}")
renderer/templates/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from renderer.templates.caption_templates import CaptionTemplate, get_template, list_templates
2
+
3
+ __all__ = ["CaptionTemplate", "get_template", "list_templates"]
renderer/templates/caption_templates.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class CaptionTemplate:
8
+ key: str
9
+ label: str
10
+ font_size: int
11
+ primary_color: str
12
+ secondary_color: str
13
+ outline_color: str = "&H000000"
14
+ back_color: str = "&H80000000"
15
+ alignment: int = 2
16
+ margin_v: int = 180
17
+ bold: bool = True
18
+ effect: str = "none"
19
+
20
+ def ass_style(self) -> str:
21
+ bold = -1 if self.bold else 0
22
+ return (
23
+ "Style: Default,DejaVu Sans,"
24
+ f"{self.font_size},{self.primary_color},{self.secondary_color},"
25
+ f"{self.outline_color},{self.back_color},{bold},0,0,0,100,100,0,0,3,3,1,"
26
+ f"{self.alignment},80,80,{self.margin_v},1"
27
+ )
28
+
29
+
30
+ TEMPLATES: dict[str, CaptionTemplate] = {
31
+ "tiktok_classic": CaptionTemplate("tiktok_classic", "TikTok Classic", 64, "&H00FFFFFF", "&H0000FFFF", effect="karaoke"),
32
+ "tiktok_zoom": CaptionTemplate("tiktok_zoom", "TikTok Zoom", 72, "&H00FFFFFF", "&H0000E5FF", effect="zoom"),
33
+ "alex_hormozi": CaptionTemplate("alex_hormozi", "Alex Hormozi", 70, "&H0000FFFF", "&H00FFFFFF", effect="bounce"),
34
+ "modern_minimal": CaptionTemplate("modern_minimal", "Modern Minimal", 52, "&H00FFFFFF", "&H00DDDDDD", margin_v=240),
35
+ "youtube_shorts": CaptionTemplate("youtube_shorts", "YouTube Shorts", 62, "&H00FFFFFF", "&H000000FF", effect="karaoke"),
36
+ "podcast_style": CaptionTemplate("podcast_style", "Podcast Style", 48, "&H00F5F5F5", "&H0099CCFF", margin_v=120),
37
+ "news_style": CaptionTemplate("news_style", "News Style", 46, "&H00FFFFFF", "&H0000FFFF", alignment=2, margin_v=100),
38
+ }
39
+
40
+
41
+ def get_template(key: str) -> CaptionTemplate:
42
+ return TEMPLATES.get(key, TEMPLATES["tiktok_classic"])
43
+
44
+
45
+ def list_templates() -> list[str]:
46
+ return list(TEMPLATES.keys())
renderer/transitions/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from renderer.transitions.builder import TransitionBuilder
2
+
3
+ __all__ = ["TransitionBuilder"]
renderer/transitions/builder.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+
4
+ class TransitionBuilder:
5
+ """Build FFmpeg xfade filters for scene joins."""
6
+
7
+ TRANSITIONS = {
8
+ "fade": "fade",
9
+ "zoom": "zoomin",
10
+ "slide": "slideleft",
11
+ "push": "slideup",
12
+ "blur": "fade",
13
+ "whip": "smoothleft",
14
+ "dissolve": "dissolve",
15
+ }
16
+
17
+ def map_transition(self, name: str) -> str:
18
+ return self.TRANSITIONS.get(name, "fade")
19
+
20
+ def xfade_chain(self, stream_count: int, durations: list[float], transitions: list[str], transition_duration: float = 0.45) -> tuple[str, str]:
21
+ if stream_count <= 1:
22
+ return "", "[0:v]"
23
+ filters: list[str] = []
24
+ current = "[0:v]"
25
+ offset = max(0.1, durations[0] - transition_duration)
26
+ for idx in range(1, stream_count):
27
+ out = f"[vx{idx}]"
28
+ transition = self.map_transition(transitions[idx - 1] if idx - 1 < len(transitions) else "fade")
29
+ filters.append(
30
+ f"{current}[{idx}:v]xfade=transition={transition}:duration={transition_duration}:offset={offset:.3f}{out}"
31
+ )
32
+ current = out
33
+ if idx < len(durations):
34
+ offset += max(0.1, durations[idx] - transition_duration)
35
+ return ";".join(filters), current