Ava2lon commited on
Commit
9845310
·
verified ·
1 Parent(s): 39035d1

Upload 34 files

Browse files
renderer/core/config.py CHANGED
@@ -18,6 +18,7 @@ class Settings:
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"))
 
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
+ api_key: str = os.getenv("BASYX_API_KEY", "")
22
  font_path: Path = Path(os.getenv("FONT_PATH", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"))
23
  output_width: int = int(os.getenv("OUTPUT_WIDTH", "1080"))
24
  output_height: int = int(os.getenv("OUTPUT_HEIGHT", "1920"))
renderer/core/models.py CHANGED
@@ -24,6 +24,7 @@ class RenderRequest:
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
@@ -59,6 +60,7 @@ class AIReelsRequest:
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
@@ -79,6 +81,14 @@ class RenderResult:
79
  logs: list[str] = field(default_factory=list)
80
 
81
 
 
 
 
 
 
 
 
 
82
  @dataclass
83
  class AssetMetadata:
84
  path: str
 
24
  scenes: list[Scene]
25
  template: str = "tiktok_classic"
26
  preset: str | None = None
27
+ creative_style: str | None = None
28
  platform: str | None = None
29
  output_name: str = "render.mp4"
30
  voiceover: str | None = None
 
60
  voiceover: str
61
  assets: list[str]
62
  template: str = "tiktok_classic"
63
+ creative_style: str | None = None
64
  platform: str | None = None
65
  output_name: str = "ai_reel.mp4"
66
  background_music: str | None = None
 
81
  logs: list[str] = field(default_factory=list)
82
 
83
 
84
+ @dataclass
85
+ class TaskResult:
86
+ output_path: Path | None = None
87
+ commands: list[list[str]] = field(default_factory=list)
88
+ metrics: dict[str, Any] = field(default_factory=dict)
89
+ logs: list[str] = field(default_factory=list)
90
+
91
+
92
  @dataclass
93
  class AssetMetadata:
94
  path: str
renderer/core/render_engine.py CHANGED
@@ -17,7 +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.templates import PlatformProfile, get_platform_profile
21
  from renderer.transcription import WhisperTranscriber
22
  from renderer.transitions import TransitionBuilder
23
 
@@ -74,6 +74,8 @@ class RenderEngine:
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))
@@ -86,8 +88,9 @@ class RenderEngine:
86
  with temp_workdir(self.settings.temp_dir, job_id) as work:
87
  workdir = Path(work)
88
  resolved = self.ingest.resolve_ai_reels_request(request, workdir)
 
89
  voice_meta = self.assets.probe(resolved.voiceover)
90
- duration = max(voice_meta.duration, len(resolved.script.split()) * 0.35, 3.0)
91
  per_scene = duration / max(1, len(resolved.assets))
92
  captions = _split_script(resolved.script, len(resolved.assets))
93
  scenes = [
@@ -96,6 +99,8 @@ class RenderEngine:
96
  "duration": round(per_scene, 3),
97
  "media": asset,
98
  "caption": captions[idx] if idx < len(captions) else "",
 
 
99
  }
100
  for idx, asset in enumerate(resolved.assets)
101
  ]
@@ -113,6 +118,8 @@ class RenderEngine:
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
 
@@ -171,6 +178,8 @@ class RenderEngine:
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))
@@ -193,6 +202,9 @@ class RenderEngine:
193
  preview = workdir / f"scene_{idx:03d}_preview.mp4"
194
  self._scale_preview(target, preview)
195
  target = preview
 
 
 
196
  prepared.append(target)
197
  return prepared
198
 
@@ -347,6 +359,23 @@ class RenderEngine:
347
  )
348
  self._run(command)
349
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  def _run(self, command: list[str]) -> None:
351
  result = self.runner.run(command)
352
  if result.stderr:
@@ -366,4 +395,3 @@ def _split_script(script: str, chunks: int) -> list[str]:
366
 
367
  def _ffmpeg_subtitle_path(path: Path) -> str:
368
  return str(path).replace("\\", "/").replace(":", r"\:")
369
-
 
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_creative_style, get_platform_profile, scene_effect_filter
21
  from renderer.transcription import WhisperTranscriber
22
  from renderer.transitions import TransitionBuilder
23
 
 
74
  "render_time_seconds": round(time.time() - started, 3),
75
  "output_size_bytes": output.stat().st_size,
76
  "scene_count": len(request.scenes),
77
+ "creative_style": request.creative_style or request.metadata.get("creative_style"),
78
+ "scene_effects": [scene.effect for scene in request.scenes if scene.effect],
79
  "platform": profile.metadata(),
80
  }
81
  return RenderResult(output_path=output, commands=list(self._commands), metrics=metrics, logs=list(self._logs))
 
88
  with temp_workdir(self.settings.temp_dir, job_id) as work:
89
  workdir = Path(work)
90
  resolved = self.ingest.resolve_ai_reels_request(request, workdir)
91
+ style = get_creative_style(resolved.creative_style)
92
  voice_meta = self.assets.probe(resolved.voiceover)
93
+ duration = max(voice_meta.duration, len(resolved.script.split()) * 0.35, style.scene_duration * len(resolved.assets), 3.0)
94
  per_scene = duration / max(1, len(resolved.assets))
95
  captions = _split_script(resolved.script, len(resolved.assets))
96
  scenes = [
 
99
  "duration": round(per_scene, 3),
100
  "media": asset,
101
  "caption": captions[idx] if idx < len(captions) else "",
102
+ "transition": style.transition_sequence[idx % len(style.transition_sequence)],
103
+ "effect": style.scene_effect_sequence[idx % len(style.scene_effect_sequence)],
104
  }
105
  for idx, asset in enumerate(resolved.assets)
106
  ]
 
118
  music_start=resolved.music_start,
119
  music_ducking=resolved.music_ducking,
120
  voice_volume=resolved.voice_volume,
121
+ creative_style=resolved.creative_style,
122
+ metadata={"creative_style": style.key, "creative_style_label": style.label},
123
  )
124
  return self._render_resolved(render_request, job_id, workdir)
125
 
 
178
  "render_time_seconds": round(time.time() - started, 3),
179
  "output_size_bytes": output.stat().st_size,
180
  "scene_count": len(request.scenes),
181
+ "creative_style": request.creative_style or request.metadata.get("creative_style"),
182
+ "scene_effects": [scene.effect for scene in request.scenes if scene.effect],
183
  "platform": profile.metadata(),
184
  }
185
  return RenderResult(output_path=output, commands=list(self._commands), metrics=metrics, logs=list(self._logs))
 
202
  preview = workdir / f"scene_{idx:03d}_preview.mp4"
203
  self._scale_preview(target, preview)
204
  target = preview
205
+ effected = self._apply_scene_effect(target, scene.effect, workdir / f"scene_{idx:03d}_effect.mp4")
206
+ if effected != target:
207
+ target = effected
208
  prepared.append(target)
209
  return prepared
210
 
 
359
  )
360
  self._run(command)
361
 
362
+ def _apply_scene_effect(self, source: Path, effect: str | None, output: Path) -> Path:
363
+ vf = scene_effect_filter(effect)
364
+ if not vf:
365
+ return source
366
+ command = (
367
+ FFmpegCommand()
368
+ .add("-hide_banner")
369
+ .input(source)
370
+ .add("-vf", vf, "-c:v", "libx264", "-preset", self.settings.preset, "-crf", self.settings.crf, "-c:a", "copy")
371
+ .overwrite()
372
+ .add(output)
373
+ .build()
374
+ )
375
+ self._run(command)
376
+ self._logs.append(f"Applied scene effect '{effect}' to {source.name}")
377
+ return output
378
+
379
  def _run(self, command: list[str]) -> None:
380
  result = self.runner.run(command)
381
  if result.stderr:
 
395
 
396
  def _ffmpeg_subtitle_path(path: Path) -> str:
397
  return str(path).replace("\\", "/").replace(":", r"\:")
 
renderer/jobs/manager.py CHANGED
@@ -35,6 +35,15 @@ class JobManager:
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))
37
 
 
 
 
 
 
 
 
 
 
38
  def submit_batch(self, requests: list[RenderRequest]) -> list[str]:
39
  job_ids: list[str] = []
40
  for request in requests:
@@ -75,6 +84,34 @@ class JobManager:
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],
@@ -110,15 +147,16 @@ class JobManager:
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
 
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))
37
 
38
+ def submit_task(
39
+ self,
40
+ handler: Callable[[str, Callable[[str], None]], object],
41
+ *,
42
+ callback_url: str | None = None,
43
+ export_target: str | None = None,
44
+ ) -> str:
45
+ return self._submit(handler, callback_url=callback_url, export_target=export_target)
46
+
47
  def submit_batch(self, requests: list[RenderRequest]) -> list[str]:
48
  job_ids: list[str] = []
49
  for request in requests:
 
84
  shutil.rmtree(uploads, ignore_errors=True)
85
  return {"removed_jobs": removed_jobs, "removed_exports": removed_exports}
86
 
87
+ def summary(self) -> dict:
88
+ records: list[JobRecord] = []
89
+ for path in self.settings.jobs_dir.glob("*.json"):
90
+ try:
91
+ records.append(JobRecord(**read_json(path, {})))
92
+ except Exception:
93
+ continue
94
+ state_counts: dict[str, int] = {}
95
+ for record in records:
96
+ state_counts[record.state] = state_counts.get(record.state, 0) + 1
97
+ active = [
98
+ {
99
+ "job_id": record.job_id,
100
+ "state": record.state,
101
+ "created_at": record.created_at,
102
+ "updated_at": record.updated_at,
103
+ "metrics": record.metrics,
104
+ }
105
+ for record in sorted(records, key=lambda item: item.updated_at, reverse=True)
106
+ if record.state in {"PENDING", "RUNNING", "CANCEL_REQUESTED"}
107
+ ]
108
+ return {
109
+ "total_jobs": len(records),
110
+ "state_counts": state_counts,
111
+ "active_jobs": active,
112
+ "max_workers": self.settings.max_workers,
113
+ }
114
+
115
  def _submit(
116
  self,
117
  handler: Callable[[str, Callable[[str], None]], object],
 
147
  return
148
  result = handler(job_id, lambda message: self.append_log(job_id, message))
149
  record = self.get(job_id)
150
+ output_path = getattr(result, "output_path", None)
151
+ export_path = self._export_copy(output_path, record.export_target, job_id) if output_path else None
152
  self._update(
153
  job_id,
154
  state="COMPLETED",
155
+ output_path=str(output_path) if output_path else None,
156
  export_path=export_path,
157
+ commands=getattr(result, "commands", []),
158
+ logs=getattr(result, "logs", []),
159
+ metrics=getattr(result, "metrics", {}) | {"attempt": attempts},
160
  )
161
  self._send_callback(job_id)
162
  return
renderer/platform/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from renderer.platform.processor import PlatformProcessor, supported_toolkit_tasks
2
+
3
+ __all__ = ["PlatformProcessor", "supported_toolkit_tasks"]
renderer/platform/processor.py ADDED
@@ -0,0 +1,506 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import mimetypes
6
+ import shutil
7
+ import zipfile
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from renderer.core.config import Settings
12
+ from renderer.core.ingest import AssetIngestor
13
+ from renderer.core.models import TaskResult
14
+ from renderer.core.utils import safe_filename, temp_workdir, write_json
15
+ from renderer.ffmpeg.assets import AssetProbe
16
+ from renderer.ffmpeg.command import FFmpegCommand
17
+ from renderer.ffmpeg.runner import FFmpegRunner
18
+ from renderer.templates import get_platform_profile
19
+
20
+
21
+ TOOLKIT_TASKS = {
22
+ "trim",
23
+ "split",
24
+ "concat",
25
+ "merge",
26
+ "compress",
27
+ "normalize",
28
+ "resize",
29
+ "crop",
30
+ "rotate",
31
+ "speed",
32
+ "reverse",
33
+ "loop",
34
+ "extract_audio",
35
+ "thumbnail",
36
+ "gif",
37
+ "frames",
38
+ "watermark",
39
+ "overlay_text",
40
+ "blur_background",
41
+ "burn_subtitles",
42
+ "convert",
43
+ "merge_audio",
44
+ "noise_reduction",
45
+ "green_screen",
46
+ }
47
+
48
+
49
+ class PlatformProcessor:
50
+ def __init__(self, settings: Settings | None = None, log=None) -> None:
51
+ self.settings = settings or Settings()
52
+ self.settings.ensure_dirs()
53
+ self._commands: list[list[str]] = []
54
+ self._logs: list[str] = []
55
+ self.runner = FFmpegRunner(self.settings.ffmpeg_timeout_seconds, log=log, on_command=self._record_command)
56
+ self.ingest = AssetIngestor(self.settings)
57
+ self.assets = AssetProbe(self.settings.metadata_cache)
58
+
59
+ def ingest_sources(self, sources: list[dict[str, Any]], job_id: str) -> TaskResult:
60
+ with temp_workdir(self.settings.temp_dir, f"{job_id}_ingest") as work:
61
+ workdir = Path(work)
62
+ staged: list[dict[str, Any]] = []
63
+ for index, source in enumerate(sources):
64
+ url = str(source.get("url") or source.get("source") or "").strip()
65
+ if not url:
66
+ continue
67
+ source_type = str(source.get("type") or _source_type(url))
68
+ if source_type == "youtube":
69
+ staged.append(
70
+ {
71
+ "source": url,
72
+ "source_type": source_type,
73
+ "status": "registered",
74
+ "note": "YouTube ingestion is registered for automation; provide a direct downloadable media URL or install yt-dlp for local extraction.",
75
+ }
76
+ )
77
+ continue
78
+ resolved = self.ingest.resolve(url, workdir / "inputs", f"source_{index:03d}")
79
+ metadata = self.assets.probe(resolved).__dict__
80
+ staged.append({"source": url, "source_type": source_type, "path": str(resolved), "metadata": metadata})
81
+ output = self._json_artifact(job_id, "ingest_manifest", {"assets": staged, "asset_count": len(staged)})
82
+ return self._result(output, {"task": "ingest", "asset_count": len(staged)})
83
+
84
+ def analyze(self, media: str, job_id: str, *, transcript: str = "", platform: str | None = None) -> TaskResult:
85
+ with temp_workdir(self.settings.temp_dir, f"{job_id}_analyze") as work:
86
+ workdir = Path(work)
87
+ source = self.ingest.resolve(media, workdir / "inputs", "media")
88
+ metadata = self.assets.probe(source)
89
+ duration = max(0.0, metadata.duration)
90
+ words = transcript.split()
91
+ words_per_minute = (len(words) / duration * 60) if duration > 0 and words else None
92
+ highlights = _highlight_windows(duration)
93
+ viral_score = _viral_score(duration, bool(words), metadata.width, metadata.height)
94
+ analysis = {
95
+ "media": str(source),
96
+ "metadata": metadata.__dict__,
97
+ "transcript": transcript,
98
+ "highlight_moments": highlights,
99
+ "viral_score": viral_score,
100
+ "hook_quality": _hook_quality(transcript),
101
+ "audience_retention_estimate": _retention_estimate(duration, viral_score),
102
+ "engagement_prediction": _engagement_prediction(viral_score),
103
+ "audience_persona": _persona(transcript),
104
+ "platform_recommendations": _platform_recommendations(duration, metadata.width, metadata.height, platform),
105
+ "scene_segmentation": highlights,
106
+ "speech_pacing": {
107
+ "words_per_minute": round(words_per_minute, 1) if words_per_minute else None,
108
+ "label": _pacing_label(words_per_minute),
109
+ },
110
+ "silence_detection": {
111
+ "estimated_silence_ratio": 0.0 if transcript else 0.18,
112
+ "note": "Heuristic estimate; use transcription with word timestamps for precise silence spans.",
113
+ },
114
+ }
115
+ output = self._json_artifact(job_id, "analysis", analysis)
116
+ return self._result(output, {"task": "analyze", "viral_score": viral_score, "duration": duration})
117
+
118
+ def metadata(self, job_id: str, *, topic: str = "", transcript: str = "", platform: str | None = None) -> TaskResult:
119
+ text = transcript or topic or "Untitled video"
120
+ title = _title_from_text(text, platform)
121
+ tags = _hashtags(text, platform)
122
+ payload = {
123
+ "title": title,
124
+ "description": _description(text, tags),
125
+ "hashtags": tags,
126
+ "keywords": _keywords(text),
127
+ "chapters": _chapters(text),
128
+ "seo_tags": _keywords(text) + [platform] if platform else _keywords(text),
129
+ "suggested_upload_schedule": _schedule(platform),
130
+ "platform": platform or "general",
131
+ }
132
+ output = self._json_artifact(job_id, "metadata", payload)
133
+ return self._result(output, {"task": "metadata", "keyword_count": len(payload["keywords"])})
134
+
135
+ def publish(self, payload: dict[str, Any], job_id: str) -> TaskResult:
136
+ platforms = payload.get("platforms") or [payload.get("platform") or "draft"]
137
+ manifest = {
138
+ "publish_state": "draft_ready" if payload.get("draft", True) else "credentials_required",
139
+ "platforms": platforms,
140
+ "scheduled_at": payload.get("scheduled_at"),
141
+ "asset": payload.get("asset") or payload.get("media"),
142
+ "title": payload.get("title"),
143
+ "description": payload.get("description"),
144
+ "retry_policy": {"max_attempts": 3, "backoff_seconds": 60},
145
+ "note": "Direct publishing requires platform OAuth/API credentials configured outside this CPU render worker.",
146
+ }
147
+ output = self._json_artifact(job_id, "publish_manifest", manifest)
148
+ return self._result(output, {"task": "publish", "platform_count": len(platforms)})
149
+
150
+ def clips(self, media: str, job_id: str, clips: list[dict[str, Any]] | None = None) -> TaskResult:
151
+ with temp_workdir(self.settings.temp_dir, f"{job_id}_clips") as work:
152
+ workdir = Path(work)
153
+ source = self.ingest.resolve(media, workdir / "inputs", "media")
154
+ metadata = self.assets.probe(source)
155
+ clip_specs = clips or _highlight_windows(metadata.duration)
156
+ outputs: list[Path] = []
157
+ for index, clip in enumerate(clip_specs):
158
+ start = max(0.0, float(clip.get("start", 0)))
159
+ end = float(clip.get("end", start + clip.get("duration", 8)))
160
+ duration = max(0.2, end - start)
161
+ target = workdir / f"clip_{index + 1:02d}.mp4"
162
+ command = (
163
+ FFmpegCommand()
164
+ .add("-hide_banner", "-ss", start)
165
+ .input(source)
166
+ .add("-t", duration, "-c", "copy")
167
+ .overwrite()
168
+ .add(target)
169
+ .build()
170
+ )
171
+ self._run(command)
172
+ outputs.append(target)
173
+ if len(outputs) == 1:
174
+ final = self._export(outputs[0], job_id, outputs[0].name)
175
+ else:
176
+ final = self.settings.exports_dir / f"{job_id}_clips.zip"
177
+ with zipfile.ZipFile(final, "w", zipfile.ZIP_DEFLATED) as archive:
178
+ for path in outputs:
179
+ archive.write(path, path.name)
180
+ return self._result(final, {"task": "clips", "clip_count": len(outputs)})
181
+
182
+ def thumbnail(self, media: str, job_id: str, *, text: str = "", timestamp: float | None = None, template: str = "bold") -> TaskResult:
183
+ with temp_workdir(self.settings.temp_dir, f"{job_id}_thumb") as work:
184
+ workdir = Path(work)
185
+ source = self.ingest.resolve(media, workdir / "inputs", "media")
186
+ metadata = self.assets.probe(source)
187
+ target = workdir / "thumbnail.jpg"
188
+ seek = timestamp if timestamp is not None else max(0.0, min(metadata.duration * 0.2, 8.0))
189
+ vf = "scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720"
190
+ if text:
191
+ vf += "," + _drawtext_filter(text, template)
192
+ command = (
193
+ FFmpegCommand()
194
+ .add("-hide_banner", "-ss", seek)
195
+ .input(source)
196
+ .add("-frames:v", 1, "-vf", vf, "-q:v", 2)
197
+ .overwrite()
198
+ .add(target)
199
+ .build()
200
+ )
201
+ self._run(command)
202
+ final = self._export(target, job_id, "thumbnail.jpg")
203
+ return self._result(final, {"task": "thumbnail", "timestamp": seek})
204
+
205
+ def toolkit(self, payload: dict[str, Any], job_id: str) -> TaskResult:
206
+ task = str(payload.get("task") or payload.get("operation") or "").strip()
207
+ if task not in TOOLKIT_TASKS:
208
+ raise ValueError(f"Unsupported toolkit task: {task}")
209
+ if task == "thumbnail":
210
+ return self.thumbnail(str(payload["input"]), job_id, text=str(payload.get("text") or ""), timestamp=payload.get("timestamp"))
211
+ if task == "split":
212
+ clips = payload.get("clips") if isinstance(payload.get("clips"), list) else payload.get("params", {}).get("clips")
213
+ return self.clips(str(payload.get("input") or payload.get("media")), job_id, clips)
214
+
215
+ with temp_workdir(self.settings.temp_dir, f"{job_id}_{task}") as work:
216
+ workdir = Path(work)
217
+ source_value = payload.get("input") or payload.get("media")
218
+ source = self.ingest.resolve(str(source_value), workdir / "inputs", "media") if source_value else workdir / "concat_placeholder.mp4"
219
+ params = payload.get("params") if isinstance(payload.get("params"), dict) else payload
220
+ output_name = safe_filename(str(payload.get("output_name") or _default_output_name(task)))
221
+ output = workdir / output_name
222
+ if task == "extract_audio" and output.suffix.lower() != ".mp3":
223
+ output = output.with_suffix(".mp3")
224
+ if task == "gif" and output.suffix.lower() != ".gif":
225
+ output = output.with_suffix(".gif")
226
+ command = self._toolkit_command(task, source, output, params, workdir)
227
+ self._run(command)
228
+ if task == "frames":
229
+ final = self.settings.exports_dir / f"{job_id}_frames.zip"
230
+ with zipfile.ZipFile(final, "w", zipfile.ZIP_DEFLATED) as archive:
231
+ for frame in sorted(workdir.glob("frame_*.jpg")):
232
+ archive.write(frame, frame.name)
233
+ else:
234
+ final = self._export(output, job_id, output.name)
235
+ return self._result(final, {"task": task})
236
+
237
+ def _toolkit_command(self, task: str, source: Path, output: Path, params: dict[str, Any], workdir: Path) -> list[str]:
238
+ cmd = FFmpegCommand().add("-hide_banner")
239
+ if task == "loop":
240
+ cmd.add("-stream_loop", int(params.get("loops", -1)))
241
+ if task in {"trim", "gif"} and params.get("start") is not None:
242
+ cmd.add("-ss", float(params.get("start", 0)))
243
+ cmd.input(source)
244
+
245
+ if task == "merge_audio":
246
+ audio = self.ingest.resolve(str(params.get("audio")), workdir / "inputs", "audio")
247
+ cmd.input(audio)
248
+ return cmd.add("-map", "0:v", "-map", "1:a", "-c:v", "copy", "-c:a", "aac", "-shortest").overwrite().add(output).build()
249
+ if task == "watermark":
250
+ image = self.ingest.resolve(str(params.get("watermark") or params.get("image")), workdir / "inputs", "watermark")
251
+ cmd.input(image)
252
+ return cmd.add("-filter_complex", "[1:v]scale=iw*0.18:-1[wm];[0:v][wm]overlay=W-w-24:H-h-24", "-c:a", "copy").overwrite().add(output).build()
253
+ if task in {"concat", "merge"}:
254
+ inputs = params.get("inputs")
255
+ if not isinstance(inputs, list) or not inputs:
256
+ raise ValueError("Concat requires params.inputs")
257
+ concat_file = workdir / "concat.txt"
258
+ lines: list[str] = []
259
+ for index, item in enumerate(inputs):
260
+ media = self.ingest.resolve(str(item), workdir / "inputs", f"concat_{index:03d}")
261
+ lines.append(f"file '{str(media).replace(chr(39), chr(39) + chr(92) + chr(39) + chr(39))}'")
262
+ concat_file.write_text("\n".join(lines), encoding="utf-8")
263
+ return FFmpegCommand().add("-hide_banner", "-f", "concat", "-safe", "0").input(concat_file).add("-c", "copy").overwrite().add(output).build()
264
+
265
+ duration = params.get("duration")
266
+ if task in {"trim", "gif", "loop"} and duration is not None:
267
+ cmd.add("-t", float(duration))
268
+
269
+ vf = _video_filter(task, params)
270
+ af = _audio_filter(task, params)
271
+ if vf:
272
+ cmd.add("-vf", vf)
273
+ if af:
274
+ cmd.add("-af", af)
275
+
276
+ if task == "extract_audio":
277
+ return cmd.add("-vn", "-c:a", "mp3", "-b:a", "192k").overwrite().add(output).build()
278
+ if task == "frames":
279
+ return cmd.add("-vf", vf or "fps=1", "-q:v", 2).overwrite().add(workdir / "frame_%04d.jpg").build()
280
+ if task == "gif":
281
+ return cmd.add("-loop", 0).overwrite().add(output).build()
282
+ if task in {"compress", "normalize", "resize", "crop", "rotate", "speed", "reverse", "loop", "overlay_text", "blur_background", "burn_subtitles", "convert", "noise_reduction", "green_screen"}:
283
+ cmd.add("-c:v", "libx264", "-preset", self.settings.preset, "-crf", int(params.get("crf", self.settings.crf)), "-c:a", "aac")
284
+ return cmd.overwrite().add(output).build()
285
+
286
+ def _json_artifact(self, job_id: str, name: str, payload: dict[str, Any]) -> Path:
287
+ output = self.settings.exports_dir / f"{job_id}_{safe_filename(name)}.json"
288
+ write_json(output, payload)
289
+ return output
290
+
291
+ def _export(self, source: Path, job_id: str, output_name: str) -> Path:
292
+ target = self.settings.exports_dir / f"{job_id}_{safe_filename(output_name)}"
293
+ shutil.copy2(source, target)
294
+ return target
295
+
296
+ def _run(self, command: list[str]) -> None:
297
+ result = self.runner.run(command)
298
+ if result.stderr:
299
+ self._logs.append(result.stderr[-4000:])
300
+
301
+ def _record_command(self, command: list[str]) -> None:
302
+ self._commands.append(command)
303
+
304
+ def _result(self, output: Path | None, metrics: dict[str, Any]) -> TaskResult:
305
+ return TaskResult(output_path=output, commands=list(self._commands), metrics=metrics, logs=list(self._logs))
306
+
307
+
308
+ def supported_toolkit_tasks() -> list[str]:
309
+ return sorted(TOOLKIT_TASKS)
310
+
311
+
312
+ def _source_type(url: str) -> str:
313
+ lowered = url.lower()
314
+ if "youtube.com" in lowered or "youtu.be" in lowered:
315
+ return "youtube"
316
+ if "drive.google.com" in lowered:
317
+ return "google_drive"
318
+ if "s3" in lowered or "amazonaws.com" in lowered:
319
+ return "s3"
320
+ return "direct_url"
321
+
322
+
323
+ def _highlight_windows(duration: float) -> list[dict[str, Any]]:
324
+ if duration <= 0:
325
+ return [{"start": 0, "end": 8, "reason": "default opener"}]
326
+ windows = [{"start": 0, "end": min(duration, 8), "reason": "opening hook"}]
327
+ if duration > 18:
328
+ middle = max(0.0, duration * 0.42)
329
+ windows.append({"start": round(middle, 2), "end": round(min(duration, middle + 10), 2), "reason": "midpoint payoff"})
330
+ if duration > 35:
331
+ end = max(0.0, duration - 12)
332
+ windows.append({"start": round(end, 2), "end": round(duration, 2), "reason": "closing CTA"})
333
+ return windows
334
+
335
+
336
+ def _viral_score(duration: float, has_words: bool, width: int | None, height: int | None) -> int:
337
+ score = 48
338
+ if 7 <= duration <= 60:
339
+ score += 20
340
+ elif duration <= 180:
341
+ score += 8
342
+ if width and height and height >= width:
343
+ score += 14
344
+ if has_words:
345
+ score += 10
346
+ return max(1, min(100, score))
347
+
348
+
349
+ def _hook_quality(transcript: str) -> dict[str, Any]:
350
+ opener = " ".join(transcript.split()[:18])
351
+ signals = sum(1 for token in ("how", "why", "secret", "mistake", "stop", "watch", "you") if token in opener.lower())
352
+ return {"score": min(100, 45 + signals * 12), "opening_text": opener}
353
+
354
+
355
+ def _retention_estimate(duration: float, viral_score: int) -> dict[str, Any]:
356
+ first_3s = min(96, 55 + viral_score * 0.35)
357
+ completion = max(18, min(88, first_3s - math.log(max(duration, 1), 1.8)))
358
+ return {"first_3_seconds_percent": round(first_3s, 1), "completion_percent": round(completion, 1)}
359
+
360
+
361
+ def _engagement_prediction(score: int) -> str:
362
+ if score >= 80:
363
+ return "high"
364
+ if score >= 62:
365
+ return "medium"
366
+ return "needs_work"
367
+
368
+
369
+ def _persona(text: str) -> str:
370
+ lowered = text.lower()
371
+ if any(word in lowered for word in ("founder", "startup", "product", "launch")):
372
+ return "builders and product-led founders"
373
+ if any(word in lowered for word in ("money", "sales", "growth", "marketing")):
374
+ return "growth-minded operators"
375
+ if any(word in lowered for word in ("learn", "tutorial", "how")):
376
+ return "learners seeking practical instruction"
377
+ return "general short-form viewers"
378
+
379
+
380
+ def _platform_recommendations(duration: float, width: int | None, height: int | None, platform: str | None) -> list[dict[str, Any]]:
381
+ vertical = bool(width and height and height >= width)
382
+ candidates = ["tiktok", "instagram_reels", "youtube_shorts"] if vertical else ["youtube_1080p", "linkedin_video"]
383
+ if platform and platform not in candidates:
384
+ candidates.insert(0, platform)
385
+ return [{"platform": item, "fit": "strong" if duration <= 90 else "medium"} for item in candidates]
386
+
387
+
388
+ def _pacing_label(wpm: float | None) -> str:
389
+ if not wpm:
390
+ return "unknown"
391
+ if wpm < 125:
392
+ return "slow"
393
+ if wpm > 185:
394
+ return "fast"
395
+ return "clear"
396
+
397
+
398
+ def _title_from_text(text: str, platform: str | None) -> str:
399
+ words = [word.strip(".,:;!?") for word in text.split() if word.strip(".,:;!?")]
400
+ title = " ".join(words[:9]) or "Untitled Video"
401
+ suffix = " #Shorts" if platform in {"youtube_shorts", "tiktok", "instagram_reels"} else ""
402
+ return f"{title.title()}{suffix}"
403
+
404
+
405
+ def _description(text: str, tags: list[str]) -> str:
406
+ summary = " ".join(text.split()[:42])
407
+ return f"{summary}\n\n{' '.join(tags)}".strip()
408
+
409
+
410
+ def _hashtags(text: str, platform: str | None) -> list[str]:
411
+ base = ["#video", "#content"]
412
+ if platform:
413
+ base.append(f"#{platform.replace('_', '')}")
414
+ for keyword in _keywords(text)[:5]:
415
+ tag = "#" + "".join(ch for ch in keyword.title() if ch.isalnum())
416
+ if tag not in base:
417
+ base.append(tag)
418
+ return base[:8]
419
+
420
+
421
+ def _keywords(text: str) -> list[str]:
422
+ stop = {"the", "and", "for", "with", "that", "this", "your", "you", "are", "from", "into", "video"}
423
+ words = [word.strip(".,:;!?").lower() for word in text.split()]
424
+ unique: list[str] = []
425
+ for word in words:
426
+ if len(word) < 4 or word in stop or word in unique:
427
+ continue
428
+ unique.append(word)
429
+ return unique[:12]
430
+
431
+
432
+ def _chapters(text: str) -> list[dict[str, Any]]:
433
+ sentences = [part.strip() for part in text.replace("?", ".").replace("!", ".").split(".") if part.strip()]
434
+ return [{"time": f"0:{index * 15:02d}", "title": sentence[:60]} for index, sentence in enumerate(sentences[:6])]
435
+
436
+
437
+ def _schedule(platform: str | None) -> dict[str, str]:
438
+ if platform in {"linkedin_video", "youtube_1080p"}:
439
+ return {"day": "Tuesday", "time": "09:00 local"}
440
+ return {"day": "Thursday", "time": "18:00 local"}
441
+
442
+
443
+ def _video_filter(task: str, params: dict[str, Any]) -> str:
444
+ if task in {"compress", "normalize"}:
445
+ profile = get_platform_profile(params.get("platform"))
446
+ return f"scale={profile.width}:{profile.height}:force_original_aspect_ratio=increase,crop={profile.width}:{profile.height},fps={profile.fps},format=yuv420p"
447
+ if task == "resize":
448
+ return f"scale={int(params.get('width', 1080))}:{int(params.get('height', 1920))}"
449
+ if task == "crop":
450
+ return f"crop={int(params.get('width', 1080))}:{int(params.get('height', 1080))}:{int(params.get('x', 0))}:{int(params.get('y', 0))}"
451
+ if task == "rotate":
452
+ return {"90": "transpose=1", "180": "hflip,vflip", "270": "transpose=2"}.get(str(params.get("degrees", "90")), "transpose=1")
453
+ if task == "speed":
454
+ factor = max(0.25, min(4.0, float(params.get("factor", 1.0))))
455
+ return f"setpts={1 / factor:.4f}*PTS"
456
+ if task == "reverse":
457
+ return "reverse"
458
+ if task == "gif":
459
+ return f"fps={int(params.get('fps', 12))},scale={int(params.get('width', 540))}:-1:flags=lanczos"
460
+ if task == "frames":
461
+ return f"fps={float(params.get('fps', 1))}"
462
+ if task == "overlay_text":
463
+ return _drawtext_filter(str(params.get("text") or "Text"), "bold")
464
+ if task == "blur_background":
465
+ return "gblur=sigma=18"
466
+ if task == "burn_subtitles":
467
+ subtitles = str(params.get("subtitles") or "").replace("\\", "/").replace(":", r"\:")
468
+ return f"subtitles='{subtitles}'"
469
+ if task == "green_screen":
470
+ color = str(params.get("color") or "0x00ff00")
471
+ similarity = float(params.get("similarity", 0.18))
472
+ blend = float(params.get("blend", 0.08))
473
+ return f"chromakey={color}:{similarity}:{blend}"
474
+ return ""
475
+
476
+
477
+ def _audio_filter(task: str, params: dict[str, Any]) -> str:
478
+ if task == "speed":
479
+ factor = max(0.5, min(2.0, float(params.get("factor", 1.0))))
480
+ return f"atempo={factor}"
481
+ if task == "reverse":
482
+ return "areverse"
483
+ if task == "noise_reduction":
484
+ return "afftdn=nf=-25"
485
+ return ""
486
+
487
+
488
+ def _drawtext_filter(text: str, template: str) -> str:
489
+ escaped = text.replace("\\", "\\\\").replace(":", r"\:").replace("'", r"\'")
490
+ color = "yellow" if template == "bold" else "white"
491
+ return (
492
+ "drawtext="
493
+ f"text='{escaped}':fontcolor={color}:fontsize=58:"
494
+ "box=1:boxcolor=black@0.55:boxborderw=24:"
495
+ "x=(w-text_w)/2:y=h-(text_h*3)"
496
+ )
497
+
498
+
499
+ def _default_output_name(task: str) -> str:
500
+ if task == "extract_audio":
501
+ return "audio.mp3"
502
+ if task == "gif":
503
+ return "clip.gif"
504
+ if task == "thumbnail":
505
+ return "thumbnail.jpg"
506
+ return f"{task}.mp4"
renderer/scenes/timeline.py CHANGED
@@ -19,6 +19,7 @@ class Timeline:
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"),
@@ -34,6 +35,16 @@ class Timeline:
34
  auto_subtitles=payload.get("auto_subtitles", False),
35
  subtitle_language=payload.get("subtitle_language"),
36
  whisper_model_size=payload.get("whisper_model_size"),
 
 
 
 
 
 
 
 
 
 
37
  normalize=payload.get("normalize", True),
38
  metadata=payload.get("metadata", {}),
39
  )
 
19
  scenes=scenes,
20
  template=payload.get("template", "tiktok_classic"),
21
  preset=payload.get("preset"),
22
+ creative_style=payload.get("creative_style"),
23
  platform=payload.get("platform"),
24
  output_name=payload.get("output_name", "render.mp4"),
25
  voiceover=payload.get("voiceover"),
 
35
  auto_subtitles=payload.get("auto_subtitles", False),
36
  subtitle_language=payload.get("subtitle_language"),
37
  whisper_model_size=payload.get("whisper_model_size"),
38
+ preview=payload.get("preview", False),
39
+ audio_normalize=payload.get("audio_normalize", False),
40
+ watermark=payload.get("watermark"),
41
+ watermark_position=payload.get("watermark_position", "bottom-right"),
42
+ intro=payload.get("intro"),
43
+ outro=payload.get("outro"),
44
+ callback_url=payload.get("callback_url"),
45
+ export_target=payload.get("export_target"),
46
+ priority=payload.get("priority", 0),
47
+ scheduled_at=payload.get("scheduled_at"),
48
  normalize=payload.get("normalize", True),
49
  metadata=payload.get("metadata", {}),
50
  )
renderer/templates/__init__.py CHANGED
@@ -1,15 +1,31 @@
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
  ]
 
1
  from renderer.templates.caption_templates import CaptionTemplate, get_template, list_templates
2
+ from renderer.templates.creative import (
3
+ apply_creative_style,
4
+ creative_style_metadata,
5
+ get_creative_style,
6
+ list_creative_styles,
7
+ list_scene_effects,
8
+ scene_effect_filter,
9
+ scene_effect_metadata,
10
+ )
11
  from renderer.templates.platforms import PlatformProfile, get_platform_profile, list_platform_profiles, platform_profile_metadata
12
  from renderer.templates.presets import apply_preset, list_presets
13
 
14
  __all__ = [
15
  "CaptionTemplate",
16
  "PlatformProfile",
17
+ "apply_creative_style",
18
  "apply_preset",
19
+ "creative_style_metadata",
20
+ "get_creative_style",
21
  "get_platform_profile",
22
  "get_template",
23
+ "list_creative_styles",
24
  "list_platform_profiles",
25
+ "list_scene_effects",
26
  "list_presets",
27
  "list_templates",
28
  "platform_profile_metadata",
29
+ "scene_effect_filter",
30
+ "scene_effect_metadata",
31
  ]
renderer/templates/caption_templates.py CHANGED
@@ -35,6 +35,10 @@ TEMPLATES: dict[str, CaptionTemplate] = {
35
  "youtube_shorts": CaptionTemplate("youtube_shorts", "YouTube Shorts", 62, "&H00FFFFFF", "&H000000FF", effect="karaoke"),
36
  "podcast_style": CaptionTemplate("podcast_style", "Podcast Style", 48, "&H00F5F5F5", "&H0099CCFF", margin_v=120),
37
  "news_style": CaptionTemplate("news_style", "News Style", 46, "&H00FFFFFF", "&H0000FFFF", alignment=2, margin_v=100),
 
 
 
 
38
  }
39
 
40
 
 
35
  "youtube_shorts": CaptionTemplate("youtube_shorts", "YouTube Shorts", 62, "&H00FFFFFF", "&H000000FF", effect="karaoke"),
36
  "podcast_style": CaptionTemplate("podcast_style", "Podcast Style", 48, "&H00F5F5F5", "&H0099CCFF", margin_v=120),
37
  "news_style": CaptionTemplate("news_style", "News Style", 46, "&H00FFFFFF", "&H0000FFFF", alignment=2, margin_v=100),
38
+ "neon_pop": CaptionTemplate("neon_pop", "Neon Pop", 76, "&H00FFFFFF", "&H0000E5FF", outline_color="&H00FF2BD6", effect="karaoke"),
39
+ "product_demo": CaptionTemplate("product_demo", "Product Demo", 54, "&H00FFFFFF", "&H00C7F9CC", margin_v=210, effect="zoom"),
40
+ "cinematic_gold": CaptionTemplate("cinematic_gold", "Cinematic Gold", 50, "&H00F4E7B2", "&H00FFFFFF", outline_color="&H00111111", margin_v=180),
41
+ "creator_clean": CaptionTemplate("creator_clean", "Creator Clean", 58, "&H00FFFFFF", "&H00BCE7FD", margin_v=220, effect="bounce"),
42
  }
43
 
44
 
renderer/templates/creative.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class CreativeStyle:
10
+ key: str
11
+ label: str
12
+ description: str
13
+ platform: str
14
+ template: str
15
+ scene_duration: float
16
+ transition_sequence: tuple[str, ...]
17
+ scene_effect_sequence: tuple[str, ...]
18
+ render_defaults: dict[str, Any] = field(default_factory=dict)
19
+ metadata: dict[str, Any] = field(default_factory=dict)
20
+
21
+ def metadata_payload(self) -> dict[str, Any]:
22
+ return {
23
+ "key": self.key,
24
+ "label": self.label,
25
+ "description": self.description,
26
+ "platform": self.platform,
27
+ "template": self.template,
28
+ "scene_duration": self.scene_duration,
29
+ "transition_sequence": list(self.transition_sequence),
30
+ "scene_effect_sequence": list(self.scene_effect_sequence),
31
+ "render_defaults": deepcopy(self.render_defaults),
32
+ "metadata": deepcopy(self.metadata),
33
+ }
34
+
35
+
36
+ SCENE_EFFECTS: dict[str, dict[str, str]] = {
37
+ "none": {"label": "None", "filter": ""},
38
+ "sharp_pop": {
39
+ "label": "Sharp Pop",
40
+ "filter": "eq=contrast=1.08:saturation=1.20:brightness=0.01,unsharp=5:5:0.8:3:3:0.4",
41
+ },
42
+ "clean_beauty": {
43
+ "label": "Clean Beauty",
44
+ "filter": "hqdn3d=1.5:1.5:6:6,eq=saturation=1.08:contrast=1.03",
45
+ },
46
+ "warm_glow": {
47
+ "label": "Warm Glow",
48
+ "filter": "eq=contrast=1.04:saturation=1.18:gamma_r=1.04:gamma_b=0.96,gblur=sigma=0.25",
49
+ },
50
+ "cinematic": {
51
+ "label": "Cinematic",
52
+ "filter": "eq=contrast=1.14:saturation=0.95:brightness=-0.015,vignette=PI/6",
53
+ },
54
+ "dreamy": {
55
+ "label": "Dreamy",
56
+ "filter": "gblur=sigma=0.6,eq=contrast=1.04:saturation=1.18:brightness=0.02",
57
+ },
58
+ "flash_pop": {
59
+ "label": "Flash Pop",
60
+ "filter": "eq=contrast=1.16:saturation=1.25:brightness=0.035",
61
+ },
62
+ "grain": {
63
+ "label": "Fine Grain",
64
+ "filter": "noise=alls=8:allf=t+u,eq=contrast=1.07:saturation=1.02",
65
+ },
66
+ "motion_blur": {
67
+ "label": "Motion Blur",
68
+ "filter": "tmix=frames=3:weights='1 2 1',eq=contrast=1.05:saturation=1.08",
69
+ },
70
+ "noir": {
71
+ "label": "Noir",
72
+ "filter": "hue=s=0,eq=contrast=1.18:brightness=-0.02",
73
+ },
74
+ }
75
+
76
+
77
+ CREATIVE_STYLES: dict[str, CreativeStyle] = {
78
+ "viral_shorts": CreativeStyle(
79
+ key="viral_shorts",
80
+ label="Viral Shorts",
81
+ description="Fast vertical pacing, punchy captions, bright contrast, and whip-style movement.",
82
+ platform="tiktok",
83
+ template="tiktok_zoom",
84
+ scene_duration=2.4,
85
+ transition_sequence=("whip", "flash", "zoom", "glitch"),
86
+ scene_effect_sequence=("sharp_pop", "flash_pop", "clean_beauty"),
87
+ render_defaults={
88
+ "subtitle_format": "ass",
89
+ "auto_subtitles": True,
90
+ "audio_normalize": True,
91
+ "music_volume": 0.24,
92
+ "music_fade_in": 0.25,
93
+ "music_fade_out": 0.8,
94
+ "music_ducking": True,
95
+ "normalize": True,
96
+ },
97
+ metadata={"energy": "high", "best_for": "hooks, offers, memes, short promos"},
98
+ ),
99
+ "product_demo": CreativeStyle(
100
+ key="product_demo",
101
+ label="Product Demo",
102
+ description="Clean cuts, readable captions, and polished color for launches and tutorials.",
103
+ platform="instagram_reels",
104
+ template="modern_minimal",
105
+ scene_duration=3.2,
106
+ transition_sequence=("slide", "wipe_left", "push", "dissolve"),
107
+ scene_effect_sequence=("clean_beauty", "sharp_pop"),
108
+ render_defaults={
109
+ "subtitle_format": "ass",
110
+ "auto_subtitles": True,
111
+ "audio_normalize": True,
112
+ "music_volume": 0.18,
113
+ "music_fade_in": 0.3,
114
+ "music_fade_out": 0.7,
115
+ "music_ducking": True,
116
+ "normalize": True,
117
+ },
118
+ metadata={"energy": "medium", "best_for": "software demos, ecommerce, tutorials"},
119
+ ),
120
+ "story_vlog": CreativeStyle(
121
+ key="story_vlog",
122
+ label="Story Vlog",
123
+ description="Warm color, softer movement, and natural pacing for personality-led edits.",
124
+ platform="instagram_reels",
125
+ template="tiktok_classic",
126
+ scene_duration=3.8,
127
+ transition_sequence=("fade", "smooth_right", "dissolve"),
128
+ scene_effect_sequence=("warm_glow", "dreamy", "clean_beauty"),
129
+ render_defaults={
130
+ "subtitle_format": "ass",
131
+ "auto_subtitles": True,
132
+ "audio_normalize": True,
133
+ "music_volume": 0.22,
134
+ "music_fade_in": 0.5,
135
+ "music_fade_out": 1.0,
136
+ "music_ducking": True,
137
+ "normalize": True,
138
+ },
139
+ metadata={"energy": "medium", "best_for": "founder updates, day-in-life edits, testimonials"},
140
+ ),
141
+ "podcast_clip": CreativeStyle(
142
+ key="podcast_clip",
143
+ label="Podcast Clip",
144
+ description="Square-safe framing, calmer captions, and narration-first audio treatment.",
145
+ platform="instagram_feed_square",
146
+ template="podcast_style",
147
+ scene_duration=5.0,
148
+ transition_sequence=("fade", "dissolve"),
149
+ scene_effect_sequence=("clean_beauty", "sharp_pop"),
150
+ render_defaults={
151
+ "subtitle_format": "ass",
152
+ "auto_subtitles": True,
153
+ "audio_normalize": True,
154
+ "music_volume": 0.12,
155
+ "music_fade_in": 0.6,
156
+ "music_fade_out": 1.2,
157
+ "music_ducking": True,
158
+ "normalize": True,
159
+ },
160
+ metadata={"energy": "low", "best_for": "interviews, audiograms, education"},
161
+ ),
162
+ "cinematic_story": CreativeStyle(
163
+ key="cinematic_story",
164
+ label="Cinematic Story",
165
+ description="Deeper contrast, film grain, and slower transitions for mini-documentary edits.",
166
+ platform="youtube_shorts",
167
+ template="modern_minimal",
168
+ scene_duration=4.2,
169
+ transition_sequence=("dissolve", "fadeblack", "smooth_left"),
170
+ scene_effect_sequence=("cinematic", "grain"),
171
+ render_defaults={
172
+ "subtitle_format": "ass",
173
+ "auto_subtitles": True,
174
+ "audio_normalize": True,
175
+ "music_volume": 0.26,
176
+ "music_fade_in": 0.8,
177
+ "music_fade_out": 1.4,
178
+ "music_ducking": True,
179
+ "normalize": True,
180
+ },
181
+ metadata={"energy": "low", "best_for": "brand films, travel, documentary shorts"},
182
+ ),
183
+ "news_explainer": CreativeStyle(
184
+ key="news_explainer",
185
+ label="News Explainer",
186
+ description="Readable lower-third style captions with stable landscape or vertical exports.",
187
+ platform="youtube_1080p",
188
+ template="news_style",
189
+ scene_duration=4.0,
190
+ transition_sequence=("wipe_left", "slide", "fade"),
191
+ scene_effect_sequence=("sharp_pop", "clean_beauty"),
192
+ render_defaults={
193
+ "subtitle_format": "ass",
194
+ "auto_subtitles": False,
195
+ "audio_normalize": True,
196
+ "music_volume": 0.1,
197
+ "music_fade_in": 0.5,
198
+ "music_fade_out": 1.0,
199
+ "music_ducking": True,
200
+ "normalize": True,
201
+ },
202
+ metadata={"energy": "medium", "best_for": "explainers, news, thought leadership"},
203
+ ),
204
+ }
205
+
206
+
207
+ def apply_creative_style(payload: dict[str, Any]) -> dict[str, Any]:
208
+ style_key = payload.get("creative_style") or payload.get("metadata", {}).get("creative_style")
209
+ if not style_key:
210
+ return payload
211
+
212
+ style = get_creative_style(str(style_key))
213
+ output = deepcopy(payload)
214
+ output["creative_style"] = style.key
215
+ _set_default(output, "platform", style.platform)
216
+ _set_default(output, "template", style.template)
217
+ for key, value in style.render_defaults.items():
218
+ _set_default(output, key, deepcopy(value))
219
+
220
+ metadata = deepcopy(style.metadata)
221
+ metadata.update(output.get("metadata", {}))
222
+ metadata["creative_style"] = style.key
223
+ metadata["creative_style_label"] = style.label
224
+ output["metadata"] = metadata
225
+
226
+ scenes = output.get("scenes")
227
+ if isinstance(scenes, list):
228
+ output["scenes"] = [_style_scene(scene, style, index) for index, scene in enumerate(scenes)]
229
+ return output
230
+
231
+
232
+ def get_creative_style(key: str | None) -> CreativeStyle:
233
+ if not key:
234
+ return CREATIVE_STYLES["viral_shorts"]
235
+ return CREATIVE_STYLES.get(key, CREATIVE_STYLES["viral_shorts"])
236
+
237
+
238
+ def list_creative_styles() -> list[str]:
239
+ return list(CREATIVE_STYLES.keys())
240
+
241
+
242
+ def creative_style_metadata() -> dict[str, dict[str, Any]]:
243
+ return {key: style.metadata_payload() for key, style in CREATIVE_STYLES.items()}
244
+
245
+
246
+ def list_scene_effects() -> list[str]:
247
+ return list(SCENE_EFFECTS.keys())
248
+
249
+
250
+ def scene_effect_metadata() -> dict[str, dict[str, str]]:
251
+ return {key: {"label": value["label"]} for key, value in SCENE_EFFECTS.items()}
252
+
253
+
254
+ def scene_effect_filter(key: str | None) -> str:
255
+ if not key:
256
+ return ""
257
+ return SCENE_EFFECTS.get(key, SCENE_EFFECTS["none"])["filter"]
258
+
259
+
260
+ def _set_default(payload: dict[str, Any], key: str, value: Any) -> None:
261
+ if key not in payload or payload[key] in (None, ""):
262
+ payload[key] = value
263
+
264
+
265
+ def _style_scene(scene: Any, style: CreativeStyle, index: int) -> Any:
266
+ if not isinstance(scene, dict):
267
+ return scene
268
+ styled = deepcopy(scene)
269
+ transition = styled.get("transition")
270
+ if transition in (None, "", "fade"):
271
+ styled["transition"] = style.transition_sequence[index % len(style.transition_sequence)]
272
+ if styled.get("effect") in (None, ""):
273
+ styled["effect"] = style.scene_effect_sequence[index % len(style.scene_effect_sequence)]
274
+ _set_default(styled, "background", "blur")
275
+ _set_default(styled, "layout", "fill")
276
+ return styled
renderer/templates/platforms.py CHANGED
@@ -151,6 +151,46 @@ PLATFORM_PROFILES: dict[str, PlatformProfile] = {
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
 
 
151
  recommended_duration_seconds=(5, 60),
152
  safe_zones={"top_px": 80, "bottom_px": 140, "left_px": 80, "right_px": 80},
153
  ),
154
+ "snapchat_spotlight": PlatformProfile(
155
+ key="snapchat_spotlight",
156
+ label="Snapchat Spotlight",
157
+ width=1080,
158
+ height=1920,
159
+ fps=30,
160
+ maxrate="8M",
161
+ bufsize="16M",
162
+ max_duration_seconds=60,
163
+ recommended_duration_seconds=(5, 30),
164
+ safe_zones=COMMON_VERTICAL_SAFE_ZONES,
165
+ notes=("Vertical, fast-paced edits perform best in Spotlight.",),
166
+ ),
167
+ "pinterest_idea_pins": PlatformProfile(
168
+ key="pinterest_idea_pins",
169
+ label="Pinterest Idea Pins",
170
+ width=1080,
171
+ height=1920,
172
+ fps=30,
173
+ maxrate="8M",
174
+ bufsize="16M",
175
+ max_duration_seconds=300,
176
+ recommended_duration_seconds=(6, 45),
177
+ safe_zones={"top_px": 160, "bottom_px": 240, "left_px": 80, "right_px": 80},
178
+ notes=("Use clear text overlays and evergreen discovery keywords.",),
179
+ ),
180
+ "linkedin_video": PlatformProfile(
181
+ key="linkedin_video",
182
+ label="LinkedIn video",
183
+ width=1920,
184
+ height=1080,
185
+ fps=30,
186
+ audio_bitrate="192k",
187
+ maxrate="10M",
188
+ bufsize="20M",
189
+ max_duration_seconds=600,
190
+ recommended_duration_seconds=(30, 180),
191
+ safe_zones={"top_px": 80, "bottom_px": 100, "left_px": 80, "right_px": 80},
192
+ notes=("Landscape explainers and square clips both work; captions are strongly recommended.",),
193
+ ),
194
  }
195
 
196
 
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
  "platform": "tiktok",
10
  "template": "tiktok_classic",
11
  "subtitle_format": "ass",
@@ -14,6 +15,7 @@ PRESETS: dict[str, dict[str, Any]] = {
14
  "normalize": True,
15
  },
16
  "youtube_shorts_hd": {
 
17
  "platform": "youtube_shorts",
18
  "template": "youtube_shorts",
19
  "subtitle_format": "ass",
@@ -21,6 +23,7 @@ PRESETS: dict[str, dict[str, Any]] = {
21
  "normalize": True,
22
  },
23
  "podcast_square": {
 
24
  "platform": "instagram_feed_square",
25
  "template": "podcast_style",
26
  "subtitle_format": "ass",
@@ -29,6 +32,7 @@ PRESETS: dict[str, dict[str, Any]] = {
29
  "metadata": {"target_aspect": "1:1"},
30
  },
31
  "reels_with_subtitles": {
 
32
  "platform": "instagram_reels",
33
  "template": "modern_minimal",
34
  "subtitle_format": "ass",
@@ -36,6 +40,7 @@ PRESETS: dict[str, dict[str, Any]] = {
36
  "normalize": True,
37
  },
38
  "draft_preview": {
 
39
  "platform": "tiktok",
40
  "template": "modern_minimal",
41
  "subtitle_format": "ass",
@@ -43,6 +48,7 @@ PRESETS: dict[str, dict[str, Any]] = {
43
  "normalize": True,
44
  },
45
  "tiktok_music_ducked": {
 
46
  "platform": "tiktok",
47
  "template": "tiktok_classic",
48
  "subtitle_format": "ass",
@@ -56,6 +62,7 @@ PRESETS: dict[str, dict[str, Any]] = {
56
  "normalize": True,
57
  },
58
  "instagram_reels_music": {
 
59
  "platform": "instagram_reels",
60
  "template": "modern_minimal",
61
  "subtitle_format": "ass",
@@ -69,6 +76,7 @@ PRESETS: dict[str, dict[str, Any]] = {
69
  "normalize": True,
70
  },
71
  "youtube_landscape_1080p": {
 
72
  "platform": "youtube_1080p",
73
  "template": "news_style",
74
  "subtitle_format": "ass",
@@ -76,6 +84,48 @@ PRESETS: dict[str, dict[str, Any]] = {
76
  "audio_normalize": True,
77
  "normalize": True,
78
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  }
80
 
81
 
 
6
 
7
  PRESETS: dict[str, dict[str, Any]] = {
8
  "tiktok_9_16_fast": {
9
+ "creative_style": "viral_shorts",
10
  "platform": "tiktok",
11
  "template": "tiktok_classic",
12
  "subtitle_format": "ass",
 
15
  "normalize": True,
16
  },
17
  "youtube_shorts_hd": {
18
+ "creative_style": "viral_shorts",
19
  "platform": "youtube_shorts",
20
  "template": "youtube_shorts",
21
  "subtitle_format": "ass",
 
23
  "normalize": True,
24
  },
25
  "podcast_square": {
26
+ "creative_style": "podcast_clip",
27
  "platform": "instagram_feed_square",
28
  "template": "podcast_style",
29
  "subtitle_format": "ass",
 
32
  "metadata": {"target_aspect": "1:1"},
33
  },
34
  "reels_with_subtitles": {
35
+ "creative_style": "story_vlog",
36
  "platform": "instagram_reels",
37
  "template": "modern_minimal",
38
  "subtitle_format": "ass",
 
40
  "normalize": True,
41
  },
42
  "draft_preview": {
43
+ "creative_style": "product_demo",
44
  "platform": "tiktok",
45
  "template": "modern_minimal",
46
  "subtitle_format": "ass",
 
48
  "normalize": True,
49
  },
50
  "tiktok_music_ducked": {
51
+ "creative_style": "viral_shorts",
52
  "platform": "tiktok",
53
  "template": "tiktok_classic",
54
  "subtitle_format": "ass",
 
62
  "normalize": True,
63
  },
64
  "instagram_reels_music": {
65
+ "creative_style": "story_vlog",
66
  "platform": "instagram_reels",
67
  "template": "modern_minimal",
68
  "subtitle_format": "ass",
 
76
  "normalize": True,
77
  },
78
  "youtube_landscape_1080p": {
79
+ "creative_style": "news_explainer",
80
  "platform": "youtube_1080p",
81
  "template": "news_style",
82
  "subtitle_format": "ass",
 
84
  "audio_normalize": True,
85
  "normalize": True,
86
  },
87
+ "capcut_viral_auto": {
88
+ "creative_style": "viral_shorts",
89
+ "platform": "tiktok",
90
+ "template": "neon_pop",
91
+ "subtitle_format": "ass",
92
+ "auto_subtitles": True,
93
+ "audio_normalize": True,
94
+ "music_volume": 0.24,
95
+ "music_fade_in": 0.2,
96
+ "music_fade_out": 0.8,
97
+ "music_loop": True,
98
+ "music_ducking": True,
99
+ "normalize": True,
100
+ },
101
+ "capcut_product_launch": {
102
+ "creative_style": "product_demo",
103
+ "platform": "instagram_reels",
104
+ "template": "product_demo",
105
+ "subtitle_format": "ass",
106
+ "auto_subtitles": True,
107
+ "audio_normalize": True,
108
+ "music_volume": 0.18,
109
+ "music_fade_in": 0.3,
110
+ "music_fade_out": 0.7,
111
+ "music_loop": True,
112
+ "music_ducking": True,
113
+ "normalize": True,
114
+ },
115
+ "capcut_cinematic_story": {
116
+ "creative_style": "cinematic_story",
117
+ "platform": "youtube_shorts",
118
+ "template": "cinematic_gold",
119
+ "subtitle_format": "ass",
120
+ "auto_subtitles": True,
121
+ "audio_normalize": True,
122
+ "music_volume": 0.26,
123
+ "music_fade_in": 0.8,
124
+ "music_fade_out": 1.4,
125
+ "music_loop": True,
126
+ "music_ducking": True,
127
+ "normalize": True,
128
+ },
129
  }
130
 
131
 
renderer/transitions/builder.py CHANGED
@@ -12,11 +12,23 @@ class TransitionBuilder:
12
  "blur": "fade",
13
  "whip": "smoothleft",
14
  "dissolve": "dissolve",
 
 
 
 
 
 
 
 
 
15
  }
16
 
17
  def map_transition(self, name: str) -> str:
18
  return self.TRANSITIONS.get(name, "fade")
19
 
 
 
 
20
  def xfade_chain(self, stream_count: int, durations: list[float], transitions: list[str], transition_duration: float = 0.45) -> tuple[str, str]:
21
  if stream_count <= 1:
22
  return "", "[0:v]"
 
12
  "blur": "fade",
13
  "whip": "smoothleft",
14
  "dissolve": "dissolve",
15
+ "flash": "fadewhite",
16
+ "glitch": "hlslice",
17
+ "wipe": "wipeleft",
18
+ "wipe_left": "wipeleft",
19
+ "wipe_right": "wiperight",
20
+ "smooth_left": "smoothleft",
21
+ "smooth_right": "smoothright",
22
+ "fadeblack": "fadeblack",
23
+ "pixel": "pixelize",
24
  }
25
 
26
  def map_transition(self, name: str) -> str:
27
  return self.TRANSITIONS.get(name, "fade")
28
 
29
+ def list_transitions(self) -> list[str]:
30
+ return list(self.TRANSITIONS.keys())
31
+
32
  def xfade_chain(self, stream_count: int, durations: list[float], transitions: list[str], transition_duration: float = 0.45) -> tuple[str, str]:
33
  if stream_count <= 1:
34
  return "", "[0:v]"