Upload 25 files
Browse files- renderer/__init__.py +7 -0
- renderer/audio/__init__.py +3 -0
- renderer/audio/mixer.py +55 -0
- renderer/core/__init__.py +1 -0
- renderer/core/config.py +29 -0
- renderer/core/models.py +77 -0
- renderer/core/render_engine.py +158 -0
- renderer/core/utils.py +54 -0
- renderer/exports/__init__.py +3 -0
- renderer/exports/manager.py +20 -0
- renderer/ffmpeg/__init__.py +1 -0
- renderer/ffmpeg/assets.py +79 -0
- renderer/ffmpeg/command.py +26 -0
- renderer/ffmpeg/normalize.py +62 -0
- renderer/ffmpeg/runner.py +96 -0
- renderer/jobs/__init__.py +3 -0
- renderer/jobs/manager.py +90 -0
- renderer/scenes/__init__.py +3 -0
- renderer/scenes/timeline.py +42 -0
- renderer/subtitles/__init__.py +3 -0
- renderer/subtitles/generator.py +89 -0
- renderer/templates/__init__.py +3 -0
- renderer/templates/caption_templates.py +46 -0
- renderer/transitions/__init__.py +3 -0
- renderer/transitions/builder.py +35 -0
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
|