Upload 52 files
Browse files- models/tokenizer.py +1 -1
- renderer/automation/__init__.py +7 -0
- renderer/automation/templates.py +96 -0
- renderer/core/config.py +3 -0
- renderer/core/models.py +7 -0
- renderer/core/render_engine.py +2 -0
- renderer/jobs/manager.py +101 -15
- renderer/platform/processor.py +122 -2
- tests/test_n8n_automation.py +144 -0
models/tokenizer.py
CHANGED
|
@@ -80,7 +80,7 @@ class Tokenizer:
|
|
| 80 |
Returns:
|
| 81 |
str: A formatted string describing the monetary value.
|
| 82 |
"""
|
| 83 |
-
m =
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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=
|
| 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 >=
|
| 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 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
)
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]) ->
|
| 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()
|