Ava2lon commited on
Commit
5d782cc
·
verified ·
1 Parent(s): 59215bb

Upload 52 files

Browse files
models/tokenizer.py CHANGED
@@ -80,7 +80,7 @@ class Tokenizer:
80
  Returns:
81
  str: A formatted string describing the monetary value.
82
  """
83
- m = m.group()
84
  currency = 'dollar' if m[0] == '$' else 'pound'
85
 
86
  # Handle whole amounts (e.g., "$10", "£20")
 
80
  Returns:
81
  str: A formatted string describing the monetary value.
82
  """
83
+ m = match.group()
84
  currency = 'dollar' if m[0] == '$' else 'pound'
85
 
86
  # Handle whole amounts (e.g., "$10", "£20")
renderer/automation/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from renderer.automation.templates import (
2
+ automation_template_catalog,
3
+ list_automation_templates,
4
+ resolve_render_template,
5
+ )
6
+
7
+ __all__ = ["automation_template_catalog", "list_automation_templates", "resolve_render_template"]
renderer/automation/templates.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from copy import deepcopy
5
+ from typing import Any
6
+
7
+
8
+ PLACEHOLDER = re.compile(r"{{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*}}")
9
+
10
+ AUTOMATION_TEMPLATES: dict[str, dict[str, Any]] = {
11
+ "vertical-captioned-clip": {
12
+ "preset": "tiktok_9_16_fast",
13
+ "output_name": "{{output_name}}",
14
+ "scenes": "{{scenes}}",
15
+ "voiceover": "{{voiceover}}",
16
+ "background_music": "{{background_music}}",
17
+ "auto_subtitles": True,
18
+ "audio_normalize": True,
19
+ },
20
+ "podcast-clip": {
21
+ "preset": "podcast_square",
22
+ "output_name": "{{output_name}}",
23
+ "scenes": "{{scenes}}",
24
+ "auto_subtitles": True,
25
+ "audio_normalize": True,
26
+ },
27
+ "product-promo": {
28
+ "preset": "capcut_product_launch",
29
+ "output_name": "{{output_name}}",
30
+ "scenes": "{{scenes}}",
31
+ "watermark": "{{watermark}}",
32
+ "background_music": "{{background_music}}",
33
+ },
34
+ }
35
+
36
+
37
+ def list_automation_templates() -> list[str]:
38
+ return sorted(AUTOMATION_TEMPLATES)
39
+
40
+
41
+ def automation_template_catalog() -> dict[str, dict[str, Any]]:
42
+ return deepcopy(AUTOMATION_TEMPLATES)
43
+
44
+
45
+ def resolve_render_template(
46
+ template: str | dict[str, Any],
47
+ variables: dict[str, Any],
48
+ overrides: dict[str, Any] | None = None,
49
+ ) -> dict[str, Any]:
50
+ if isinstance(template, str):
51
+ if template not in AUTOMATION_TEMPLATES:
52
+ raise ValueError(f"Unknown automation template: {template}")
53
+ source = deepcopy(AUTOMATION_TEMPLATES[template])
54
+ elif isinstance(template, dict):
55
+ source = deepcopy(template)
56
+ else:
57
+ raise ValueError("Template must be a registered name or JSON object")
58
+
59
+ rendered = _substitute(source, variables)
60
+ if not isinstance(rendered, dict):
61
+ raise ValueError("Rendered template must produce a JSON object")
62
+ return _deep_merge(rendered, overrides or {})
63
+
64
+
65
+ def _substitute(value: Any, variables: dict[str, Any]) -> Any:
66
+ if isinstance(value, dict):
67
+ return {key: _substitute(item, variables) for key, item in value.items()}
68
+ if isinstance(value, list):
69
+ return [_substitute(item, variables) for item in value]
70
+ if not isinstance(value, str):
71
+ return value
72
+
73
+ exact = PLACEHOLDER.fullmatch(value)
74
+ if exact:
75
+ return deepcopy(_lookup(variables, exact.group(1)))
76
+
77
+ return PLACEHOLDER.sub(lambda match: str(_lookup(variables, match.group(1))), value)
78
+
79
+
80
+ def _lookup(variables: dict[str, Any], key: str) -> Any:
81
+ value: Any = variables
82
+ for part in key.split("."):
83
+ if not isinstance(value, dict) or part not in value:
84
+ raise ValueError(f"Missing template variable: {key}")
85
+ value = value[part]
86
+ return value
87
+
88
+
89
+ def _deep_merge(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
90
+ output = deepcopy(base)
91
+ for key, value in overrides.items():
92
+ if isinstance(value, dict) and isinstance(output.get(key), dict):
93
+ output[key] = _deep_merge(output[key], value)
94
+ else:
95
+ output[key] = deepcopy(value)
96
+ return output
renderer/core/config.py CHANGED
@@ -19,6 +19,7 @@ class Settings:
19
  metadata_cache: Path = Path(os.getenv("METADATA_CACHE", str(DEFAULT_ROOT / "temp" / "metadata_cache.json")))
20
  signing_secret: str = os.getenv("AVA2LON_SIGNING_SECRET", os.getenv("BASYX_SIGNING_SECRET", "dev-secret-change-me"))
21
  api_key: str = os.getenv("AVA2LON_API_KEY", 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"))
@@ -32,6 +33,8 @@ class Settings:
32
  whisper_device: str = os.getenv("WHISPER_DEVICE", "cpu")
33
  whisper_model_dir: Path = Path(os.getenv("WHISPER_MODEL_DIR", str(DEFAULT_ROOT / "models")))
34
  max_retries: int = int(os.getenv("MAX_RETRIES", "3"))
 
 
35
  max_workers: int = int(os.getenv("MAX_RENDER_WORKERS", "1"))
36
  job_retention_seconds: int = int(os.getenv("JOB_RETENTION_SECONDS", str(24 * 3600)))
37
  crf: int = int(os.getenv("OUTPUT_CRF", "23"))
 
19
  metadata_cache: Path = Path(os.getenv("METADATA_CACHE", str(DEFAULT_ROOT / "temp" / "metadata_cache.json")))
20
  signing_secret: str = os.getenv("AVA2LON_SIGNING_SECRET", os.getenv("BASYX_SIGNING_SECRET", "dev-secret-change-me"))
21
  api_key: str = os.getenv("AVA2LON_API_KEY", os.getenv("BASYX_API_KEY", ""))
22
+ public_base_url: str = os.getenv("PUBLIC_BASE_URL", "").rstrip("/")
23
  font_path: Path = Path(os.getenv("FONT_PATH", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"))
24
  output_width: int = int(os.getenv("OUTPUT_WIDTH", "1080"))
25
  output_height: int = int(os.getenv("OUTPUT_HEIGHT", "1920"))
 
33
  whisper_device: str = os.getenv("WHISPER_DEVICE", "cpu")
34
  whisper_model_dir: Path = Path(os.getenv("WHISPER_MODEL_DIR", str(DEFAULT_ROOT / "models")))
35
  max_retries: int = int(os.getenv("MAX_RETRIES", "3"))
36
+ callback_max_retries: int = int(os.getenv("CALLBACK_MAX_RETRIES", "3"))
37
+ callback_retry_seconds: float = float(os.getenv("CALLBACK_RETRY_SECONDS", "1"))
38
  max_workers: int = int(os.getenv("MAX_RENDER_WORKERS", "1"))
39
  job_retention_seconds: int = int(os.getenv("JOB_RETENTION_SECONDS", str(24 * 3600)))
40
  crf: int = int(os.getenv("OUTPUT_CRF", "23"))
renderer/core/models.py CHANGED
@@ -71,6 +71,8 @@ class AIReelsRequest:
71
  music_start: float = 0.0
72
  music_ducking: bool = True
73
  voice_volume: float = 1.0
 
 
74
 
75
 
76
  @dataclass
@@ -112,12 +114,17 @@ class JobRecord:
112
  state: JobState
113
  created_at: float
114
  updated_at: float
 
115
  output_path: str | None = None
116
  download_token: str | None = None
117
  callback_url: str | None = None
118
  export_target: str | None = None
119
  export_path: str | None = None
120
  failure_reason: str | None = None
 
 
 
121
  commands: list[list[str]] = field(default_factory=list)
122
  logs: list[str] = field(default_factory=list)
123
  metrics: dict[str, Any] = field(default_factory=dict)
 
 
71
  music_start: float = 0.0
72
  music_ducking: bool = True
73
  voice_volume: float = 1.0
74
+ callback_url: str | None = None
75
+ export_target: str | None = None
76
 
77
 
78
  @dataclass
 
114
  state: JobState
115
  created_at: float
116
  updated_at: float
117
+ job_type: str = "task"
118
  output_path: str | None = None
119
  download_token: str | None = None
120
  callback_url: str | None = None
121
  export_target: str | None = None
122
  export_path: str | None = None
123
  failure_reason: str | None = None
124
+ callback_attempts: int = 0
125
+ callback_delivered_at: float | None = None
126
+ callback_last_error: str | None = None
127
  commands: list[list[str]] = field(default_factory=list)
128
  logs: list[str] = field(default_factory=list)
129
  metrics: dict[str, Any] = field(default_factory=dict)
130
+ metadata: dict[str, Any] = field(default_factory=dict)
renderer/core/render_engine.py CHANGED
@@ -73,6 +73,7 @@ class RenderEngine:
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
  "creative_style": request.creative_style or request.metadata.get("creative_style"),
78
  "scene_effects": [scene.effect for scene in request.scenes if scene.effect],
@@ -177,6 +178,7 @@ class RenderEngine:
177
  metrics = {
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],
 
73
  metrics = {
74
  "render_time_seconds": round(time.time() - started, 3),
75
  "output_size_bytes": output.stat().st_size,
76
+ "duration_seconds": round(timeline.total_duration, 3),
77
  "scene_count": len(request.scenes),
78
  "creative_style": request.creative_style or request.metadata.get("creative_style"),
79
  "scene_effects": [scene.effect for scene in request.scenes if scene.effect],
 
178
  metrics = {
179
  "render_time_seconds": round(time.time() - started, 3),
180
  "output_size_bytes": output.stat().st_size,
181
+ "duration_seconds": round(timeline.total_duration, 3),
182
  "scene_count": len(request.scenes),
183
  "creative_style": request.creative_style or request.metadata.get("creative_style"),
184
  "scene_effects": [scene.effect for scene in request.scenes if scene.effect],
renderer/jobs/manager.py CHANGED
@@ -1,10 +1,14 @@
1
  from __future__ import annotations
2
 
3
  import concurrent.futures
 
 
4
  import http.client
5
  import json
 
6
  import shutil
7
  import threading
 
8
  import urllib.request
9
  from urllib.parse import urlparse
10
  from dataclasses import asdict
@@ -30,10 +34,17 @@ class JobManager:
30
  lambda job_id, log: RenderEngine(self.settings, log=log).render(request, job_id),
31
  callback_url=request.callback_url,
32
  export_target=request.export_target,
 
 
33
  )
34
 
35
  def submit_ai_reels(self, request: AIReelsRequest) -> str:
36
- return self._submit(lambda job_id, log: RenderEngine(self.settings, log=log).ai_reels(request, job_id))
 
 
 
 
 
37
 
38
  def submit_task(
39
  self,
@@ -41,8 +52,10 @@ class JobManager:
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] = []
@@ -112,11 +125,24 @@ class JobManager:
112
  "max_workers": self.settings.max_workers,
113
  }
114
 
 
 
 
 
 
 
 
 
 
 
 
115
  def _submit(
116
  self,
117
  handler: Callable[[str, Callable[[str], None]], object],
118
  callback_url: str | None = None,
119
  export_target: str | None = None,
 
 
120
  ) -> str:
121
  job_id = new_id()
122
  record = JobRecord(
@@ -124,9 +150,11 @@ class JobManager:
124
  state="PENDING",
125
  created_at=now(),
126
  updated_at=now(),
 
127
  download_token=create_download_token(self.settings.signing_secret, job_id),
128
  callback_url=callback_url,
129
  export_target=export_target,
 
130
  )
131
  self._save(record)
132
  self.executor.submit(self._run_with_retries, job_id, handler)
@@ -134,7 +162,8 @@ class JobManager:
134
 
135
  def _run_with_retries(self, job_id: str, handler: Callable[[str, Callable[[str], None]], object]) -> None:
136
  attempts = 0
137
- while attempts < self.settings.max_retries:
 
138
  attempts += 1
139
  if self.get(job_id).state == "CANCELLED":
140
  self._send_callback(job_id)
@@ -149,6 +178,9 @@ class JobManager:
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",
@@ -156,13 +188,13 @@ class JobManager:
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
163
  except Exception as exc:
164
  self.append_log(job_id, f"Attempt {attempts} failed: {exc}")
165
- if attempts >= self.settings.max_retries:
166
  self._update(job_id, state="FAILED", failure_reason=str(exc), metrics={"attempt": attempts})
167
  self._send_callback(job_id)
168
 
@@ -211,17 +243,54 @@ class JobManager:
211
  return
212
  if not record.callback_url:
213
  return
214
- payload = json.dumps(asdict(record), default=str).encode("utf-8")
215
- request = urllib.request.Request(
216
- record.callback_url,
217
- data=payload,
218
- headers={"Content-Type": "application/json", "User-Agent": "ava2lon-studio-callback/2.0"},
219
- method="POST",
 
 
 
 
220
  )
221
- try:
222
- urllib.request.urlopen(request, timeout=10).read()
223
- except Exception as exc:
224
- self.append_log(job_id, f"Callback delivery failed: {exc}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
 
226
 
227
  def _put_file(url: str, path: Path) -> None:
@@ -251,3 +320,20 @@ def _put_file(url: str, path: Path) -> None:
251
  connection.close()
252
  if response.status >= 400:
253
  raise RuntimeError(f"Export upload failed with HTTP {response.status}: {body[:500]!r}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import concurrent.futures
4
+ import hashlib
5
+ import hmac
6
  import http.client
7
  import json
8
+ import mimetypes
9
  import shutil
10
  import threading
11
+ import time
12
  import urllib.request
13
  from urllib.parse import urlparse
14
  from dataclasses import asdict
 
34
  lambda job_id, log: RenderEngine(self.settings, log=log).render(request, job_id),
35
  callback_url=request.callback_url,
36
  export_target=request.export_target,
37
+ metadata=request.metadata,
38
+ job_type="render",
39
  )
40
 
41
  def submit_ai_reels(self, request: AIReelsRequest) -> str:
42
+ return self._submit(
43
+ lambda job_id, log: RenderEngine(self.settings, log=log).ai_reels(request, job_id),
44
+ callback_url=request.callback_url,
45
+ export_target=request.export_target,
46
+ job_type="render",
47
+ )
48
 
49
  def submit_task(
50
  self,
 
52
  *,
53
  callback_url: str | None = None,
54
  export_target: str | None = None,
55
+ metadata: dict | None = None,
56
+ job_type: str = "task",
57
  ) -> str:
58
+ return self._submit(handler, callback_url=callback_url, export_target=export_target, metadata=metadata, job_type=job_type)
59
 
60
  def submit_batch(self, requests: list[RenderRequest]) -> list[str]:
61
  job_ids: list[str] = []
 
125
  "max_workers": self.settings.max_workers,
126
  }
127
 
128
+ def group(self, group_id: str) -> list[JobRecord]:
129
+ records: list[JobRecord] = []
130
+ for path in self.settings.jobs_dir.glob("*.json"):
131
+ try:
132
+ record = JobRecord(**read_json(path, {}))
133
+ except Exception:
134
+ continue
135
+ if record.metadata.get("group_id") == group_id:
136
+ records.append(record)
137
+ return sorted(records, key=lambda item: item.created_at)
138
+
139
  def _submit(
140
  self,
141
  handler: Callable[[str, Callable[[str], None]], object],
142
  callback_url: str | None = None,
143
  export_target: str | None = None,
144
+ metadata: dict | None = None,
145
+ job_type: str = "task",
146
  ) -> str:
147
  job_id = new_id()
148
  record = JobRecord(
 
150
  state="PENDING",
151
  created_at=now(),
152
  updated_at=now(),
153
+ job_type=job_type,
154
  download_token=create_download_token(self.settings.signing_secret, job_id),
155
  callback_url=callback_url,
156
  export_target=export_target,
157
+ metadata=metadata or {},
158
  )
159
  self._save(record)
160
  self.executor.submit(self._run_with_retries, job_id, handler)
 
162
 
163
  def _run_with_retries(self, job_id: str, handler: Callable[[str, Callable[[str], None]], object]) -> None:
164
  attempts = 0
165
+ max_attempts = max(1, self.settings.max_retries)
166
+ while attempts < max_attempts:
167
  attempts += 1
168
  if self.get(job_id).state == "CANCELLED":
169
  self._send_callback(job_id)
 
178
  record = self.get(job_id)
179
  output_path = getattr(result, "output_path", None)
180
  export_path = self._export_copy(output_path, record.export_target, job_id) if output_path else None
181
+ result_metrics = getattr(result, "metrics", {}) or {}
182
+ if output_path:
183
+ result_metrics = result_metrics | {"artifact": _artifact_metadata(Path(output_path))}
184
  self._update(
185
  job_id,
186
  state="COMPLETED",
 
188
  export_path=export_path,
189
  commands=getattr(result, "commands", []),
190
  logs=getattr(result, "logs", []),
191
+ metrics=result_metrics | {"attempt": attempts},
192
  )
193
  self._send_callback(job_id)
194
  return
195
  except Exception as exc:
196
  self.append_log(job_id, f"Attempt {attempts} failed: {exc}")
197
+ if attempts >= max_attempts:
198
  self._update(job_id, state="FAILED", failure_reason=str(exc), metrics={"attempt": attempts})
199
  self._send_callback(job_id)
200
 
 
243
  return
244
  if not record.callback_url:
245
  return
246
+ event = _event_name(record.job_type, record.state)
247
+ data = asdict(record)
248
+ data.update(
249
+ {
250
+ "event": event,
251
+ "event_id": f"{job_id}:{record.state.lower()}:{int(record.updated_at)}",
252
+ "occurred_at": record.updated_at,
253
+ "status_url": self._public_url(f"/status/{job_id}"),
254
+ "download_url": self._public_url(f"/download/{job_id}?token={record.download_token}"),
255
+ }
256
  )
257
+ payload = json.dumps(data, default=str).encode("utf-8")
258
+ signature = hmac.new(self.settings.signing_secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
259
+ attempts = max(1, self.settings.callback_max_retries)
260
+ last_error: str | None = None
261
+ for attempt in range(1, attempts + 1):
262
+ request = urllib.request.Request(
263
+ record.callback_url,
264
+ data=payload,
265
+ headers={
266
+ "Content-Type": "application/json",
267
+ "User-Agent": "ava2lon-studio-callback/2.0",
268
+ "X-Ava2lon-Event": event,
269
+ "X-Ava2lon-Signature": f"sha256={signature}",
270
+ },
271
+ method="POST",
272
+ )
273
+ try:
274
+ with urllib.request.urlopen(request, timeout=10) as response:
275
+ response.read()
276
+ if response.status >= 400:
277
+ raise RuntimeError(f"HTTP {response.status}")
278
+ self._update(
279
+ job_id,
280
+ callback_attempts=attempt,
281
+ callback_delivered_at=now(),
282
+ callback_last_error=None,
283
+ )
284
+ return
285
+ except Exception as exc:
286
+ last_error = str(exc)
287
+ self._update(job_id, callback_attempts=attempt, callback_last_error=last_error)
288
+ if attempt < attempts:
289
+ time.sleep(max(0.0, self.settings.callback_retry_seconds) * (2 ** (attempt - 1)))
290
+ self.append_log(job_id, f"Callback delivery failed after {attempts} attempts: {last_error}")
291
+
292
+ def _public_url(self, path: str) -> str:
293
+ return f"{self.settings.public_base_url}{path}" if self.settings.public_base_url else path
294
 
295
 
296
  def _put_file(url: str, path: Path) -> None:
 
320
  connection.close()
321
  if response.status >= 400:
322
  raise RuntimeError(f"Export upload failed with HTTP {response.status}: {body[:500]!r}")
323
+
324
+
325
+ def _event_name(job_type: str, state: str) -> str:
326
+ return f"{job_type}.{state.lower()}"
327
+
328
+
329
+ def _artifact_metadata(path: Path) -> dict:
330
+ digest = hashlib.sha256()
331
+ with path.open("rb") as source:
332
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
333
+ digest.update(chunk)
334
+ return {
335
+ "filename": path.name,
336
+ "size_bytes": path.stat().st_size,
337
+ "mime_type": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
338
+ "sha256": digest.hexdigest(),
339
+ }
renderer/platform/processor.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  import json
4
  import math
5
  import mimetypes
 
6
  import shutil
7
  import zipfile
8
  from pathlib import Path
@@ -14,7 +15,7 @@ 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
 
@@ -66,6 +67,13 @@ TOOLKIT_TASKS = {
66
  "chroma_key",
67
  "blue_screen",
68
  "ai_background_removal",
 
 
 
 
 
 
 
69
  }
70
 
71
 
@@ -225,6 +233,25 @@ class PlatformProcessor:
225
  final = self._export(target, job_id, "thumbnail.jpg")
226
  return self._result(final, {"task": "thumbnail", "timestamp": seek})
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  def toolkit(self, payload: dict[str, Any], job_id: str) -> TaskResult:
229
  task = str(payload.get("task") or payload.get("operation") or "").strip()
230
  if task not in TOOLKIT_TASKS:
@@ -234,6 +261,11 @@ class PlatformProcessor:
234
  if task == "split":
235
  clips = payload.get("clips") if isinstance(payload.get("clips"), list) else payload.get("params", {}).get("clips")
236
  return self.clips(str(payload.get("input") or payload.get("media")), job_id, clips)
 
 
 
 
 
237
 
238
  with temp_workdir(self.settings.temp_dir, f"{job_id}_{task}") as work:
239
  workdir = Path(work)
@@ -246,6 +278,8 @@ class PlatformProcessor:
246
  output = output.with_suffix(".mp3")
247
  if task == "gif" and output.suffix.lower() != ".gif":
248
  output = output.with_suffix(".gif")
 
 
249
  command = self._toolkit_command(task, source, output, params, workdir)
250
  self._run(command)
251
  if task == "frames":
@@ -253,6 +287,11 @@ class PlatformProcessor:
253
  with zipfile.ZipFile(final, "w", zipfile.ZIP_DEFLATED) as archive:
254
  for frame in sorted(workdir.glob("frame_*.jpg")):
255
  archive.write(frame, frame.name)
 
 
 
 
 
256
  else:
257
  final = self._export(output, job_id, output.name)
258
  return self._result(final, {"task": task})
@@ -284,6 +323,30 @@ class PlatformProcessor:
284
  lines.append(f"file '{str(media).replace(chr(39), chr(39) + chr(92) + chr(39) + chr(39))}'")
285
  concat_file.write_text("\n".join(lines), encoding="utf-8")
286
  return FFmpegCommand().add("-hide_banner", "-f", "concat", "-safe", "0").input(concat_file).add("-c", "copy").overwrite().add(output).build()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
 
288
  duration = params.get("duration")
289
  if task in {"cut", "trim", "gif", "loop", "freeze_frame"} and duration is not None:
@@ -354,10 +417,11 @@ class PlatformProcessor:
354
  shutil.copy2(source, target)
355
  return target
356
 
357
- def _run(self, command: list[str]) -> None:
358
  result = self.runner.run(command)
359
  if result.stderr:
360
  self._logs.append(result.stderr[-4000:])
 
361
 
362
  def _record_command(self, command: list[str]) -> None:
363
  self._commands.append(command)
@@ -600,4 +664,60 @@ def _default_output_name(task: str) -> str:
600
  return "clip.gif"
601
  if task == "thumbnail":
602
  return "thumbnail.jpg"
 
 
 
 
603
  return f"{task}.mp4"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import json
4
  import math
5
  import mimetypes
6
+ import re
7
  import shutil
8
  import zipfile
9
  from pathlib import Path
 
15
  from renderer.core.utils import safe_filename, temp_workdir, write_json
16
  from renderer.ffmpeg.assets import AssetProbe
17
  from renderer.ffmpeg.command import FFmpegCommand
18
+ from renderer.ffmpeg.runner import CommandResult, FFmpegRunner
19
  from renderer.templates import get_platform_profile
20
 
21
 
 
67
  "chroma_key",
68
  "blue_screen",
69
  "ai_background_removal",
70
+ "inspect",
71
+ "loudness_analyze",
72
+ "silence_detect",
73
+ "black_detect",
74
+ "scene_detect",
75
+ "contact_sheet",
76
+ "hls",
77
  }
78
 
79
 
 
233
  final = self._export(target, job_id, "thumbnail.jpg")
234
  return self._result(final, {"task": "thumbnail", "timestamp": seek})
235
 
236
+ def inspect_media(self, media: str, job_id: str) -> TaskResult:
237
+ with temp_workdir(self.settings.temp_dir, f"{job_id}_inspect") as work:
238
+ source = self.ingest.resolve(media, Path(work) / "inputs", "media")
239
+ metadata = self.assets.probe(source).__dict__
240
+ metadata["source"] = media
241
+ output = self._json_artifact(job_id, "media_inspection", metadata)
242
+ return self._result(output, {"task": "inspect", "duration": metadata.get("duration", 0)})
243
+
244
+ def media_analysis(self, task: str, media: str, job_id: str, params: dict[str, Any]) -> TaskResult:
245
+ with temp_workdir(self.settings.temp_dir, f"{job_id}_{task}") as work:
246
+ source = self.ingest.resolve(media, Path(work) / "inputs", "media")
247
+ command = _analysis_command(task, source, params)
248
+ result = self._run(command)
249
+ analysis = _parse_analysis(task, result.stderr, params)
250
+ analysis.update({"task": task, "source": media})
251
+ output = self._json_artifact(job_id, task, analysis)
252
+ count = len(analysis.get("events", []))
253
+ return self._result(output, {"task": task, "event_count": count})
254
+
255
  def toolkit(self, payload: dict[str, Any], job_id: str) -> TaskResult:
256
  task = str(payload.get("task") or payload.get("operation") or "").strip()
257
  if task not in TOOLKIT_TASKS:
 
261
  if task == "split":
262
  clips = payload.get("clips") if isinstance(payload.get("clips"), list) else payload.get("params", {}).get("clips")
263
  return self.clips(str(payload.get("input") or payload.get("media")), job_id, clips)
264
+ if task == "inspect":
265
+ return self.inspect_media(str(payload.get("input") or payload.get("media")), job_id)
266
+ if task in {"loudness_analyze", "silence_detect", "black_detect", "scene_detect"}:
267
+ params = payload.get("params") if isinstance(payload.get("params"), dict) else payload
268
+ return self.media_analysis(task, str(payload.get("input") or payload.get("media")), job_id, params)
269
 
270
  with temp_workdir(self.settings.temp_dir, f"{job_id}_{task}") as work:
271
  workdir = Path(work)
 
278
  output = output.with_suffix(".mp3")
279
  if task == "gif" and output.suffix.lower() != ".gif":
280
  output = output.with_suffix(".gif")
281
+ if task == "contact_sheet" and output.suffix.lower() not in {".jpg", ".jpeg", ".png"}:
282
+ output = output.with_suffix(".jpg")
283
  command = self._toolkit_command(task, source, output, params, workdir)
284
  self._run(command)
285
  if task == "frames":
 
287
  with zipfile.ZipFile(final, "w", zipfile.ZIP_DEFLATED) as archive:
288
  for frame in sorted(workdir.glob("frame_*.jpg")):
289
  archive.write(frame, frame.name)
290
+ elif task == "hls":
291
+ final = self.settings.exports_dir / f"{job_id}_hls.zip"
292
+ with zipfile.ZipFile(final, "w", zipfile.ZIP_DEFLATED) as archive:
293
+ for path in sorted(workdir.glob("hls_*")):
294
+ archive.write(path, path.name)
295
  else:
296
  final = self._export(output, job_id, output.name)
297
  return self._result(final, {"task": task})
 
323
  lines.append(f"file '{str(media).replace(chr(39), chr(39) + chr(92) + chr(39) + chr(39))}'")
324
  concat_file.write_text("\n".join(lines), encoding="utf-8")
325
  return FFmpegCommand().add("-hide_banner", "-f", "concat", "-safe", "0").input(concat_file).add("-c", "copy").overwrite().add(output).build()
326
+ if task == "contact_sheet":
327
+ columns = max(1, min(10, int(params.get("columns", 4))))
328
+ rows = max(1, min(10, int(params.get("rows", 4))))
329
+ interval = max(0.1, float(params.get("interval_seconds", 5)))
330
+ width = max(80, min(1920, int(params.get("thumbnail_width", 320))))
331
+ vf = f"fps=1/{interval},scale={width}:-1,tile={columns}x{rows}:padding=4:margin=4"
332
+ return cmd.add("-vf", vf, "-frames:v", 1, "-q:v", 2).overwrite().add(output).build()
333
+ if task == "hls":
334
+ segment_seconds = max(1, min(30, int(params.get("segment_seconds", 6))))
335
+ playlist_type = str(params.get("playlist_type", "vod")).lower()
336
+ if playlist_type not in {"event", "vod"}:
337
+ raise ValueError("HLS playlist_type must be 'event' or 'vod'")
338
+ playlist = workdir / "hls_playlist.m3u8"
339
+ segments = workdir / "hls_segment_%05d.ts"
340
+ return (
341
+ cmd.add(
342
+ "-c:v", "libx264", "-preset", self.settings.preset, "-crf", int(params.get("crf", self.settings.crf)),
343
+ "-c:a", "aac", "-f", "hls", "-hls_time", segment_seconds, "-hls_playlist_type", playlist_type,
344
+ "-hls_segment_filename", segments,
345
+ )
346
+ .overwrite()
347
+ .add(playlist)
348
+ .build()
349
+ )
350
 
351
  duration = params.get("duration")
352
  if task in {"cut", "trim", "gif", "loop", "freeze_frame"} and duration is not None:
 
417
  shutil.copy2(source, target)
418
  return target
419
 
420
+ def _run(self, command: list[str]) -> CommandResult:
421
  result = self.runner.run(command)
422
  if result.stderr:
423
  self._logs.append(result.stderr[-4000:])
424
+ return result
425
 
426
  def _record_command(self, command: list[str]) -> None:
427
  self._commands.append(command)
 
664
  return "clip.gif"
665
  if task == "thumbnail":
666
  return "thumbnail.jpg"
667
+ if task == "contact_sheet":
668
+ return "contact_sheet.jpg"
669
+ if task == "hls":
670
+ return "stream.zip"
671
  return f"{task}.mp4"
672
+
673
+
674
+ def _analysis_command(task: str, source: Path, params: dict[str, Any]) -> list[str]:
675
+ command = FFmpegCommand().add("-hide_banner").input(source)
676
+ if task == "loudness_analyze":
677
+ return command.add("-vn", "-af", "ebur128=framelog=verbose", "-f", "null", "-").build()
678
+ if task == "silence_detect":
679
+ noise = float(params.get("noise_db", -35))
680
+ duration = float(params.get("duration_seconds", 0.5))
681
+ return command.add("-af", f"silencedetect=noise={noise}dB:d={duration}", "-f", "null", "-").build()
682
+ if task == "black_detect":
683
+ threshold = max(0.0, min(1.0, float(params.get("pixel_threshold", 0.1))))
684
+ duration = max(0.1, float(params.get("duration_seconds", 0.1)))
685
+ return command.add("-vf", f"blackdetect=d={duration}:pic_th={threshold}", "-an", "-f", "null", "-").build()
686
+ if task == "scene_detect":
687
+ threshold = max(0.01, min(1.0, float(params.get("threshold", 0.4))))
688
+ return command.add("-vf", f"select='gt(scene,{threshold})',showinfo", "-an", "-f", "null", "-").build()
689
+ raise ValueError(f"Unsupported analysis task: {task}")
690
+
691
+
692
+ def _parse_analysis(task: str, stderr: str, params: dict[str, Any]) -> dict[str, Any]:
693
+ events: list[dict[str, Any]] = []
694
+ if task == "loudness_analyze":
695
+ integrated = re.findall(r"I:\s*(-?\d+(?:\.\d+)?)\s*LUFS", stderr)
696
+ loudness_range = re.findall(r"LRA:\s*(-?\d+(?:\.\d+)?)\s*LU", stderr)
697
+ true_peak = re.findall(r"Peak:\s*(-?\d+(?:\.\d+)?)\s*dBFS", stderr)
698
+ return {
699
+ "integrated_lufs": float(integrated[-1]) if integrated else None,
700
+ "loudness_range_lu": float(loudness_range[-1]) if loudness_range else None,
701
+ "true_peak_dbfs": float(true_peak[-1]) if true_peak else None,
702
+ "target_lufs": float(params.get("target_lufs", -14)),
703
+ }
704
+ if task == "silence_detect":
705
+ starts = re.finditer(r"silence_start:\s*([0-9.]+)", stderr)
706
+ ends = list(re.finditer(r"silence_end:\s*([0-9.]+).*?silence_duration:\s*([0-9.]+)", stderr))
707
+ end_index = 0
708
+ for match in starts:
709
+ if end_index < len(ends):
710
+ end = ends[end_index]
711
+ if float(end.group(1)) >= float(match.group(1)):
712
+ events.append({"start": float(match.group(1)), "end": float(end.group(1)), "duration": float(end.group(2))})
713
+ end_index += 1
714
+ else:
715
+ events.append({"start": float(match.group(1))})
716
+ return {"events": events, "noise_db": float(params.get("noise_db", -35))}
717
+ if task == "black_detect":
718
+ for match in re.finditer(r"black_start:([0-9.]+)\s+black_end:([0-9.]+)\s+black_duration:([0-9.]+)", stderr):
719
+ events.append({"start": float(match.group(1)), "end": float(match.group(2)), "duration": float(match.group(3))})
720
+ return {"events": events, "pixel_threshold": float(params.get("pixel_threshold", 0.1))}
721
+ for match in re.finditer(r"pts_time:([0-9.]+)", stderr):
722
+ events.append({"time": float(match.group(1))})
723
+ return {"events": events, "threshold": float(params.get("threshold", 0.4))}
tests/test_n8n_automation.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import tempfile
6
+ import time
7
+ import unittest
8
+ from http.server import BaseHTTPRequestHandler, HTTPServer
9
+ from pathlib import Path
10
+ from threading import Thread
11
+
12
+ from renderer.automation.templates import resolve_render_template
13
+ from renderer.core.config import Settings
14
+ from renderer.core.models import TaskResult
15
+ from renderer.jobs.manager import JobManager
16
+ from renderer.platform.processor import _analysis_command, _parse_analysis
17
+
18
+
19
+ class N8NAutomationTests(unittest.TestCase):
20
+ def test_template_variables_preserve_json_types(self) -> None:
21
+ result = resolve_render_template(
22
+ {"scenes": "{{scenes}}", "title": "Campaign {{campaign.name}}", "enabled": True},
23
+ {"scenes": [{"start": 0, "duration": 4}], "campaign": {"name": "July"}},
24
+ {"enabled": False},
25
+ )
26
+ self.assertEqual(result["scenes"], [{"start": 0, "duration": 4}])
27
+ self.assertEqual(result["title"], "Campaign July")
28
+ self.assertFalse(result["enabled"])
29
+
30
+ def test_analysis_parsers_return_structured_events(self) -> None:
31
+ silence = _parse_analysis(
32
+ "silence_detect",
33
+ "silence_start: 1.25\nsilence_end: 2.75 | silence_duration: 1.50",
34
+ {},
35
+ )
36
+ black = _parse_analysis("black_detect", "black_start:0 black_end:1.2 black_duration:1.2", {})
37
+ loudness = _parse_analysis(
38
+ "loudness_analyze",
39
+ "I: -20.0 LUFS\nLRA: 4.0 LU\nPeak: -1.2 dBFS",
40
+ {},
41
+ )
42
+ self.assertEqual(silence["events"][0]["duration"], 1.5)
43
+ self.assertEqual(black["events"][0]["end"], 1.2)
44
+ self.assertEqual(loudness["integrated_lufs"], -20.0)
45
+
46
+ def test_hls_command_keeps_playlist_segment_prefix(self) -> None:
47
+ from renderer.platform.processor import PlatformProcessor
48
+
49
+ with tempfile.TemporaryDirectory() as directory:
50
+ settings = Settings(temp_dir=Path(directory) / "temp", exports_dir=Path(directory) / "exports")
51
+ processor = PlatformProcessor(settings)
52
+ command = processor._toolkit_command(
53
+ "hls", Path("input.mp4"), Path(directory) / "stream.zip", {}, Path(directory)
54
+ )
55
+ self.assertTrue(any(value.endswith("hls_segment_%05d.ts") for value in command))
56
+ self.assertTrue(any(value.endswith("hls_playlist.m3u8") for value in command))
57
+
58
+ def test_job_records_artifact_and_group_metadata(self) -> None:
59
+ with tempfile.TemporaryDirectory() as directory:
60
+ root = Path(directory)
61
+ settings = Settings(
62
+ temp_dir=root / "temp",
63
+ exports_dir=root / "exports",
64
+ jobs_dir=root / "jobs",
65
+ storage_dir=root / "storage",
66
+ metadata_cache=root / "temp" / "metadata.json",
67
+ whisper_model_dir=root / "models",
68
+ max_workers=1,
69
+ max_retries=1,
70
+ )
71
+ manager = JobManager(settings)
72
+
73
+ def handler(job_id: str, log) -> TaskResult:
74
+ output = settings.exports_dir / f"{job_id}.json"
75
+ output.write_text("automation", encoding="utf-8")
76
+ return TaskResult(output_path=output, metrics={"task": "test"})
77
+
78
+ job_id = manager.submit_task(handler, metadata={"group_id": "group_test", "variant_name": "shorts"})
79
+ deadline = time.time() + 5
80
+ while time.time() < deadline and manager.get(job_id).state in {"PENDING", "RUNNING"}:
81
+ time.sleep(0.02)
82
+ record = manager.get(job_id)
83
+ manager.executor.shutdown(wait=True)
84
+
85
+ self.assertEqual(record.state, "COMPLETED")
86
+ self.assertEqual(record.metadata["group_id"], "group_test")
87
+ artifact = record.metrics["artifact"]
88
+ self.assertEqual(artifact["sha256"], hashlib.sha256(b"automation").hexdigest())
89
+
90
+ def test_webhook_contains_event_and_signature(self) -> None:
91
+ received: list[dict] = []
92
+
93
+ class Handler(BaseHTTPRequestHandler):
94
+ def do_POST(self) -> None:
95
+ body = self.rfile.read(int(self.headers["Content-Length"]))
96
+ received.append({"body": json.loads(body), "signature": self.headers.get("X-Ava2lon-Signature")})
97
+ self.send_response(204)
98
+ self.end_headers()
99
+
100
+ def log_message(self, format: str, *args) -> None:
101
+ return
102
+
103
+ server = HTTPServer(("127.0.0.1", 0), Handler)
104
+ thread = Thread(target=server.serve_forever, daemon=True)
105
+ thread.start()
106
+ try:
107
+ with tempfile.TemporaryDirectory() as directory:
108
+ root = Path(directory)
109
+ settings = Settings(
110
+ temp_dir=root / "temp",
111
+ exports_dir=root / "exports",
112
+ jobs_dir=root / "jobs",
113
+ storage_dir=root / "storage",
114
+ metadata_cache=root / "temp" / "metadata.json",
115
+ whisper_model_dir=root / "models",
116
+ max_workers=1,
117
+ max_retries=1,
118
+ callback_max_retries=1,
119
+ )
120
+ manager = JobManager(settings)
121
+
122
+ def handler(job_id: str, log) -> TaskResult:
123
+ output = settings.exports_dir / f"{job_id}.txt"
124
+ output.write_text("done", encoding="utf-8")
125
+ return TaskResult(output_path=output)
126
+
127
+ callback = f"http://127.0.0.1:{server.server_port}/callback"
128
+ job_id = manager.submit_task(handler, callback_url=callback)
129
+ deadline = time.time() + 5
130
+ while time.time() < deadline and manager.get(job_id).callback_delivered_at is None:
131
+ time.sleep(0.02)
132
+ record = manager.get(job_id)
133
+ manager.executor.shutdown(wait=True)
134
+ finally:
135
+ server.shutdown()
136
+ server.server_close()
137
+
138
+ self.assertIsNotNone(record.callback_delivered_at)
139
+ self.assertEqual(received[0]["body"]["event"], "task.completed")
140
+ self.assertTrue(received[0]["signature"].startswith("sha256="))
141
+
142
+
143
+ if __name__ == "__main__":
144
+ unittest.main()