Upload 38 files
Browse files- renderer/audio/mixer.py +62 -6
- renderer/core/__pycache__/__init__.cpython-310.pyc +0 -0
- renderer/core/__pycache__/config.cpython-310.pyc +0 -0
- renderer/core/__pycache__/ingest.cpython-310.pyc +0 -0
- renderer/core/__pycache__/models.cpython-310.pyc +0 -0
- renderer/core/__pycache__/render_engine.cpython-310.pyc +0 -0
- renderer/core/__pycache__/security.cpython-310.pyc +0 -0
- renderer/core/__pycache__/utils.cpython-310.pyc +0 -0
- renderer/core/models.py +16 -0
- renderer/core/render_engine.py +113 -8
- renderer/ffmpeg/normalize.py +26 -14
- renderer/scenes/timeline.py +9 -0
- renderer/templates/__init__.py +12 -1
- renderer/templates/platforms.py +168 -0
- renderer/templates/presets.py +39 -0
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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
command = (
|
| 51 |
FFmpegCommand()
|
| 52 |
.add("-hide_banner")
|
| 53 |
.input(video)
|
| 54 |
-
.input(audio)
|
| 55 |
-
.add("-filter_complex", f"[1:a]
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
mixed = self._apply_watermark(mixed, resolved, workdir)
|
| 54 |
-
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
mixed = self._apply_watermark(mixed, request, workdir)
|
| 127 |
-
|
|
|
|
|
|
|
| 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 ==
|
| 19 |
-
and metadata.height ==
|
| 20 |
-
and round(metadata.fps or 0) ==
|
| 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={
|
| 28 |
"force_original_aspect_ratio=increase,"
|
| 29 |
-
f"crop={
|
| 30 |
-
f"fps={
|
| 31 |
)
|
| 32 |
cmd = (
|
| 33 |
FFmpegCommand()
|
| 34 |
.add("-hide_banner")
|
| 35 |
.input(path)
|
| 36 |
-
.add("-vf", vf, "-c:v", "libx264", "-preset", self.settings.preset, "-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={
|
| 48 |
"force_original_aspect_ratio=increase,"
|
| 49 |
-
f"crop={
|
| 50 |
-
f"fps={
|
| 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",
|
| 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__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|