Ava2lon commited on
Commit
4c5a10d
·
verified ·
1 Parent(s): aa4434c

Upload 38 files

Browse files
renderer/audio/mixer.py CHANGED
@@ -19,14 +19,48 @@ 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, 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]"
@@ -36,7 +70,7 @@ class AudioMixer:
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")
@@ -46,13 +80,17 @@ class AudioMixer:
46
  )
47
  else:
48
  audio = voiceover or music
49
- volume = "1.0" if voiceover else "0.316"
 
 
 
 
50
  command = (
51
  FFmpegCommand()
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)
@@ -60,3 +98,21 @@ class AudioMixer:
60
  )
61
  self.runner.run(command)
62
  return output
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  "[ducked][voice]amix=inputs=2:duration=first:dropout_transition=2[aout]"
20
  )
21
 
22
+ def mix(
23
+ self,
24
+ video: Path,
25
+ music: str | None,
26
+ voiceover: str | None,
27
+ output: Path,
28
+ normalize: bool = False,
29
+ *,
30
+ duration: float | None = None,
31
+ music_volume: float = 0.316,
32
+ music_fade_in: float = 0.0,
33
+ music_fade_out: float = 0.0,
34
+ music_loop: bool = True,
35
+ music_start: float = 0.0,
36
+ music_ducking: bool = True,
37
+ voice_volume: float = 1.0,
38
+ ) -> Path:
39
  audio_tail = ",loudnorm=I=-16:TP=-1.5:LRA=11" if normalize else ""
40
  if not music and not voiceover:
41
  command = FFmpegCommand().add("-hide_banner").input(video).add("-c", "copy").overwrite().add(output).build()
42
  self.runner.run(command)
43
  return output
44
  if voiceover and music:
45
+ music_filter = _music_filter(
46
+ music_volume,
47
+ fade_in=music_fade_in,
48
+ fade_out=music_fade_out,
49
+ duration=duration,
50
+ )
51
+ if music_ducking:
52
+ ducking_filter = (
53
+ f"[1:a]{music_filter}[music];"
54
+ "[music][2:a]sidechaincompress=threshold=0.02:ratio=8:attack=30:release=600[ducked];"
55
+ f"[2:a]volume={voice_volume}[voice];"
56
+ "[ducked][voice]amix=inputs=2:duration=first:dropout_transition=2[aout]"
57
+ )
58
+ else:
59
+ ducking_filter = (
60
+ f"[1:a]{music_filter}[music];"
61
+ f"[2:a]volume={voice_volume}[voice];"
62
+ "[music][voice]amix=inputs=2:duration=first:dropout_transition=2[aout]"
63
+ )
64
  if normalize:
65
  ducking_filter += ";[aout]loudnorm=I=-16:TP=-1.5:LRA=11[anorm]"
66
  audio_map = "[anorm]"
 
70
  FFmpegCommand()
71
  .add("-hide_banner")
72
  .input(video)
73
+ .input(music, **_music_input_options(music_loop, music_start))
74
  .input(voiceover)
75
  .add("-filter_complex", ducking_filter)
76
  .add("-map", "0:v", "-map", audio_map, "-c:v", "copy", "-c:a", "aac", "-shortest")
 
80
  )
81
  else:
82
  audio = voiceover or music
83
+ input_options = {} if voiceover else _music_input_options(music_loop, music_start)
84
+ volume = voice_volume if voiceover else music_volume
85
+ audio_filter = f"volume={volume}"
86
+ if music and not voiceover:
87
+ audio_filter = _music_filter(music_volume, fade_in=music_fade_in, fade_out=music_fade_out, duration=duration)
88
  command = (
89
  FFmpegCommand()
90
  .add("-hide_banner")
91
  .input(video)
92
+ .input(audio, **input_options)
93
+ .add("-filter_complex", f"[1:a]{audio_filter}{audio_tail}[aout]")
94
  .add("-map", "0:v", "-map", "[aout]", "-c:v", "copy", "-c:a", "aac", "-shortest")
95
  .overwrite()
96
  .add(output)
 
98
  )
99
  self.runner.run(command)
100
  return output
101
+
102
+
103
+ def _music_input_options(loop: bool, start: float) -> dict[str, object]:
104
+ options: dict[str, object] = {}
105
+ if loop:
106
+ options["stream_loop"] = -1
107
+ if start > 0:
108
+ options["ss"] = start
109
+ return options
110
+
111
+
112
+ def _music_filter(volume: float, *, fade_in: float, fade_out: float, duration: float | None) -> str:
113
+ filters = [f"volume={volume}"]
114
+ if fade_in > 0:
115
+ filters.append(f"afade=t=in:st=0:d={fade_in}")
116
+ if fade_out > 0 and duration and duration > fade_out:
117
+ filters.append(f"afade=t=out:st={max(0.0, duration - fade_out):.3f}:d={fade_out}")
118
+ return ",".join(filters)
renderer/core/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (176 Bytes). View file
 
renderer/core/__pycache__/config.cpython-310.pyc ADDED
Binary file (2.71 kB). View file
 
renderer/core/__pycache__/ingest.cpython-310.pyc ADDED
Binary file (5.51 kB). View file
 
renderer/core/__pycache__/models.cpython-310.pyc ADDED
Binary file (4.08 kB). View file
 
renderer/core/__pycache__/render_engine.cpython-310.pyc ADDED
Binary file (12.2 kB). View file
 
renderer/core/__pycache__/security.cpython-310.pyc ADDED
Binary file (907 Bytes). View file
 
renderer/core/__pycache__/utils.cpython-310.pyc ADDED
Binary file (2.36 kB). View file
 
renderer/core/models.py CHANGED
@@ -24,9 +24,17 @@ 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
@@ -51,8 +59,16 @@ class AIReelsRequest:
51
  voiceover: str
52
  assets: list[str]
53
  template: str = "tiktok_classic"
 
54
  output_name: str = "ai_reel.mp4"
55
  background_music: str | None = None
 
 
 
 
 
 
 
56
 
57
 
58
  @dataclass
 
24
  scenes: list[Scene]
25
  template: str = "tiktok_classic"
26
  preset: str | None = None
27
+ platform: str | None = None
28
  output_name: str = "render.mp4"
29
  voiceover: str | None = None
30
  background_music: str | None = None
31
+ music_volume: float = 0.316
32
+ music_fade_in: float = 0.0
33
+ music_fade_out: float = 0.0
34
+ music_loop: bool = True
35
+ music_start: float = 0.0
36
+ music_ducking: bool = True
37
+ voice_volume: float = 1.0
38
  subtitle_format: Literal["srt", "ass"] = "ass"
39
  auto_subtitles: bool = False
40
  subtitle_language: str | None = None
 
59
  voiceover: str
60
  assets: list[str]
61
  template: str = "tiktok_classic"
62
+ platform: str | None = None
63
  output_name: str = "ai_reel.mp4"
64
  background_music: str | None = None
65
+ music_volume: float = 0.316
66
+ music_fade_in: float = 0.0
67
+ music_fade_out: float = 0.0
68
+ music_loop: bool = True
69
+ music_start: float = 0.0
70
+ music_ducking: bool = True
71
+ voice_volume: float = 1.0
72
 
73
 
74
  @dataclass
renderer/core/render_engine.py CHANGED
@@ -1,4 +1,4 @@
1
- from __future__ import annotations
2
 
3
  import mimetypes
4
  import shutil
@@ -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.transcription import WhisperTranscriber
21
  from renderer.transitions import TransitionBuilder
22
 
@@ -49,14 +50,31 @@ class RenderEngine:
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 = {
57
  "render_time_seconds": round(time.time() - started, 3),
58
  "output_size_bytes": output.stat().st_size,
59
  "scene_count": len(request.scenes),
 
60
  }
61
  return RenderResult(output_path=output, commands=list(self._commands), metrics=metrics, logs=list(self._logs))
62
 
@@ -84,9 +102,17 @@ class RenderEngine:
84
  render_request = RenderRequest(
85
  scenes=Timeline.request_from_payload({"scenes": scenes}).scenes,
86
  template=resolved.template,
 
87
  output_name=resolved.output_name,
88
  voiceover=resolved.voiceover,
89
  background_music=resolved.background_music,
 
 
 
 
 
 
 
90
  )
91
  return self._render_resolved(render_request, job_id, workdir)
92
 
@@ -122,27 +148,45 @@ class RenderEngine:
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),
130
  "output_size_bytes": output.stat().st_size,
131
  "scene_count": len(request.scenes),
 
132
  }
133
  return RenderResult(output_path=output, commands=list(self._commands), metrics=metrics, logs=list(self._logs))
134
 
135
  def _prepare_scene_media(self, request: RenderRequest, workdir: Path) -> list[Path]:
136
  prepared: list[Path] = []
 
137
  for idx, scene in enumerate(request.scenes):
138
  source = Path(scene.media)
139
  metadata = self.assets.probe(source)
140
  target = workdir / f"scene_{idx:03d}.mp4"
141
  mime_type = metadata.mime_type or mimetypes.guess_type(str(source))[0] or ""
142
  if mime_type.startswith("image/") or source.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp", ".gif"}:
143
- self.normalizer.image_to_video(source, target, scene.duration)
144
- elif request.normalize and self.normalizer.needs_normalization(metadata):
145
- self.normalizer.normalize(source, target, scene.duration)
146
  else:
147
  shutil.copy2(source, target)
148
  if request.preview:
@@ -230,6 +274,66 @@ class RenderEngine:
230
  self._run(command)
231
  return output
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  def _scale_preview(self, source: Path, output: Path) -> None:
234
  command = (
235
  FFmpegCommand()
@@ -262,3 +366,4 @@ def _split_script(script: str, chunks: int) -> list[str]:
262
 
263
  def _ffmpeg_subtitle_path(path: Path) -> str:
264
  return str(path).replace("\\", "/").replace(":", r"\:")
 
 
1
+ from __future__ import annotations
2
 
3
  import mimetypes
4
  import shutil
 
17
  from renderer.ffmpeg.runner import FFmpegRunner
18
  from renderer.scenes import Timeline
19
  from renderer.subtitles import SubtitleGenerator
20
+ from renderer.templates import PlatformProfile, get_platform_profile
21
  from renderer.transcription import WhisperTranscriber
22
  from renderer.transitions import TransitionBuilder
23
 
 
50
  prepared = self._prepare_scene_media(resolved, workdir)
51
  subtitles = self._write_subtitles(resolved, timeline, workdir)
52
  video = self._compose_video(prepared, resolved, subtitles, workdir)
53
+ mixed = self.audio.mix(
54
+ video,
55
+ resolved.background_music,
56
+ resolved.voiceover,
57
+ workdir / "mixed.mp4",
58
+ normalize=resolved.audio_normalize,
59
+ duration=timeline.total_duration,
60
+ music_volume=resolved.music_volume,
61
+ music_fade_in=resolved.music_fade_in,
62
+ music_fade_out=resolved.music_fade_out,
63
+ music_loop=resolved.music_loop,
64
+ music_start=resolved.music_start,
65
+ music_ducking=resolved.music_ducking,
66
+ voice_volume=resolved.voice_volume,
67
+ )
68
  mixed = self._apply_watermark(mixed, resolved, workdir)
69
+ optimized = self._optimize_for_platform(mixed, resolved, workdir)
70
+ output = self.exports.save(optimized, job_id, request.output_name)
71
  cleanup_directory(workdir, keep={mixed})
72
+ profile = self._profile(request)
73
  metrics = {
74
  "render_time_seconds": round(time.time() - started, 3),
75
  "output_size_bytes": output.stat().st_size,
76
  "scene_count": len(request.scenes),
77
+ "platform": profile.metadata(),
78
  }
79
  return RenderResult(output_path=output, commands=list(self._commands), metrics=metrics, logs=list(self._logs))
80
 
 
102
  render_request = RenderRequest(
103
  scenes=Timeline.request_from_payload({"scenes": scenes}).scenes,
104
  template=resolved.template,
105
+ platform=resolved.platform,
106
  output_name=resolved.output_name,
107
  voiceover=resolved.voiceover,
108
  background_music=resolved.background_music,
109
+ music_volume=resolved.music_volume,
110
+ music_fade_in=resolved.music_fade_in,
111
+ music_fade_out=resolved.music_fade_out,
112
+ music_loop=resolved.music_loop,
113
+ music_start=resolved.music_start,
114
+ music_ducking=resolved.music_ducking,
115
+ voice_volume=resolved.voice_volume,
116
  )
117
  return self._render_resolved(render_request, job_id, workdir)
118
 
 
148
  prepared = self._prepare_scene_media(request, workdir)
149
  subtitles = self._write_subtitles(request, timeline, workdir)
150
  video = self._compose_video(prepared, request, subtitles, workdir)
151
+ mixed = self.audio.mix(
152
+ video,
153
+ request.background_music,
154
+ request.voiceover,
155
+ workdir / "mixed.mp4",
156
+ normalize=request.audio_normalize,
157
+ duration=timeline.total_duration,
158
+ music_volume=request.music_volume,
159
+ music_fade_in=request.music_fade_in,
160
+ music_fade_out=request.music_fade_out,
161
+ music_loop=request.music_loop,
162
+ music_start=request.music_start,
163
+ music_ducking=request.music_ducking,
164
+ voice_volume=request.voice_volume,
165
+ )
166
  mixed = self._apply_watermark(mixed, request, workdir)
167
+ optimized = self._optimize_for_platform(mixed, request, workdir)
168
+ output = self.exports.save(optimized, job_id, request.output_name)
169
+ profile = self._profile(request)
170
  metrics = {
171
  "render_time_seconds": round(time.time() - started, 3),
172
  "output_size_bytes": output.stat().st_size,
173
  "scene_count": len(request.scenes),
174
+ "platform": profile.metadata(),
175
  }
176
  return RenderResult(output_path=output, commands=list(self._commands), metrics=metrics, logs=list(self._logs))
177
 
178
  def _prepare_scene_media(self, request: RenderRequest, workdir: Path) -> list[Path]:
179
  prepared: list[Path] = []
180
+ profile = self._profile(request)
181
  for idx, scene in enumerate(request.scenes):
182
  source = Path(scene.media)
183
  metadata = self.assets.probe(source)
184
  target = workdir / f"scene_{idx:03d}.mp4"
185
  mime_type = metadata.mime_type or mimetypes.guess_type(str(source))[0] or ""
186
  if mime_type.startswith("image/") or source.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp", ".gif"}:
187
+ self.normalizer.image_to_video(source, target, scene.duration, profile=profile)
188
+ elif request.normalize and self.normalizer.needs_normalization(metadata, profile=profile):
189
+ self.normalizer.normalize(source, target, scene.duration, profile=profile)
190
  else:
191
  shutil.copy2(source, target)
192
  if request.preview:
 
274
  self._run(command)
275
  return output
276
 
277
+ def _optimize_for_platform(self, video: Path, request: RenderRequest, workdir: Path) -> Path:
278
+ profile = self._profile(request)
279
+ output = workdir / "platform_optimized.mp4"
280
+ vf = (
281
+ f"scale={profile.width}:{profile.height}:force_original_aspect_ratio=increase,"
282
+ f"crop={profile.width}:{profile.height},fps={profile.fps},format=yuv420p"
283
+ )
284
+ command_builder = (
285
+ FFmpegCommand()
286
+ .add("-hide_banner")
287
+ .input(video)
288
+ .add(
289
+ "-vf",
290
+ vf,
291
+ "-c:v",
292
+ profile.video_codec,
293
+ "-profile:v",
294
+ "high",
295
+ "-pix_fmt",
296
+ "yuv420p",
297
+ "-preset",
298
+ self.settings.preset,
299
+ "-crf",
300
+ profile.crf,
301
+ "-r",
302
+ profile.fps,
303
+ "-g",
304
+ max(1, profile.fps * 2),
305
+ )
306
+ )
307
+ if profile.maxrate:
308
+ command_builder.add("-maxrate", profile.maxrate)
309
+ if profile.bufsize:
310
+ command_builder.add("-bufsize", profile.bufsize)
311
+ command = (
312
+ command_builder.add(
313
+ "-c:a",
314
+ profile.audio_codec,
315
+ "-b:a",
316
+ profile.audio_bitrate,
317
+ "-ar",
318
+ profile.audio_sample_rate,
319
+ "-ac",
320
+ "2",
321
+ "-movflags",
322
+ "+faststart",
323
+ "-shortest",
324
+ )
325
+ .overwrite()
326
+ .add(output)
327
+ .build()
328
+ )
329
+ self._run(command)
330
+ return output
331
+
332
+ def _profile(self, request: RenderRequest) -> PlatformProfile:
333
+ profile_key = request.platform or request.metadata.get("platform") or request.metadata.get("target_platform")
334
+ return get_platform_profile(str(profile_key) if profile_key else None)
335
+
336
+
337
  def _scale_preview(self, source: Path, output: Path) -> None:
338
  command = (
339
  FFmpegCommand()
 
366
 
367
  def _ffmpeg_subtitle_path(path: Path) -> str:
368
  return str(path).replace("\\", "/").replace(":", r"\:")
369
+
renderer/ffmpeg/normalize.py CHANGED
@@ -6,6 +6,7 @@ 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:
@@ -13,27 +14,34 @@ class Normalizer:
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:
@@ -42,18 +50,22 @@ class Normalizer:
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()
 
6
  from renderer.core.models import AssetMetadata
7
  from renderer.ffmpeg.command import FFmpegCommand
8
  from renderer.ffmpeg.runner import FFmpegRunner
9
+ from renderer.templates import PlatformProfile
10
 
11
 
12
  class Normalizer:
 
14
  self.settings = settings
15
  self.runner = runner
16
 
17
+ def needs_normalization(self, metadata: AssetMetadata, profile: PlatformProfile | None = None) -> bool:
18
+ width = profile.width if profile else self.settings.output_width
19
+ height = profile.height if profile else self.settings.output_height
20
+ fps = profile.fps if profile else self.settings.output_fps
21
  return not (
22
+ metadata.width == width
23
+ and metadata.height == height
24
+ and round(metadata.fps or 0) == fps
25
  and metadata.video_codec == "h264"
26
  and (metadata.audio_codec in ("aac", None))
27
  )
28
 
29
+ def normalize(self, path: str | Path, output: Path, duration: float | None = None, profile: PlatformProfile | None = None) -> Path:
30
+ width = profile.width if profile else self.settings.output_width
31
+ height = profile.height if profile else self.settings.output_height
32
+ fps = profile.fps if profile else self.settings.output_fps
33
+ crf = profile.crf if profile else self.settings.crf
34
  vf = (
35
+ f"scale={width}:{height}:"
36
  "force_original_aspect_ratio=increase,"
37
+ f"crop={width}:{height},"
38
+ f"fps={fps},format=yuv420p"
39
  )
40
  cmd = (
41
  FFmpegCommand()
42
  .add("-hide_banner")
43
  .input(path)
44
+ .add("-vf", vf, "-c:v", "libx264", "-preset", self.settings.preset, "-crf", crf)
45
  .add("-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart")
46
  )
47
  if duration:
 
50
  self.runner.run(command)
51
  return output
52
 
53
+ def image_to_video(self, path: str | Path, output: Path, duration: float, profile: PlatformProfile | None = None) -> Path:
54
+ width = profile.width if profile else self.settings.output_width
55
+ height = profile.height if profile else self.settings.output_height
56
+ fps = profile.fps if profile else self.settings.output_fps
57
+ crf = profile.crf if profile else self.settings.crf
58
  vf = (
59
+ f"scale={width}:{height}:"
60
  "force_original_aspect_ratio=increase,"
61
+ f"crop={width}:{height},"
62
+ f"fps={fps},format=yuv420p"
63
  )
64
  command = (
65
  FFmpegCommand()
66
  .add("-hide_banner", "-loop", "1", "-t", duration)
67
  .input(path)
68
+ .add("-vf", vf, "-an", "-c:v", "libx264", "-preset", self.settings.preset, "-crf", crf)
69
  .overwrite()
70
  .add(output)
71
  .build()
renderer/scenes/timeline.py CHANGED
@@ -18,9 +18,18 @@ class Timeline:
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
  auto_subtitles=payload.get("auto_subtitles", False),
26
  subtitle_language=payload.get("subtitle_language"),
 
18
  return RenderRequest(
19
  scenes=scenes,
20
  template=payload.get("template", "tiktok_classic"),
21
+ preset=payload.get("preset"),
22
+ platform=payload.get("platform"),
23
  output_name=payload.get("output_name", "render.mp4"),
24
  voiceover=payload.get("voiceover"),
25
  background_music=payload.get("background_music"),
26
+ music_volume=payload.get("music_volume", 0.316),
27
+ music_fade_in=payload.get("music_fade_in", 0.0),
28
+ music_fade_out=payload.get("music_fade_out", 0.0),
29
+ music_loop=payload.get("music_loop", True),
30
+ music_start=payload.get("music_start", 0.0),
31
+ music_ducking=payload.get("music_ducking", True),
32
+ voice_volume=payload.get("voice_volume", 1.0),
33
  subtitle_format=payload.get("subtitle_format", "ass"),
34
  auto_subtitles=payload.get("auto_subtitles", False),
35
  subtitle_language=payload.get("subtitle_language"),
renderer/templates/__init__.py CHANGED
@@ -1,4 +1,15 @@
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"]
 
 
 
 
 
 
 
 
 
 
 
1
  from renderer.templates.caption_templates import CaptionTemplate, get_template, list_templates
2
+ from renderer.templates.platforms import PlatformProfile, get_platform_profile, list_platform_profiles, platform_profile_metadata
3
  from renderer.templates.presets import apply_preset, list_presets
4
 
5
+ __all__ = [
6
+ "CaptionTemplate",
7
+ "PlatformProfile",
8
+ "apply_preset",
9
+ "get_platform_profile",
10
+ "get_template",
11
+ "list_platform_profiles",
12
+ "list_presets",
13
+ "list_templates",
14
+ "platform_profile_metadata",
15
+ ]
renderer/templates/platforms.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class PlatformProfile:
9
+ key: str
10
+ label: str
11
+ width: int
12
+ height: int
13
+ fps: int = 30
14
+ video_codec: str = "libx264"
15
+ audio_codec: str = "aac"
16
+ audio_bitrate: str = "128k"
17
+ audio_sample_rate: int = 48000
18
+ crf: int = 23
19
+ maxrate: str | None = None
20
+ bufsize: str | None = None
21
+ max_duration_seconds: int | None = None
22
+ recommended_duration_seconds: tuple[int, int] | None = None
23
+ safe_zones: dict[str, int] = field(default_factory=dict)
24
+ notes: tuple[str, ...] = ()
25
+
26
+ @property
27
+ def aspect_ratio(self) -> str:
28
+ return f"{self.width}:{self.height}"
29
+
30
+ def metadata(self) -> dict[str, Any]:
31
+ return {
32
+ "platform": self.key,
33
+ "label": self.label,
34
+ "width": self.width,
35
+ "height": self.height,
36
+ "fps": self.fps,
37
+ "aspect_ratio": self.aspect_ratio,
38
+ "video_codec": self.video_codec,
39
+ "audio_codec": self.audio_codec,
40
+ "audio_bitrate": self.audio_bitrate,
41
+ "max_duration_seconds": self.max_duration_seconds,
42
+ "recommended_duration_seconds": self.recommended_duration_seconds,
43
+ "safe_zones": self.safe_zones,
44
+ "notes": list(self.notes),
45
+ }
46
+
47
+
48
+ COMMON_VERTICAL_SAFE_ZONES = {
49
+ "top_px": 220,
50
+ "bottom_px": 340,
51
+ "left_px": 80,
52
+ "right_px": 80,
53
+ }
54
+
55
+
56
+ PLATFORM_PROFILES: dict[str, PlatformProfile] = {
57
+ "tiktok": PlatformProfile(
58
+ key="tiktok",
59
+ label="TikTok vertical",
60
+ width=1080,
61
+ height=1920,
62
+ fps=30,
63
+ maxrate="8M",
64
+ bufsize="16M",
65
+ max_duration_seconds=600,
66
+ recommended_duration_seconds=(12, 60),
67
+ safe_zones=COMMON_VERTICAL_SAFE_ZONES,
68
+ notes=(
69
+ "9:16 vertical MP4 keeps the frame full-screen in the For You feed.",
70
+ "Use licensed or original music to avoid muted audio or takedowns.",
71
+ ),
72
+ ),
73
+ "instagram_reels": PlatformProfile(
74
+ key="instagram_reels",
75
+ label="Instagram Reels",
76
+ width=1080,
77
+ height=1920,
78
+ fps=30,
79
+ maxrate="8M",
80
+ bufsize="16M",
81
+ max_duration_seconds=180,
82
+ recommended_duration_seconds=(7, 90),
83
+ safe_zones=COMMON_VERTICAL_SAFE_ZONES,
84
+ notes=(
85
+ "9:16 is the safest Reels export to avoid cropping or blank space.",
86
+ "Keep captions and logos away from top and bottom app chrome.",
87
+ ),
88
+ ),
89
+ "facebook_reels": PlatformProfile(
90
+ key="facebook_reels",
91
+ label="Facebook Reels",
92
+ width=1080,
93
+ height=1920,
94
+ fps=30,
95
+ maxrate="8M",
96
+ bufsize="16M",
97
+ max_duration_seconds=180,
98
+ recommended_duration_seconds=(7, 90),
99
+ safe_zones=COMMON_VERTICAL_SAFE_ZONES,
100
+ ),
101
+ "youtube_shorts": PlatformProfile(
102
+ key="youtube_shorts",
103
+ label="YouTube Shorts",
104
+ width=1080,
105
+ height=1920,
106
+ fps=30,
107
+ audio_bitrate="192k",
108
+ maxrate="10M",
109
+ bufsize="20M",
110
+ max_duration_seconds=180,
111
+ recommended_duration_seconds=(15, 60),
112
+ safe_zones={"top_px": 180, "bottom_px": 300, "left_px": 80, "right_px": 80},
113
+ notes=(
114
+ "YouTube categorizes square or vertical videos up to 3 minutes as Shorts.",
115
+ "Avoid Content ID-claimed music in Shorts longer than 60 seconds.",
116
+ ),
117
+ ),
118
+ "youtube_1080p": PlatformProfile(
119
+ key="youtube_1080p",
120
+ label="YouTube 1080p landscape",
121
+ width=1920,
122
+ height=1080,
123
+ fps=30,
124
+ audio_bitrate="192k",
125
+ maxrate="12M",
126
+ bufsize="24M",
127
+ recommended_duration_seconds=(60, 600),
128
+ notes=("16:9 H.264/AAC output for standard YouTube uploads.",),
129
+ ),
130
+ "instagram_feed_square": PlatformProfile(
131
+ key="instagram_feed_square",
132
+ label="Instagram feed square",
133
+ width=1080,
134
+ height=1080,
135
+ fps=30,
136
+ maxrate="8M",
137
+ bufsize="16M",
138
+ max_duration_seconds=3600,
139
+ recommended_duration_seconds=(5, 60),
140
+ safe_zones={"top_px": 80, "bottom_px": 120, "left_px": 80, "right_px": 80},
141
+ ),
142
+ "instagram_feed_portrait": PlatformProfile(
143
+ key="instagram_feed_portrait",
144
+ label="Instagram feed portrait",
145
+ width=1080,
146
+ height=1350,
147
+ fps=30,
148
+ maxrate="8M",
149
+ bufsize="16M",
150
+ max_duration_seconds=3600,
151
+ recommended_duration_seconds=(5, 60),
152
+ safe_zones={"top_px": 80, "bottom_px": 140, "left_px": 80, "right_px": 80},
153
+ ),
154
+ }
155
+
156
+
157
+ def get_platform_profile(key: str | None) -> PlatformProfile:
158
+ if not key:
159
+ return PLATFORM_PROFILES["tiktok"]
160
+ return PLATFORM_PROFILES.get(key, PLATFORM_PROFILES["tiktok"])
161
+
162
+
163
+ def list_platform_profiles() -> list[str]:
164
+ return sorted(PLATFORM_PROFILES)
165
+
166
+
167
+ def platform_profile_metadata() -> dict[str, dict[str, Any]]:
168
+ return {key: profile.metadata() for key, profile in sorted(PLATFORM_PROFILES.items())}
renderer/templates/presets.py CHANGED
@@ -6,6 +6,7 @@ from typing import Any
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,
@@ -13,12 +14,14 @@ PRESETS: dict[str, dict[str, Any]] = {
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,
@@ -26,17 +29,53 @@ PRESETS: dict[str, dict[str, Any]] = {
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
 
 
6
 
7
  PRESETS: dict[str, dict[str, Any]] = {
8
  "tiktok_9_16_fast": {
9
+ "platform": "tiktok",
10
  "template": "tiktok_classic",
11
  "subtitle_format": "ass",
12
  "auto_subtitles": True,
 
14
  "normalize": True,
15
  },
16
  "youtube_shorts_hd": {
17
+ "platform": "youtube_shorts",
18
  "template": "youtube_shorts",
19
  "subtitle_format": "ass",
20
  "auto_subtitles": True,
21
  "normalize": True,
22
  },
23
  "podcast_square": {
24
+ "platform": "instagram_feed_square",
25
  "template": "podcast_style",
26
  "subtitle_format": "ass",
27
  "auto_subtitles": True,
 
29
  "metadata": {"target_aspect": "1:1"},
30
  },
31
  "reels_with_subtitles": {
32
+ "platform": "instagram_reels",
33
  "template": "modern_minimal",
34
  "subtitle_format": "ass",
35
  "auto_subtitles": True,
36
  "normalize": True,
37
  },
38
  "draft_preview": {
39
+ "platform": "tiktok",
40
  "template": "modern_minimal",
41
  "subtitle_format": "ass",
42
  "preview": True,
43
  "normalize": True,
44
  },
45
+ "tiktok_music_ducked": {
46
+ "platform": "tiktok",
47
+ "template": "tiktok_classic",
48
+ "subtitle_format": "ass",
49
+ "auto_subtitles": True,
50
+ "audio_normalize": True,
51
+ "music_volume": 0.25,
52
+ "music_fade_in": 0.4,
53
+ "music_fade_out": 1.0,
54
+ "music_loop": True,
55
+ "music_ducking": True,
56
+ "normalize": True,
57
+ },
58
+ "instagram_reels_music": {
59
+ "platform": "instagram_reels",
60
+ "template": "modern_minimal",
61
+ "subtitle_format": "ass",
62
+ "auto_subtitles": True,
63
+ "audio_normalize": True,
64
+ "music_volume": 0.28,
65
+ "music_fade_in": 0.3,
66
+ "music_fade_out": 0.8,
67
+ "music_loop": True,
68
+ "music_ducking": True,
69
+ "normalize": True,
70
+ },
71
+ "youtube_landscape_1080p": {
72
+ "platform": "youtube_1080p",
73
+ "template": "news_style",
74
+ "subtitle_format": "ass",
75
+ "auto_subtitles": False,
76
+ "audio_normalize": True,
77
+ "normalize": True,
78
+ },
79
  }
80
 
81