Ava2lon commited on
Commit
750fe81
·
verified ·
1 Parent(s): 98fa87c

Upload 30 files

Browse files
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", self.ducking_filter())
35
- .add("-map", "0:v", "-map", "[aout]", "-c:v", "copy", "-c:a", "aac", "-shortest")
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 (self.temp_dir, self.exports_dir, self.jobs_dir, self.metadata_cache.parent):
 
 
 
 
 
 
 
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
- events = self.subtitles.from_scenes(request.scenes, timeline.total_duration)
 
 
 
 
 
 
 
 
 
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(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))
@@ -37,9 +47,50 @@ class JobManager:
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
@@ -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]