Upload 30 files
Browse files- renderer/audio/mixer.py +11 -4
- renderer/core/config.py +15 -1
- renderer/core/models.py +21 -1
- renderer/core/render_engine.py +83 -3
- renderer/core/security.py +18 -0
- renderer/jobs/manager.py +128 -3
- renderer/scenes/timeline.py +3 -0
- renderer/templates/__init__.py +2 -1
- renderer/templates/presets.py +57 -0
- renderer/transcription/__init__.py +3 -0
- renderer/transcription/whisper.py +128 -0
renderer/audio/mixer.py
CHANGED
|
@@ -19,20 +19,27 @@ class AudioMixer:
|
|
| 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",
|
| 35 |
-
.add("-map", "0:v", "-map",
|
| 36 |
.overwrite()
|
| 37 |
.add(output)
|
| 38 |
.build()
|
|
@@ -45,7 +52,7 @@ class AudioMixer:
|
|
| 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)
|
|
|
|
| 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, normalize: bool = False) -> Path:
|
| 23 |
+
audio_tail = ",loudnorm=I=-16:TP=-1.5:LRA=11" if normalize else ""
|
| 24 |
if not music and not voiceover:
|
| 25 |
command = FFmpegCommand().add("-hide_banner").input(video).add("-c", "copy").overwrite().add(output).build()
|
| 26 |
self.runner.run(command)
|
| 27 |
return output
|
| 28 |
if voiceover and music:
|
| 29 |
+
ducking_filter = self.ducking_filter()
|
| 30 |
+
if normalize:
|
| 31 |
+
ducking_filter += ";[aout]loudnorm=I=-16:TP=-1.5:LRA=11[anorm]"
|
| 32 |
+
audio_map = "[anorm]"
|
| 33 |
+
else:
|
| 34 |
+
audio_map = "[aout]"
|
| 35 |
command = (
|
| 36 |
FFmpegCommand()
|
| 37 |
.add("-hide_banner")
|
| 38 |
.input(video)
|
| 39 |
.input(music)
|
| 40 |
.input(voiceover)
|
| 41 |
+
.add("-filter_complex", ducking_filter)
|
| 42 |
+
.add("-map", "0:v", "-map", audio_map, "-c:v", "copy", "-c:a", "aac", "-shortest")
|
| 43 |
.overwrite()
|
| 44 |
.add(output)
|
| 45 |
.build()
|
|
|
|
| 52 |
.add("-hide_banner")
|
| 53 |
.input(video)
|
| 54 |
.input(audio)
|
| 55 |
+
.add("-filter_complex", f"[1:a]volume={volume}{audio_tail}[aout]")
|
| 56 |
.add("-map", "0:v", "-map", "[aout]", "-c:v", "copy", "-c:a", "aac", "-shortest")
|
| 57 |
.overwrite()
|
| 58 |
.add(output)
|
renderer/core/config.py
CHANGED
|
@@ -15,7 +15,9 @@ class Settings:
|
|
| 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"))
|
|
@@ -24,11 +26,23 @@ class Settings:
|
|
| 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"))
|
| 30 |
preset: str = os.getenv("OUTPUT_PRESET", "veryfast")
|
| 31 |
|
| 32 |
def ensure_dirs(self) -> None:
|
| 33 |
-
for directory in (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
directory.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 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 |
+
storage_dir: Path = Path(os.getenv("STORAGE_DIR", str(DEFAULT_ROOT / "storage")))
|
| 19 |
metadata_cache: Path = Path(os.getenv("METADATA_CACHE", str(DEFAULT_ROOT / "temp" / "metadata_cache.json")))
|
| 20 |
+
signing_secret: str = os.getenv("BASYX_SIGNING_SECRET", "dev-secret-change-me")
|
| 21 |
font_path: Path = Path(os.getenv("FONT_PATH", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"))
|
| 22 |
output_width: int = int(os.getenv("OUTPUT_WIDTH", "1080"))
|
| 23 |
output_height: int = int(os.getenv("OUTPUT_HEIGHT", "1920"))
|
|
|
|
| 26 |
download_timeout_seconds: int = int(os.getenv("DOWNLOAD_TIMEOUT_SECONDS", "60"))
|
| 27 |
max_download_bytes: int = int(os.getenv("MAX_DOWNLOAD_BYTES", str(500 * 1024 * 1024)))
|
| 28 |
allow_private_asset_urls: bool = os.getenv("ALLOW_PRIVATE_ASSET_URLS", "false").lower() == "true"
|
| 29 |
+
whisper_model_size: str = os.getenv("WHISPER_MODEL_SIZE", "tiny")
|
| 30 |
+
whisper_compute_type: str = os.getenv("WHISPER_COMPUTE_TYPE", "int8")
|
| 31 |
+
whisper_device: str = os.getenv("WHISPER_DEVICE", "cpu")
|
| 32 |
+
whisper_model_dir: Path = Path(os.getenv("WHISPER_MODEL_DIR", str(DEFAULT_ROOT / "models")))
|
| 33 |
max_retries: int = int(os.getenv("MAX_RETRIES", "3"))
|
| 34 |
max_workers: int = int(os.getenv("MAX_RENDER_WORKERS", "1"))
|
| 35 |
+
job_retention_seconds: int = int(os.getenv("JOB_RETENTION_SECONDS", str(24 * 3600)))
|
| 36 |
crf: int = int(os.getenv("OUTPUT_CRF", "23"))
|
| 37 |
preset: str = os.getenv("OUTPUT_PRESET", "veryfast")
|
| 38 |
|
| 39 |
def ensure_dirs(self) -> None:
|
| 40 |
+
for directory in (
|
| 41 |
+
self.temp_dir,
|
| 42 |
+
self.exports_dir,
|
| 43 |
+
self.jobs_dir,
|
| 44 |
+
self.storage_dir,
|
| 45 |
+
self.metadata_cache.parent,
|
| 46 |
+
self.whisper_model_dir,
|
| 47 |
+
):
|
| 48 |
directory.mkdir(parents=True, exist_ok=True)
|
renderer/core/models.py
CHANGED
|
@@ -4,7 +4,7 @@ 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
|
|
@@ -15,16 +15,32 @@ class Scene:
|
|
| 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 |
|
|
@@ -71,6 +87,10 @@ class JobRecord:
|
|
| 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)
|
|
|
|
| 4 |
from pathlib import Path
|
| 5 |
from typing import Any, Literal
|
| 6 |
|
| 7 |
+
JobState = Literal["PENDING", "RUNNING", "FAILED", "COMPLETED", "CANCEL_REQUESTED", "CANCELLED"]
|
| 8 |
|
| 9 |
|
| 10 |
@dataclass
|
|
|
|
| 15 |
caption: str = ""
|
| 16 |
transition: str = "fade"
|
| 17 |
background: str = "blur"
|
| 18 |
+
layout: str = "fill"
|
| 19 |
+
effect: str | None = None
|
| 20 |
|
| 21 |
|
| 22 |
@dataclass
|
| 23 |
class RenderRequest:
|
| 24 |
scenes: list[Scene]
|
| 25 |
template: str = "tiktok_classic"
|
| 26 |
+
preset: str | None = None
|
| 27 |
output_name: str = "render.mp4"
|
| 28 |
voiceover: str | None = None
|
| 29 |
background_music: str | None = None
|
| 30 |
subtitle_format: Literal["srt", "ass"] = "ass"
|
| 31 |
+
auto_subtitles: bool = False
|
| 32 |
+
subtitle_language: str | None = None
|
| 33 |
+
whisper_model_size: str | None = None
|
| 34 |
+
preview: bool = False
|
| 35 |
+
audio_normalize: bool = False
|
| 36 |
+
watermark: str | None = None
|
| 37 |
+
watermark_position: str = "bottom-right"
|
| 38 |
+
intro: str | None = None
|
| 39 |
+
outro: str | None = None
|
| 40 |
+
callback_url: str | None = None
|
| 41 |
+
export_target: str | None = None
|
| 42 |
+
priority: int = 0
|
| 43 |
+
scheduled_at: float | None = None
|
| 44 |
normalize: bool = True
|
| 45 |
metadata: dict[str, Any] = field(default_factory=dict)
|
| 46 |
|
|
|
|
| 87 |
created_at: float
|
| 88 |
updated_at: float
|
| 89 |
output_path: str | None = None
|
| 90 |
+
download_token: str | None = None
|
| 91 |
+
callback_url: str | None = None
|
| 92 |
+
export_target: str | None = None
|
| 93 |
+
export_path: str | None = None
|
| 94 |
failure_reason: str | None = None
|
| 95 |
commands: list[list[str]] = field(default_factory=list)
|
| 96 |
logs: list[str] = field(default_factory=list)
|
renderer/core/render_engine.py
CHANGED
|
@@ -17,6 +17,7 @@ from renderer.ffmpeg.normalize import Normalizer
|
|
| 17 |
from renderer.ffmpeg.runner import FFmpegRunner
|
| 18 |
from renderer.scenes import Timeline
|
| 19 |
from renderer.subtitles import SubtitleGenerator
|
|
|
|
| 20 |
from renderer.transitions import TransitionBuilder
|
| 21 |
|
| 22 |
|
|
@@ -31,6 +32,7 @@ class RenderEngine:
|
|
| 31 |
self.assets = AssetProbe(self.settings.metadata_cache)
|
| 32 |
self.normalizer = Normalizer(self.settings, self.runner)
|
| 33 |
self.subtitles = SubtitleGenerator()
|
|
|
|
| 34 |
self.transitions = TransitionBuilder()
|
| 35 |
self.audio = AudioMixer(self.runner)
|
| 36 |
self.exports = ExportManager(self.settings.exports_dir)
|
|
@@ -47,7 +49,8 @@ class RenderEngine:
|
|
| 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 = {
|
|
@@ -90,6 +93,27 @@ class RenderEngine:
|
|
| 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 = []
|
|
@@ -98,7 +122,8 @@ class RenderEngine:
|
|
| 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),
|
|
@@ -120,11 +145,24 @@ class RenderEngine:
|
|
| 120 |
self.normalizer.normalize(source, target, scene.duration)
|
| 121 |
else:
|
| 122 |
shutil.copy2(source, target)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
prepared.append(target)
|
| 124 |
return prepared
|
| 125 |
|
| 126 |
def _write_subtitles(self, request: RenderRequest, timeline: Timeline, workdir: Path) -> Path | None:
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
if not events:
|
| 129 |
return None
|
| 130 |
if request.subtitle_format == "srt":
|
|
@@ -163,6 +201,48 @@ class RenderEngine:
|
|
| 163 |
return subtitled
|
| 164 |
return composed
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
def _run(self, command: list[str]) -> None:
|
| 167 |
result = self.runner.run(command)
|
| 168 |
if result.stderr:
|
|
|
|
| 17 |
from renderer.ffmpeg.runner import FFmpegRunner
|
| 18 |
from renderer.scenes import Timeline
|
| 19 |
from renderer.subtitles import SubtitleGenerator
|
| 20 |
+
from renderer.transcription import WhisperTranscriber
|
| 21 |
from renderer.transitions import TransitionBuilder
|
| 22 |
|
| 23 |
|
|
|
|
| 32 |
self.assets = AssetProbe(self.settings.metadata_cache)
|
| 33 |
self.normalizer = Normalizer(self.settings, self.runner)
|
| 34 |
self.subtitles = SubtitleGenerator()
|
| 35 |
+
self.transcriber = WhisperTranscriber(self.settings)
|
| 36 |
self.transitions = TransitionBuilder()
|
| 37 |
self.audio = AudioMixer(self.runner)
|
| 38 |
self.exports = ExportManager(self.settings.exports_dir)
|
|
|
|
| 49 |
prepared = self._prepare_scene_media(resolved, workdir)
|
| 50 |
subtitles = self._write_subtitles(resolved, timeline, workdir)
|
| 51 |
video = self._compose_video(prepared, resolved, subtitles, workdir)
|
| 52 |
+
mixed = self.audio.mix(video, resolved.background_music, resolved.voiceover, workdir / "mixed.mp4", normalize=resolved.audio_normalize)
|
| 53 |
+
mixed = self._apply_watermark(mixed, resolved, workdir)
|
| 54 |
output = self.exports.save(mixed, job_id, request.output_name)
|
| 55 |
cleanup_directory(workdir, keep={mixed})
|
| 56 |
metrics = {
|
|
|
|
| 93 |
def inspect_asset(self, path: str | Path) -> dict:
|
| 94 |
return self.assets.probe(path).__dict__
|
| 95 |
|
| 96 |
+
def transcribe(
|
| 97 |
+
self,
|
| 98 |
+
audio_path: str | Path,
|
| 99 |
+
*,
|
| 100 |
+
model_size: str | None = None,
|
| 101 |
+
language: str | None = None,
|
| 102 |
+
task: str = "transcribe",
|
| 103 |
+
beam_size: int = 5,
|
| 104 |
+
vad_filter: bool = True,
|
| 105 |
+
word_timestamps: bool = True,
|
| 106 |
+
) -> dict:
|
| 107 |
+
return self.transcriber.transcribe(
|
| 108 |
+
audio_path,
|
| 109 |
+
model_size=model_size,
|
| 110 |
+
language=language,
|
| 111 |
+
task=task,
|
| 112 |
+
beam_size=beam_size,
|
| 113 |
+
vad_filter=vad_filter,
|
| 114 |
+
word_timestamps=word_timestamps,
|
| 115 |
+
).as_dict()
|
| 116 |
+
|
| 117 |
def _render_resolved(self, request: RenderRequest, job_id: str, workdir: Path) -> RenderResult:
|
| 118 |
self._commands = []
|
| 119 |
self._logs = []
|
|
|
|
| 122 |
prepared = self._prepare_scene_media(request, workdir)
|
| 123 |
subtitles = self._write_subtitles(request, timeline, workdir)
|
| 124 |
video = self._compose_video(prepared, request, subtitles, workdir)
|
| 125 |
+
mixed = self.audio.mix(video, request.background_music, request.voiceover, workdir / "mixed.mp4", normalize=request.audio_normalize)
|
| 126 |
+
mixed = self._apply_watermark(mixed, request, workdir)
|
| 127 |
output = self.exports.save(mixed, job_id, request.output_name)
|
| 128 |
metrics = {
|
| 129 |
"render_time_seconds": round(time.time() - started, 3),
|
|
|
|
| 145 |
self.normalizer.normalize(source, target, scene.duration)
|
| 146 |
else:
|
| 147 |
shutil.copy2(source, target)
|
| 148 |
+
if request.preview:
|
| 149 |
+
preview = workdir / f"scene_{idx:03d}_preview.mp4"
|
| 150 |
+
self._scale_preview(target, preview)
|
| 151 |
+
target = preview
|
| 152 |
prepared.append(target)
|
| 153 |
return prepared
|
| 154 |
|
| 155 |
def _write_subtitles(self, request: RenderRequest, timeline: Timeline, workdir: Path) -> Path | None:
|
| 156 |
+
if request.auto_subtitles and request.voiceover:
|
| 157 |
+
transcript = self.transcriber.transcribe(
|
| 158 |
+
request.voiceover,
|
| 159 |
+
model_size=request.whisper_model_size,
|
| 160 |
+
language=request.subtitle_language,
|
| 161 |
+
word_timestamps=True,
|
| 162 |
+
)
|
| 163 |
+
events = transcript.subtitle_events(prefer_words=True)
|
| 164 |
+
else:
|
| 165 |
+
events = self.subtitles.from_scenes(request.scenes, timeline.total_duration)
|
| 166 |
if not events:
|
| 167 |
return None
|
| 168 |
if request.subtitle_format == "srt":
|
|
|
|
| 201 |
return subtitled
|
| 202 |
return composed
|
| 203 |
|
| 204 |
+
def _apply_watermark(self, video: Path, request: RenderRequest, workdir: Path) -> Path:
|
| 205 |
+
if not request.watermark:
|
| 206 |
+
return video
|
| 207 |
+
watermark = Path(request.watermark)
|
| 208 |
+
if not watermark.exists():
|
| 209 |
+
self._logs.append(f"Watermark skipped; file not found: {watermark}")
|
| 210 |
+
return video
|
| 211 |
+
output = workdir / "watermarked.mp4"
|
| 212 |
+
position = {
|
| 213 |
+
"top-left": "20:20",
|
| 214 |
+
"top-right": "W-w-20:20",
|
| 215 |
+
"bottom-left": "20:H-h-20",
|
| 216 |
+
"bottom-right": "W-w-20:H-h-20",
|
| 217 |
+
"center": "(W-w)/2:(H-h)/2",
|
| 218 |
+
}.get(request.watermark_position, "W-w-20:H-h-20")
|
| 219 |
+
command = (
|
| 220 |
+
FFmpegCommand()
|
| 221 |
+
.add("-hide_banner")
|
| 222 |
+
.input(video)
|
| 223 |
+
.input(watermark)
|
| 224 |
+
.add("-filter_complex", f"[1:v]scale=iw*0.22:-1[wm];[0:v][wm]overlay={position}")
|
| 225 |
+
.add("-c:a", "copy", "-c:v", "libx264", "-preset", self.settings.preset, "-crf", self.settings.crf)
|
| 226 |
+
.overwrite()
|
| 227 |
+
.add(output)
|
| 228 |
+
.build()
|
| 229 |
+
)
|
| 230 |
+
self._run(command)
|
| 231 |
+
return output
|
| 232 |
+
|
| 233 |
+
def _scale_preview(self, source: Path, output: Path) -> None:
|
| 234 |
+
command = (
|
| 235 |
+
FFmpegCommand()
|
| 236 |
+
.add("-hide_banner")
|
| 237 |
+
.input(source)
|
| 238 |
+
.add("-vf", "scale=540:960:force_original_aspect_ratio=decrease,pad=540:960:(ow-iw)/2:(oh-ih)/2")
|
| 239 |
+
.add("-c:v", "libx264", "-preset", "ultrafast", "-crf", "30", "-c:a", "aac")
|
| 240 |
+
.overwrite()
|
| 241 |
+
.add(output)
|
| 242 |
+
.build()
|
| 243 |
+
)
|
| 244 |
+
self._run(command)
|
| 245 |
+
|
| 246 |
def _run(self, command: list[str]) -> None:
|
| 247 |
result = self.runner.run(command)
|
| 248 |
if result.stderr:
|
renderer/core/security.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hmac
|
| 4 |
+
import secrets
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def create_download_token(secret: str, job_id: str) -> str:
|
| 8 |
+
nonce = secrets.token_urlsafe(18)
|
| 9 |
+
signature = hmac.digest(secret.encode("utf-8"), f"{job_id}:{nonce}".encode("utf-8"), "sha256").hex()
|
| 10 |
+
return f"{nonce}.{signature}"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def verify_download_token(secret: str, job_id: str, token: str | None) -> bool:
|
| 14 |
+
if not token or "." not in token:
|
| 15 |
+
return False
|
| 16 |
+
nonce, signature = token.split(".", 1)
|
| 17 |
+
expected = hmac.digest(secret.encode("utf-8"), f"{job_id}:{nonce}".encode("utf-8"), "sha256").hex()
|
| 18 |
+
return hmac.compare_digest(signature, expected)
|
renderer/jobs/manager.py
CHANGED
|
@@ -1,7 +1,12 @@
|
|
| 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
|
|
@@ -9,6 +14,7 @@ from typing import Callable
|
|
| 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 |
|
|
@@ -20,7 +26,11 @@ class JobManager:
|
|
| 20 |
self.lock = threading.Lock()
|
| 21 |
|
| 22 |
def submit_render(self, request: RenderRequest) -> str:
|
| 23 |
-
return self._submit(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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))
|
|
@@ -37,9 +47,50 @@ class JobManager:
|
|
| 37 |
raise KeyError(job_id)
|
| 38 |
return JobRecord(**data)
|
| 39 |
|
| 40 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
job_id = new_id()
|
| 42 |
-
record = JobRecord(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
self._save(record)
|
| 44 |
self.executor.submit(self._run_with_retries, job_id, handler)
|
| 45 |
return job_id
|
|
@@ -48,22 +99,34 @@ class JobManager:
|
|
| 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:
|
|
@@ -88,3 +151,65 @@ class JobManager:
|
|
| 88 |
|
| 89 |
def _save(self, record: JobRecord) -> None:
|
| 90 |
write_json(self._record_path(record.job_id), asdict(record))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import concurrent.futures
|
| 4 |
+
import http.client
|
| 5 |
+
import json
|
| 6 |
+
import shutil
|
| 7 |
import threading
|
| 8 |
+
import urllib.request
|
| 9 |
+
from urllib.parse import urlparse
|
| 10 |
from dataclasses import asdict
|
| 11 |
from pathlib import Path
|
| 12 |
from typing import Callable
|
|
|
|
| 14 |
from renderer.core.config import Settings
|
| 15 |
from renderer.core.models import AIReelsRequest, JobRecord, RenderRequest
|
| 16 |
from renderer.core.render_engine import RenderEngine
|
| 17 |
+
from renderer.core.security import create_download_token
|
| 18 |
from renderer.core.utils import new_id, now, read_json, write_json
|
| 19 |
|
| 20 |
|
|
|
|
| 26 |
self.lock = threading.Lock()
|
| 27 |
|
| 28 |
def submit_render(self, request: RenderRequest) -> str:
|
| 29 |
+
return self._submit(
|
| 30 |
+
lambda job_id, log: RenderEngine(self.settings, log=log).render(request, job_id),
|
| 31 |
+
callback_url=request.callback_url,
|
| 32 |
+
export_target=request.export_target,
|
| 33 |
+
)
|
| 34 |
|
| 35 |
def submit_ai_reels(self, request: AIReelsRequest) -> str:
|
| 36 |
return self._submit(lambda job_id, log: RenderEngine(self.settings, log=log).ai_reels(request, job_id))
|
|
|
|
| 47 |
raise KeyError(job_id)
|
| 48 |
return JobRecord(**data)
|
| 49 |
|
| 50 |
+
def cancel(self, job_id: str) -> JobRecord:
|
| 51 |
+
record = self.get(job_id)
|
| 52 |
+
if record.state == "PENDING":
|
| 53 |
+
self._update(job_id, state="CANCELLED", failure_reason="Cancelled before execution")
|
| 54 |
+
elif record.state == "RUNNING":
|
| 55 |
+
self._update(job_id, state="CANCEL_REQUESTED", failure_reason="Cancellation requested")
|
| 56 |
+
return self.get(job_id)
|
| 57 |
+
|
| 58 |
+
def cleanup(self, older_than_seconds: int | None = None) -> dict[str, int]:
|
| 59 |
+
cutoff = now() - (older_than_seconds or self.settings.job_retention_seconds)
|
| 60 |
+
removed_jobs = 0
|
| 61 |
+
removed_exports = 0
|
| 62 |
+
for path in self.settings.jobs_dir.glob("*.json"):
|
| 63 |
+
record = JobRecord(**read_json(path, {}))
|
| 64 |
+
if record.updated_at >= cutoff or record.state in {"PENDING", "RUNNING", "CANCEL_REQUESTED"}:
|
| 65 |
+
continue
|
| 66 |
+
if record.output_path:
|
| 67 |
+
output = Path(record.output_path)
|
| 68 |
+
if output.exists():
|
| 69 |
+
output.unlink()
|
| 70 |
+
removed_exports += 1
|
| 71 |
+
path.unlink(missing_ok=True)
|
| 72 |
+
removed_jobs += 1
|
| 73 |
+
uploads = self.settings.temp_dir / "uploads"
|
| 74 |
+
if uploads.exists():
|
| 75 |
+
shutil.rmtree(uploads, ignore_errors=True)
|
| 76 |
+
return {"removed_jobs": removed_jobs, "removed_exports": removed_exports}
|
| 77 |
+
|
| 78 |
+
def _submit(
|
| 79 |
+
self,
|
| 80 |
+
handler: Callable[[str, Callable[[str], None]], object],
|
| 81 |
+
callback_url: str | None = None,
|
| 82 |
+
export_target: str | None = None,
|
| 83 |
+
) -> str:
|
| 84 |
job_id = new_id()
|
| 85 |
+
record = JobRecord(
|
| 86 |
+
job_id=job_id,
|
| 87 |
+
state="PENDING",
|
| 88 |
+
created_at=now(),
|
| 89 |
+
updated_at=now(),
|
| 90 |
+
download_token=create_download_token(self.settings.signing_secret, job_id),
|
| 91 |
+
callback_url=callback_url,
|
| 92 |
+
export_target=export_target,
|
| 93 |
+
)
|
| 94 |
self._save(record)
|
| 95 |
self.executor.submit(self._run_with_retries, job_id, handler)
|
| 96 |
return job_id
|
|
|
|
| 99 |
attempts = 0
|
| 100 |
while attempts < self.settings.max_retries:
|
| 101 |
attempts += 1
|
| 102 |
+
if self.get(job_id).state == "CANCELLED":
|
| 103 |
+
self._send_callback(job_id)
|
| 104 |
+
return
|
| 105 |
self._update(job_id, state="RUNNING", metrics={"attempt": attempts})
|
| 106 |
try:
|
| 107 |
+
if self.get(job_id).state == "CANCEL_REQUESTED":
|
| 108 |
+
self._update(job_id, state="CANCELLED", failure_reason="Cancelled before render started")
|
| 109 |
+
self._send_callback(job_id)
|
| 110 |
+
return
|
| 111 |
result = handler(job_id, lambda message: self.append_log(job_id, message))
|
| 112 |
+
record = self.get(job_id)
|
| 113 |
+
export_path = self._export_copy(result.output_path, record.export_target, job_id)
|
| 114 |
self._update(
|
| 115 |
job_id,
|
| 116 |
state="COMPLETED",
|
| 117 |
output_path=str(result.output_path),
|
| 118 |
+
export_path=export_path,
|
| 119 |
commands=result.commands,
|
| 120 |
logs=result.logs,
|
| 121 |
metrics=result.metrics | {"attempt": attempts},
|
| 122 |
)
|
| 123 |
+
self._send_callback(job_id)
|
| 124 |
return
|
| 125 |
except Exception as exc:
|
| 126 |
self.append_log(job_id, f"Attempt {attempts} failed: {exc}")
|
| 127 |
if attempts >= self.settings.max_retries:
|
| 128 |
self._update(job_id, state="FAILED", failure_reason=str(exc), metrics={"attempt": attempts})
|
| 129 |
+
self._send_callback(job_id)
|
| 130 |
|
| 131 |
def append_log(self, job_id: str, message: str) -> None:
|
| 132 |
with self.lock:
|
|
|
|
| 151 |
|
| 152 |
def _save(self, record: JobRecord) -> None:
|
| 153 |
write_json(self._record_path(record.job_id), asdict(record))
|
| 154 |
+
|
| 155 |
+
def _export_copy(self, output_path: Path, export_target: str | None, job_id: str) -> str | None:
|
| 156 |
+
if not export_target:
|
| 157 |
+
return None
|
| 158 |
+
if export_target != "local":
|
| 159 |
+
if export_target.startswith(("http://", "https://")):
|
| 160 |
+
_put_file(export_target, output_path)
|
| 161 |
+
return export_target
|
| 162 |
+
return None
|
| 163 |
+
target_dir = self.settings.storage_dir / job_id
|
| 164 |
+
target_dir.mkdir(parents=True, exist_ok=True)
|
| 165 |
+
target = target_dir / output_path.name
|
| 166 |
+
shutil.copy2(output_path, target)
|
| 167 |
+
return str(target)
|
| 168 |
+
|
| 169 |
+
def _send_callback(self, job_id: str) -> None:
|
| 170 |
+
try:
|
| 171 |
+
record = self.get(job_id)
|
| 172 |
+
except KeyError:
|
| 173 |
+
return
|
| 174 |
+
if not record.callback_url:
|
| 175 |
+
return
|
| 176 |
+
payload = json.dumps(asdict(record), default=str).encode("utf-8")
|
| 177 |
+
request = urllib.request.Request(
|
| 178 |
+
record.callback_url,
|
| 179 |
+
data=payload,
|
| 180 |
+
headers={"Content-Type": "application/json", "User-Agent": "basyx-ffmpeg-callback/1.0"},
|
| 181 |
+
method="POST",
|
| 182 |
+
)
|
| 183 |
+
try:
|
| 184 |
+
urllib.request.urlopen(request, timeout=10).read()
|
| 185 |
+
except Exception as exc:
|
| 186 |
+
self.append_log(job_id, f"Callback delivery failed: {exc}")
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _put_file(url: str, path: Path) -> None:
|
| 190 |
+
parsed = urlparse(url)
|
| 191 |
+
connection_cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
|
| 192 |
+
connection = connection_cls(parsed.netloc, timeout=60)
|
| 193 |
+
target = parsed.path or "/"
|
| 194 |
+
if parsed.query:
|
| 195 |
+
target += f"?{parsed.query}"
|
| 196 |
+
headers = {
|
| 197 |
+
"Content-Type": "video/mp4",
|
| 198 |
+
"Content-Length": str(path.stat().st_size),
|
| 199 |
+
"User-Agent": "basyx-ffmpeg-export/1.0",
|
| 200 |
+
}
|
| 201 |
+
connection.putrequest("PUT", target)
|
| 202 |
+
for key, value in headers.items():
|
| 203 |
+
connection.putheader(key, value)
|
| 204 |
+
connection.endheaders()
|
| 205 |
+
with path.open("rb") as source:
|
| 206 |
+
while True:
|
| 207 |
+
chunk = source.read(1024 * 1024)
|
| 208 |
+
if not chunk:
|
| 209 |
+
break
|
| 210 |
+
connection.send(chunk)
|
| 211 |
+
response = connection.getresponse()
|
| 212 |
+
body = response.read()
|
| 213 |
+
connection.close()
|
| 214 |
+
if response.status >= 400:
|
| 215 |
+
raise RuntimeError(f"Export upload failed with HTTP {response.status}: {body[:500]!r}")
|
renderer/scenes/timeline.py
CHANGED
|
@@ -22,6 +22,9 @@ class Timeline:
|
|
| 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 |
)
|
|
|
|
| 22 |
voiceover=payload.get("voiceover"),
|
| 23 |
background_music=payload.get("background_music"),
|
| 24 |
subtitle_format=payload.get("subtitle_format", "ass"),
|
| 25 |
+
auto_subtitles=payload.get("auto_subtitles", False),
|
| 26 |
+
subtitle_language=payload.get("subtitle_language"),
|
| 27 |
+
whisper_model_size=payload.get("whisper_model_size"),
|
| 28 |
normalize=payload.get("normalize", True),
|
| 29 |
metadata=payload.get("metadata", {}),
|
| 30 |
)
|
renderer/templates/__init__.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
from renderer.templates.caption_templates import CaptionTemplate, get_template, list_templates
|
|
|
|
| 2 |
|
| 3 |
-
__all__ = ["CaptionTemplate", "get_template", "list_templates"]
|
|
|
|
| 1 |
from renderer.templates.caption_templates import CaptionTemplate, get_template, list_templates
|
| 2 |
+
from renderer.templates.presets import apply_preset, list_presets
|
| 3 |
|
| 4 |
+
__all__ = ["CaptionTemplate", "apply_preset", "get_template", "list_presets", "list_templates"]
|
renderer/templates/presets.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from copy import deepcopy
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
PRESETS: dict[str, dict[str, Any]] = {
|
| 8 |
+
"tiktok_9_16_fast": {
|
| 9 |
+
"template": "tiktok_classic",
|
| 10 |
+
"subtitle_format": "ass",
|
| 11 |
+
"auto_subtitles": True,
|
| 12 |
+
"preview": False,
|
| 13 |
+
"normalize": True,
|
| 14 |
+
},
|
| 15 |
+
"youtube_shorts_hd": {
|
| 16 |
+
"template": "youtube_shorts",
|
| 17 |
+
"subtitle_format": "ass",
|
| 18 |
+
"auto_subtitles": True,
|
| 19 |
+
"normalize": True,
|
| 20 |
+
},
|
| 21 |
+
"podcast_square": {
|
| 22 |
+
"template": "podcast_style",
|
| 23 |
+
"subtitle_format": "ass",
|
| 24 |
+
"auto_subtitles": True,
|
| 25 |
+
"normalize": True,
|
| 26 |
+
"metadata": {"target_aspect": "1:1"},
|
| 27 |
+
},
|
| 28 |
+
"reels_with_subtitles": {
|
| 29 |
+
"template": "modern_minimal",
|
| 30 |
+
"subtitle_format": "ass",
|
| 31 |
+
"auto_subtitles": True,
|
| 32 |
+
"normalize": True,
|
| 33 |
+
},
|
| 34 |
+
"draft_preview": {
|
| 35 |
+
"template": "modern_minimal",
|
| 36 |
+
"subtitle_format": "ass",
|
| 37 |
+
"preview": True,
|
| 38 |
+
"normalize": True,
|
| 39 |
+
},
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def list_presets() -> list[str]:
|
| 44 |
+
return sorted(PRESETS)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def apply_preset(payload: dict[str, Any]) -> dict[str, Any]:
|
| 48 |
+
preset_name = payload.get("preset")
|
| 49 |
+
if not preset_name:
|
| 50 |
+
return payload
|
| 51 |
+
preset = deepcopy(PRESETS.get(preset_name, {}))
|
| 52 |
+
preset.update(payload)
|
| 53 |
+
if "metadata" in PRESETS.get(preset_name, {}) or "metadata" in payload:
|
| 54 |
+
metadata = deepcopy(PRESETS.get(preset_name, {}).get("metadata", {}))
|
| 55 |
+
metadata.update(payload.get("metadata", {}))
|
| 56 |
+
preset["metadata"] = metadata
|
| 57 |
+
return preset
|
renderer/transcription/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from renderer.transcription.whisper import TranscriptionResult, WhisperTranscriber
|
| 2 |
+
|
| 3 |
+
__all__ = ["TranscriptionResult", "WhisperTranscriber"]
|
renderer/transcription/whisper.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import asdict, dataclass, field
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from threading import Lock
|
| 6 |
+
|
| 7 |
+
from renderer.core.config import Settings
|
| 8 |
+
from renderer.subtitles import SubtitleEvent
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class TranscriptionWord:
|
| 13 |
+
start: float
|
| 14 |
+
end: float
|
| 15 |
+
word: str
|
| 16 |
+
probability: float | None = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class TranscriptionSegment:
|
| 21 |
+
id: int
|
| 22 |
+
start: float
|
| 23 |
+
end: float
|
| 24 |
+
text: str
|
| 25 |
+
words: list[TranscriptionWord] = field(default_factory=list)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class TranscriptionResult:
|
| 30 |
+
text: str
|
| 31 |
+
language: str | None
|
| 32 |
+
language_probability: float | None
|
| 33 |
+
duration: float | None
|
| 34 |
+
segments: list[TranscriptionSegment]
|
| 35 |
+
|
| 36 |
+
def as_dict(self) -> dict:
|
| 37 |
+
return asdict(self)
|
| 38 |
+
|
| 39 |
+
def subtitle_events(self, prefer_words: bool = False) -> list[SubtitleEvent]:
|
| 40 |
+
if prefer_words:
|
| 41 |
+
words = [
|
| 42 |
+
SubtitleEvent(word.start, word.end, word.word.strip())
|
| 43 |
+
for segment in self.segments
|
| 44 |
+
for word in segment.words
|
| 45 |
+
if word.word.strip()
|
| 46 |
+
]
|
| 47 |
+
if words:
|
| 48 |
+
return words
|
| 49 |
+
return [SubtitleEvent(segment.start, segment.end, segment.text.strip()) for segment in self.segments if segment.text.strip()]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class WhisperTranscriber:
|
| 53 |
+
"""Lazy CPU-first faster-whisper wrapper."""
|
| 54 |
+
|
| 55 |
+
_models: dict[tuple[str, str, str, str], object] = {}
|
| 56 |
+
_lock = Lock()
|
| 57 |
+
|
| 58 |
+
def __init__(self, settings: Settings) -> None:
|
| 59 |
+
self.settings = settings
|
| 60 |
+
|
| 61 |
+
def transcribe(
|
| 62 |
+
self,
|
| 63 |
+
audio_path: str | Path,
|
| 64 |
+
*,
|
| 65 |
+
model_size: str | None = None,
|
| 66 |
+
language: str | None = None,
|
| 67 |
+
task: str = "transcribe",
|
| 68 |
+
beam_size: int = 5,
|
| 69 |
+
vad_filter: bool = True,
|
| 70 |
+
word_timestamps: bool = True,
|
| 71 |
+
) -> TranscriptionResult:
|
| 72 |
+
model = self._model(model_size or self.settings.whisper_model_size)
|
| 73 |
+
segments_iter, info = model.transcribe(
|
| 74 |
+
str(audio_path),
|
| 75 |
+
language=language,
|
| 76 |
+
task=task,
|
| 77 |
+
beam_size=beam_size,
|
| 78 |
+
vad_filter=vad_filter,
|
| 79 |
+
word_timestamps=word_timestamps,
|
| 80 |
+
)
|
| 81 |
+
segments: list[TranscriptionSegment] = []
|
| 82 |
+
for segment in segments_iter:
|
| 83 |
+
words = [
|
| 84 |
+
TranscriptionWord(
|
| 85 |
+
start=float(word.start),
|
| 86 |
+
end=float(word.end),
|
| 87 |
+
word=word.word,
|
| 88 |
+
probability=getattr(word, "probability", None),
|
| 89 |
+
)
|
| 90 |
+
for word in (segment.words or [])
|
| 91 |
+
]
|
| 92 |
+
segments.append(
|
| 93 |
+
TranscriptionSegment(
|
| 94 |
+
id=int(segment.id),
|
| 95 |
+
start=float(segment.start),
|
| 96 |
+
end=float(segment.end),
|
| 97 |
+
text=segment.text.strip(),
|
| 98 |
+
words=words,
|
| 99 |
+
)
|
| 100 |
+
)
|
| 101 |
+
return TranscriptionResult(
|
| 102 |
+
text=" ".join(segment.text for segment in segments).strip(),
|
| 103 |
+
language=getattr(info, "language", None),
|
| 104 |
+
language_probability=getattr(info, "language_probability", None),
|
| 105 |
+
duration=getattr(info, "duration", None),
|
| 106 |
+
segments=segments,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
def _model(self, model_size: str):
|
| 110 |
+
key = (
|
| 111 |
+
model_size,
|
| 112 |
+
self.settings.whisper_device,
|
| 113 |
+
self.settings.whisper_compute_type,
|
| 114 |
+
str(self.settings.whisper_model_dir),
|
| 115 |
+
)
|
| 116 |
+
with self._lock:
|
| 117 |
+
if key not in self._models:
|
| 118 |
+
try:
|
| 119 |
+
from faster_whisper import WhisperModel
|
| 120 |
+
except ImportError as exc:
|
| 121 |
+
raise RuntimeError("faster-whisper is not installed. Install requirements.txt to enable transcription.") from exc
|
| 122 |
+
self._models[key] = WhisperModel(
|
| 123 |
+
model_size,
|
| 124 |
+
device=self.settings.whisper_device,
|
| 125 |
+
compute_type=self.settings.whisper_compute_type,
|
| 126 |
+
download_root=str(self.settings.whisper_model_dir),
|
| 127 |
+
)
|
| 128 |
+
return self._models[key]
|