Add word-by-word motivational captions template

#3
by basyx - opened
app/api/media.py CHANGED
@@ -33,6 +33,18 @@ async def stream_file(path: Path) -> AsyncIterator[bytes]:
33
  yield chunk
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  @router.get("/v1/media/{request_id}/{filename}", name="download_media")
37
  async def download_media(request: Request, request_id: str, filename: str) -> StreamingResponse:
38
  request.state.operation = "media.download"
@@ -57,12 +69,9 @@ async def download_media(request: Request, request_id: str, filename: str) -> St
57
  else:
58
  path = container.cleanup.resolve_download(request_id, filename)
59
  media_type = container.validator.infer_mime(path)
60
- headers = {
61
- "Content-Disposition": f'attachment; filename="{path.name}"',
62
- "Content-Length": str(path.stat().st_size),
63
- "X-Request-ID": request_id,
64
- }
65
- return StreamingResponse(stream_file(path), media_type=media_type, headers=headers)
66
 
67
 
68
  @router.get("/v1/media/signed/{request_id}/{filename}", name="download_signed_media")
@@ -80,15 +89,12 @@ async def download_signed_media(
80
  signature is bound to the exact request ID and filename.
81
  """
82
  request.state.operation = "media.signed_download"
83
- signer = MediaURLSigner(request.app.state.container.settings)
 
84
  if not signer.verify(request_id, filename, expires, signature):
85
  raise NotFoundError("Output file not found")
86
- path = request.app.state.container.cleanup.resolve_download(request_id, filename)
87
- media_type = request.app.state.container.validator.infer_mime(path)
88
- headers = {
89
- "Content-Disposition": f'attachment; filename="{path.name}"',
90
- "Content-Length": str(path.stat().st_size),
91
- "X-Request-ID": request_id,
92
- "Cache-Control": "private, max-age=60",
93
- }
94
- return StreamingResponse(stream_file(path), media_type=media_type, headers=headers)
 
33
  yield chunk
34
 
35
 
36
+ def _download_headers(path: Path, request_id: str) -> dict[str, str]:
37
+ return {
38
+ "Content-Disposition": f'attachment; filename="{path.name}"',
39
+ "Content-Length": str(path.stat().st_size),
40
+ "X-Request-ID": request_id,
41
+ "X-Content-Type-Options": "nosniff",
42
+ # Media links are capabilities or workspace-scoped resources. Avoid
43
+ # intermediary/browser cache retention beyond their authorization.
44
+ "Cache-Control": "private, no-store",
45
+ }
46
+
47
+
48
  @router.get("/v1/media/{request_id}/{filename}", name="download_media")
49
  async def download_media(request: Request, request_id: str, filename: str) -> StreamingResponse:
50
  request.state.operation = "media.download"
 
69
  else:
70
  path = container.cleanup.resolve_download(request_id, filename)
71
  media_type = container.validator.infer_mime(path)
72
+ return StreamingResponse(
73
+ stream_file(path), media_type=media_type, headers=_download_headers(path, request_id)
74
+ )
 
 
 
75
 
76
 
77
  @router.get("/v1/media/signed/{request_id}/{filename}", name="download_signed_media")
 
89
  signature is bound to the exact request ID and filename.
90
  """
91
  request.state.operation = "media.signed_download"
92
+ container = request.app.state.container
93
+ signer = MediaURLSigner(container.settings)
94
  if not signer.verify(request_id, filename, expires, signature):
95
  raise NotFoundError("Output file not found")
96
+ path = container.cleanup.resolve_download(request_id, filename)
97
+ media_type = container.validator.infer_mime(path)
98
+ return StreamingResponse(
99
+ stream_file(path), media_type=media_type, headers=_download_headers(path, request_id)
100
+ )
 
 
 
 
app/core/request_limits.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
4
+
5
+
6
+ class RequestBodyLimitMiddleware:
7
+ """Enforce a hard byte ceiling on HTTP request bodies, including chunked bodies."""
8
+
9
+ def __init__(self, app: ASGIApp, max_bytes: int) -> None:
10
+ self.app = app
11
+ self.max_bytes = max_bytes
12
+
13
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
14
+ if scope["type"] != "http" or scope["method"] in {"GET", "HEAD", "OPTIONS"}:
15
+ await self.app(scope, receive, send)
16
+ return
17
+
18
+ headers = {key.lower(): value for key, value in scope.get("headers", [])}
19
+ declared = headers.get(b"content-length")
20
+ if declared:
21
+ try:
22
+ if int(declared) > self.max_bytes:
23
+ await self._too_large(scope, receive, send)
24
+ return
25
+ except ValueError:
26
+ # Invalid Content-Length is left to the ASGI server/parser; the
27
+ # streamed byte counter below remains authoritative.
28
+ pass
29
+
30
+ received = 0
31
+ exceeded = False
32
+ response_started = False
33
+
34
+ async def limited_receive() -> Message:
35
+ nonlocal received, exceeded
36
+ message = await receive()
37
+ if message["type"] == "http.request":
38
+ received += len(message.get("body", b""))
39
+ if received > self.max_bytes:
40
+ exceeded = True
41
+ # Do not pass an over-limit chunk into a multipart parser or
42
+ # request.body(); it could otherwise spool/read the full body.
43
+ return {"type": "http.disconnect"}
44
+ return message
45
+
46
+ async def guarded_send(message: Message) -> None:
47
+ nonlocal response_started
48
+ if message["type"] == "http.response.start":
49
+ if exceeded and not response_started:
50
+ response_started = True
51
+ await send({
52
+ "type": "http.response.start",
53
+ "status": 413,
54
+ "headers": [
55
+ (b"content-type", b"application/json"),
56
+ (b"cache-control", b"no-store"),
57
+ ],
58
+ })
59
+ return
60
+ response_started = True
61
+ if exceeded:
62
+ if message["type"] == "http.response.body" and not response_started:
63
+ return
64
+ if message["type"] == "http.response.body":
65
+ await send({"type": "http.response.body", "body": b'{"detail":"Request body too large"}', "more_body": False})
66
+ return
67
+ await send(message)
68
+
69
+ try:
70
+ await self.app(scope, limited_receive, guarded_send)
71
+ except Exception:
72
+ if exceeded and not response_started:
73
+ await self._too_large(scope, receive, send)
74
+ return
75
+ raise
76
+
77
+ async def _too_large(self, scope: Scope, receive: Receive, send: Send) -> None:
78
+ body = b'{"detail":"Request body too large"}'
79
+ await send({
80
+ "type": "http.response.start",
81
+ "status": 413,
82
+ "headers": [
83
+ (b"content-type", b"application/json"),
84
+ (b"content-length", str(len(body)).encode("ascii")),
85
+ (b"cache-control", b"no-store"),
86
+ ],
87
+ })
88
+ await send({"type": "http.response.body", "body": body, "more_body": False})
app/operations/mix_audio.py CHANGED
@@ -138,3 +138,57 @@ async def add_audio(
138
  "duration_seconds": duration,
139
  },
140
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  "duration_seconds": duration,
139
  },
140
  )
141
+
142
+
143
+ async def duck_background_music(
144
+ ffmpeg: FFmpegService,
145
+ inputs: Sequence[InputMedia],
146
+ params: dict[str, Any],
147
+ output_dir: Path,
148
+ ) -> OperationResult:
149
+ """Mix background music under the video's narration using side-chain compression."""
150
+ require_inputs(inputs, 2)
151
+ video, music = inputs[0], inputs[1]
152
+ duration = _video_duration(inputs)
153
+ if not _has_audio(video):
154
+ raise InputError("Background-music ducking requires narration audio in the source video")
155
+ music_volume = bounded_number(params, "music_volume", 0.22, 0, 2)
156
+ voice_volume = bounded_number(params, "voice_volume", 1, 0, 4)
157
+ threshold = bounded_number(params, "threshold", 0.03, 0.001, 1)
158
+ ratio = bounded_number(params, "ratio", 8, 1, 20)
159
+ attack = bounded_number(params, "attack", 20, 1, 2000)
160
+ release = bounded_number(params, "release", 300, 10, 5000)
161
+ bitrate = _audio_bitrate(params)
162
+ duration_value = f"{duration:.6f}"
163
+ filters = (
164
+ f"[0:a]apad=whole_dur={duration_value},atrim=duration={duration_value},"
165
+ f"volume={voice_volume:.6g}[voice];"
166
+ f"[1:a]apad=whole_dur={duration_value},atrim=duration={duration_value},"
167
+ f"volume={music_volume:.6g}[music];"
168
+ f"[music][voice]sidechaincompress=threshold={threshold:.6g}:"
169
+ f"ratio={ratio:.6g}:attack={attack:.6g}:release={release:.6g}[ducked];"
170
+ f"[voice][ducked]amix=inputs=2:duration=first:normalize=0[mixed]"
171
+ )
172
+ output = output_path(output_dir, "ducked-audio", "mp4")
173
+ args: list[str | Path] = []
174
+ for media in inputs:
175
+ args.extend(["-i", media.temp_path])
176
+ args.extend([
177
+ "-filter_complex", filters, "-map", "0:v:0", "-map", "[mixed]",
178
+ "-c:v", "copy", "-c:a", "aac", "-b:a", bitrate,
179
+ "-movflags", "+faststart", output,
180
+ ])
181
+ return await execute(
182
+ ffmpeg, args, output, "video.duck_background_music",
183
+ {
184
+ "preserve_video": True,
185
+ "mix_mode": "sidechain_ducking",
186
+ "music_volume": music_volume,
187
+ "voice_volume": voice_volume,
188
+ "threshold": threshold,
189
+ "ratio": ratio,
190
+ "attack_ms": attack,
191
+ "release_ms": release,
192
+ "duration_seconds": duration,
193
+ },
194
+ )
app/services/cleanup.py CHANGED
@@ -58,9 +58,7 @@ class CleanupService:
58
 
59
  async def publish(self, request_id: str, source: Path, filename: str) -> Path:
60
  self._validate_request_id(request_id)
61
- safe_name = Path(filename).name
62
- if not safe_name or safe_name in {".", ".."}:
63
- raise ProcessingError("The generated output filename is invalid")
64
  destination_dir = self.settings.output_dir / request_id
65
  destination_dir.mkdir(parents=True, exist_ok=True)
66
  destination = destination_dir / safe_name
@@ -71,18 +69,9 @@ class CleanupService:
71
  return destination
72
 
73
  async def publish_new(self, request_id: str, source: Path, filename: str) -> Path | None:
74
- """Publish a generated output without replacing an existing file.
75
-
76
- This is used by durable generation-job reconciliation, where a retry
77
- must never overwrite a canonical output created by an earlier worker
78
- attempt. Existing media operations continue to use ``publish`` and
79
- retain their established replacement semantics.
80
- """
81
-
82
  self._validate_request_id(request_id)
83
- safe_name = Path(filename).name
84
- if not safe_name or safe_name in {".", ".."}:
85
- raise ProcessingError("The generated output filename is invalid")
86
  destination_dir = self.settings.output_dir / request_id
87
  destination_dir.mkdir(parents=True, exist_ok=True)
88
  destination = destination_dir / safe_name
@@ -92,22 +81,17 @@ class CleanupService:
92
  @staticmethod
93
  def _publish_new_sync(source: Path, destination: Path) -> bool:
94
  """Atomically claim a destination, with a cross-device fallback."""
95
-
96
  try:
97
  os.link(source, destination)
98
  except FileExistsError:
99
  return False
100
  except OSError:
101
- # ``temp_dir`` and ``output_dir`` can be different mounts. An
102
- # exclusive create still prevents overwrite in that arrangement.
103
  try:
104
  with source.open("rb") as input_stream, destination.open("xb") as output_stream:
105
  shutil.copyfileobj(input_stream, output_stream, length=1024 * 1024)
106
  except FileExistsError:
107
  return False
108
  except OSError:
109
- # Never leave a partial file reachable from the output
110
- # directory when cross-device publication fails.
111
  try:
112
  destination.unlink(missing_ok=True)
113
  except OSError:
@@ -121,11 +105,20 @@ class CleanupService:
121
 
122
  def resolve_download(self, request_id: str, filename: str) -> Path:
123
  self._validate_request_id(request_id)
124
- if filename != Path(filename).name:
 
 
 
 
 
125
  raise NotFoundError("Output file not found")
126
- root = (self.settings.output_dir / request_id).resolve()
127
- candidate = (root / filename).resolve()
128
- if candidate.parent != root or not candidate.is_file():
 
 
 
 
129
  raise NotFoundError("Output file not found")
130
  return candidate
131
 
@@ -133,8 +126,8 @@ class CleanupService:
133
  cutoff = time.time() - self.settings.cleanup_minutes * 60
134
  async with self._lock:
135
  active = self._active.copy()
136
- removed = 0
137
  protected = protected_request_ids or set()
 
138
  for base in (self.settings.temp_dir, self.settings.output_dir):
139
  if not base.exists():
140
  continue
@@ -179,3 +172,18 @@ class CleanupService:
179
  UUID(request_id)
180
  except (ValueError, AttributeError) as exc:
181
  raise NotFoundError("Output file not found") from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  async def publish(self, request_id: str, source: Path, filename: str) -> Path:
60
  self._validate_request_id(request_id)
61
+ safe_name = self._validate_filename(filename)
 
 
62
  destination_dir = self.settings.output_dir / request_id
63
  destination_dir.mkdir(parents=True, exist_ok=True)
64
  destination = destination_dir / safe_name
 
69
  return destination
70
 
71
  async def publish_new(self, request_id: str, source: Path, filename: str) -> Path | None:
72
+ """Publish a generated output without replacing an existing file."""
 
 
 
 
 
 
 
73
  self._validate_request_id(request_id)
74
+ safe_name = self._validate_filename(filename)
 
 
75
  destination_dir = self.settings.output_dir / request_id
76
  destination_dir.mkdir(parents=True, exist_ok=True)
77
  destination = destination_dir / safe_name
 
81
  @staticmethod
82
  def _publish_new_sync(source: Path, destination: Path) -> bool:
83
  """Atomically claim a destination, with a cross-device fallback."""
 
84
  try:
85
  os.link(source, destination)
86
  except FileExistsError:
87
  return False
88
  except OSError:
 
 
89
  try:
90
  with source.open("rb") as input_stream, destination.open("xb") as output_stream:
91
  shutil.copyfileobj(input_stream, output_stream, length=1024 * 1024)
92
  except FileExistsError:
93
  return False
94
  except OSError:
 
 
95
  try:
96
  destination.unlink(missing_ok=True)
97
  except OSError:
 
105
 
106
  def resolve_download(self, request_id: str, filename: str) -> Path:
107
  self._validate_request_id(request_id)
108
+ safe_name = self._validate_filename(filename)
109
+ base = self.settings.output_dir.resolve()
110
+ request_root = base / request_id
111
+ # Do not follow a request-directory symlink out of the configured
112
+ # output root, even if the resulting file happens to exist.
113
+ if request_root.is_symlink() or not request_root.is_dir():
114
  raise NotFoundError("Output file not found")
115
+ candidate_unresolved = request_root / safe_name
116
+ # A published artifact must be a regular file created in this exact
117
+ # request directory, not a symlink to another artifact or host file.
118
+ if candidate_unresolved.is_symlink():
119
+ raise NotFoundError("Output file not found")
120
+ candidate = candidate_unresolved.resolve()
121
+ if candidate.parent != request_root or candidate.name != safe_name or not candidate.is_file():
122
  raise NotFoundError("Output file not found")
123
  return candidate
124
 
 
126
  cutoff = time.time() - self.settings.cleanup_minutes * 60
127
  async with self._lock:
128
  active = self._active.copy()
 
129
  protected = protected_request_ids or set()
130
+ removed = 0
131
  for base in (self.settings.temp_dir, self.settings.output_dir):
132
  if not base.exists():
133
  continue
 
172
  UUID(request_id)
173
  except (ValueError, AttributeError) as exc:
174
  raise NotFoundError("Output file not found") from exc
175
+
176
+ @staticmethod
177
+ def _validate_filename(filename: str) -> str:
178
+ # Keep URL path parameters and Content-Disposition headers unambiguous.
179
+ if (
180
+ not filename
181
+ or filename in {".", ".."}
182
+ or Path(filename).name != filename
183
+ or "/" in filename
184
+ or "\\" in filename
185
+ or any(ord(char) < 32 or ord(char) == 127 for char in filename)
186
+ or '"' in filename
187
+ ):
188
+ raise NotFoundError("Output file not found")
189
+ return filename
app/services/downloader.py CHANGED
@@ -16,27 +16,66 @@ from app.core.exceptions import DownloadError, InputError
16
  from app.services.validator import MediaValidator
17
 
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  class Downloader:
20
- """Size-limited streaming HTTP(S) downloader with SSRF protection."""
21
 
22
  def __init__(self, settings: Settings, validator: MediaValidator) -> None:
23
  self.settings = settings
24
  self.validator = validator
 
25
 
26
- async def download(
27
- self, url: str, destination_dir: Path, filename: str | None = None
28
- ) -> tuple[Path, str]:
29
  current_url = url
30
  timeout = httpx.Timeout(self.settings.download_timeout_seconds, connect=30.0)
31
  headers = {"User-Agent": "MediaAPI/1.0"}
32
- async with httpx.AsyncClient(timeout=timeout, headers=headers) as client:
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  for _ in range(6):
34
  await self.validate_url(current_url)
35
  destination: Path | None = None
36
  try:
37
- async with client.stream(
38
- "GET", current_url, follow_redirects=False
39
- ) as response:
40
  if response.status_code in {301, 302, 303, 307, 308}:
41
  location = response.headers.get("location")
42
  if not location:
@@ -45,21 +84,15 @@ class Downloader:
45
  continue
46
  response.raise_for_status()
47
  declared = self._content_length(response.headers.get("content-length"))
48
- if declared and declared > self.settings.max_upload_size:
49
  raise InputError("Remote media exceeds MAX_UPLOAD_SIZE")
50
- name = self.validator.safe_filename(
51
- filename or self._response_filename(response, current_url),
52
- "download.bin",
53
- )
54
- unique_name = f"{uuid4().hex}_{name}"
55
- destination = destination_dir / unique_name
56
  size = 0
57
  async with aiofiles.open(destination, "wb") as output:
58
  async for chunk in response.aiter_bytes(1024 * 1024):
59
  size += len(chunk)
60
  if size > self.settings.max_upload_size:
61
- await output.close()
62
- destination.unlink(missing_ok=True)
63
  raise InputError("Remote media exceeds MAX_UPLOAD_SIZE")
64
  await output.write(chunk)
65
  mime_type = response.headers.get("content-type", "").split(";", 1)[0]
@@ -69,10 +102,7 @@ class Downloader:
69
  except httpx.HTTPStatusError as exc:
70
  if destination is not None:
71
  destination.unlink(missing_ok=True)
72
- raise DownloadError(
73
- "Remote server rejected the download",
74
- details={"status_code": exc.response.status_code},
75
- ) from exc
76
  except httpx.HTTPError as exc:
77
  if destination is not None:
78
  destination.unlink(missing_ok=True)
@@ -88,28 +118,46 @@ class Downloader:
88
  raise DownloadError("Too many redirects while downloading media")
89
 
90
  async def validate_url(self, value: str) -> None:
91
- parsed = urlparse(value)
92
- if parsed.scheme not in {"http", "https"} or not parsed.hostname:
93
- raise InputError("Only HTTP and HTTPS media URLs are supported")
94
- if parsed.username or parsed.password:
95
- raise InputError("URLs containing credentials are not allowed")
 
 
 
 
 
 
 
 
 
96
  if self.settings.allow_private_urls:
97
- return
 
 
98
  try:
99
- records = await asyncio.to_thread(
100
- socket.getaddrinfo, parsed.hostname, parsed.port or 443, type=socket.SOCK_STREAM
101
- )
102
- except socket.gaierror as exc:
103
  raise DownloadError("The remote hostname could not be resolved") from exc
 
 
 
104
  for record in records:
105
- address = ipaddress.ip_address(record[4][0])
106
- if not address.is_global:
 
 
 
107
  raise InputError("Private, loopback, and link-local URLs are not allowed")
 
 
 
108
 
109
  @staticmethod
110
  def _response_filename(response: httpx.Response, url: str) -> str:
111
  disposition = response.headers.get("content-disposition", "")
112
- match = re.search(r"filename\*?=(?:UTF-8''|\")?([^\";]+)", disposition, re.I)
113
  if match:
114
  return unquote(match.group(1).strip())
115
  path_name = Path(unquote(urlparse(url).path)).name
@@ -117,7 +165,10 @@ class Downloader:
117
 
118
  @staticmethod
119
  def _content_length(value: str | None) -> int | None:
 
 
120
  try:
121
- return int(value) if value else None
122
- except ValueError:
123
  return None
 
 
16
  from app.services.validator import MediaValidator
17
 
18
 
19
+ class _PinnedNetworkBackend:
20
+ """httpcore network backend that dials only DNS-validated IPs.
21
+
22
+ TLS negotiation still receives the original URL hostname from httpcore,
23
+ preserving SNI and certificate hostname verification.
24
+ """
25
+
26
+ def __init__(self, downloader: "Downloader", fallback: object) -> None:
27
+ self.downloader = downloader
28
+ self.fallback = fallback
29
+
30
+ async def connect_tcp(self, host: str, port: int, timeout: float | None = None, local_address: str | None = None, socket_options=None):
31
+ pinned = self.downloader._pinned.get((host.lower().rstrip("."), port))
32
+ target = pinned[0] if pinned else host
33
+ return await self.fallback.connect_tcp(
34
+ host=target,
35
+ port=port,
36
+ timeout=timeout,
37
+ local_address=local_address,
38
+ socket_options=socket_options,
39
+ )
40
+
41
+ async def connect_unix_socket(self, path: str, timeout: float | None = None, socket_options=None):
42
+ return await self.fallback.connect_unix_socket(path, timeout=timeout, socket_options=socket_options)
43
+
44
+ async def sleep(self, seconds: float) -> None:
45
+ await self.fallback.sleep(seconds)
46
+
47
+
48
  class Downloader:
49
+ """Size-limited streaming HTTP(S) downloader with connection-pinned SSRF protection."""
50
 
51
  def __init__(self, settings: Settings, validator: MediaValidator) -> None:
52
  self.settings = settings
53
  self.validator = validator
54
+ self._pinned: dict[tuple[str, int], tuple[str, ...]] = {}
55
 
56
+ async def download(self, url: str, destination_dir: Path, filename: str | None = None) -> tuple[Path, str]:
 
 
57
  current_url = url
58
  timeout = httpx.Timeout(self.settings.download_timeout_seconds, connect=30.0)
59
  headers = {"User-Agent": "MediaAPI/1.0"}
60
+ transport = httpx.AsyncHTTPTransport(retries=0)
61
+ # HTTPX 0.28 uses httpcore's network backend for socket creation. The
62
+ # wrapper pins the TCP destination while httpcore retains the URL host
63
+ # for TLS SNI, certificate checks, and the HTTP Host header.
64
+ try:
65
+ from httpcore._backends.auto import AutoBackend
66
+ transport._pool._network_backend = _PinnedNetworkBackend(self, AutoBackend())
67
+ except (AttributeError, ImportError) as exc:
68
+ raise DownloadError("Secure connection-pinned downloads are unavailable in this runtime") from exc
69
+
70
+ async with httpx.AsyncClient(
71
+ timeout=timeout, headers=headers, follow_redirects=False,
72
+ trust_env=False, transport=transport,
73
+ ) as client:
74
  for _ in range(6):
75
  await self.validate_url(current_url)
76
  destination: Path | None = None
77
  try:
78
+ async with client.stream("GET", current_url, follow_redirects=False) as response:
 
 
79
  if response.status_code in {301, 302, 303, 307, 308}:
80
  location = response.headers.get("location")
81
  if not location:
 
84
  continue
85
  response.raise_for_status()
86
  declared = self._content_length(response.headers.get("content-length"))
87
+ if declared is not None and declared > self.settings.max_upload_size:
88
  raise InputError("Remote media exceeds MAX_UPLOAD_SIZE")
89
+ name = self.validator.safe_filename(filename or self._response_filename(response, current_url), "download.bin")
90
+ destination = destination_dir / f"{uuid4().hex}_{name}"
 
 
 
 
91
  size = 0
92
  async with aiofiles.open(destination, "wb") as output:
93
  async for chunk in response.aiter_bytes(1024 * 1024):
94
  size += len(chunk)
95
  if size > self.settings.max_upload_size:
 
 
96
  raise InputError("Remote media exceeds MAX_UPLOAD_SIZE")
97
  await output.write(chunk)
98
  mime_type = response.headers.get("content-type", "").split(";", 1)[0]
 
102
  except httpx.HTTPStatusError as exc:
103
  if destination is not None:
104
  destination.unlink(missing_ok=True)
105
+ raise DownloadError("Remote server rejected the download", details={"status_code": exc.response.status_code}) from exc
 
 
 
106
  except httpx.HTTPError as exc:
107
  if destination is not None:
108
  destination.unlink(missing_ok=True)
 
118
  raise DownloadError("Too many redirects while downloading media")
119
 
120
  async def validate_url(self, value: str) -> None:
121
+ try:
122
+ parsed = urlparse(value)
123
+ if (
124
+ parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname
125
+ or parsed.username is not None or parsed.password is not None
126
+ or any(char.isspace() for char in parsed.hostname)
127
+ ):
128
+ raise InputError("Only credential-free HTTP and HTTPS media URLs are supported")
129
+ port = parsed.port
130
+ except ValueError as exc:
131
+ raise InputError("Media URL contains an invalid host or port") from exc
132
+
133
+ hostname = parsed.hostname.encode("idna").decode("ascii").lower().rstrip(".")
134
+ target_port = port or (443 if parsed.scheme.lower() == "https" else 80)
135
  if self.settings.allow_private_urls:
136
+ # Explicit opt-in permits private hosts; still pin the current DNS
137
+ # result to prevent a second, inconsistent lookup by the HTTP client.
138
+ pass
139
  try:
140
+ records = await asyncio.to_thread(socket.getaddrinfo, hostname, target_port, type=socket.SOCK_STREAM)
141
+ except (socket.gaierror, OSError) as exc:
 
 
142
  raise DownloadError("The remote hostname could not be resolved") from exc
143
+ if not records:
144
+ raise DownloadError("The remote hostname has no usable address")
145
+ addresses: list[str] = []
146
  for record in records:
147
+ try:
148
+ address = ipaddress.ip_address(record[4][0].split("%", 1)[0])
149
+ except (ValueError, IndexError, TypeError) as exc:
150
+ raise DownloadError("The remote hostname resolved to an invalid address") from exc
151
+ if not self.settings.allow_private_urls and not address.is_global:
152
  raise InputError("Private, loopback, and link-local URLs are not allowed")
153
+ if str(address) not in addresses:
154
+ addresses.append(str(address))
155
+ self._pinned[(hostname, target_port)] = tuple(addresses)
156
 
157
  @staticmethod
158
  def _response_filename(response: httpx.Response, url: str) -> str:
159
  disposition = response.headers.get("content-disposition", "")
160
+ match = re.search(r"filename\\*?=(?:UTF-8''|\\")?([^\\\";]+)", disposition, re.I)
161
  if match:
162
  return unquote(match.group(1).strip())
163
  path_name = Path(unquote(urlparse(url).path)).name
 
165
 
166
  @staticmethod
167
  def _content_length(value: str | None) -> int | None:
168
+ if value is None:
169
+ return None
170
  try:
171
+ parsed = int(value)
172
+ except (TypeError, ValueError):
173
  return None
174
+ return parsed if parsed >= 0 else None
app/services/media_service.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import json
 
4
  import os
5
  import time
6
  from collections.abc import Awaitable, Callable, Sequence
@@ -8,6 +9,7 @@ from pathlib import Path
8
  from typing import Any
9
 
10
  from app.core.config import Settings
 
11
  from app.core.logger import get_logger
12
  from app.core.response import SuccessResponse
13
  from app.models.media import InputMedia, OperationResult, ResolvedRequest
@@ -244,6 +246,8 @@ class MediaProcessor:
244
  output_size = 0
245
  canonical_asset_id = None
246
  if result.path is not None:
 
 
247
  published = await self.cleanup.publish(
248
  resolved.request_id, result.path, result.filename or result.path.name
249
  )
@@ -291,6 +295,75 @@ class MediaProcessor:
291
  metadata=metadata,
292
  )
293
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  @staticmethod
295
  def _optional_int(
296
  value: Any, name: str, minimum: int, maximum: int
 
1
  from __future__ import annotations
2
 
3
  import json
4
+ import math
5
  import os
6
  import time
7
  from collections.abc import Awaitable, Callable, Sequence
 
9
  from typing import Any
10
 
11
  from app.core.config import Settings
12
+ from app.core.exceptions import ProcessingError
13
  from app.core.logger import get_logger
14
  from app.core.response import SuccessResponse
15
  from app.models.media import InputMedia, OperationResult, ResolvedRequest
 
246
  output_size = 0
247
  canonical_asset_id = None
248
  if result.path is not None:
249
+ output_report = await self.validate_output_result(result)
250
+ result.metadata = {**result.metadata, "output_validation": output_report}
251
  published = await self.cleanup.publish(
252
  resolved.request_id, result.path, result.filename or result.path.name
253
  )
 
295
  metadata=metadata,
296
  )
297
 
298
+ async def validate_output_result(self, result: OperationResult) -> dict[str, Any]:
299
+ """Validate every published media artifact, regardless of which API produced it."""
300
+ path = result.path
301
+ if path is None:
302
+ return {"valid": True, "file_artifact": False}
303
+ if not path.is_file():
304
+ raise ProcessingError("Output artifact is missing")
305
+ try:
306
+ size = path.stat().st_size
307
+ except OSError as exc:
308
+ raise ProcessingError("Unable to inspect output artifact") from exc
309
+ if size <= 0:
310
+ raise ProcessingError("Output artifact is empty")
311
+
312
+ mime_type = result.mime_type or self.validator.infer_mime(path)
313
+ report: dict[str, Any] = {
314
+ "valid": True,
315
+ "file_artifact": True,
316
+ "size_bytes": size,
317
+ "mime_type": mime_type,
318
+ "probed": False,
319
+ "container": None,
320
+ "duration_seconds": None,
321
+ "video_stream_count": None,
322
+ "audio_stream_count": None,
323
+ "resolution": None,
324
+ }
325
+ if not mime_type.startswith(("audio/", "video/", "image/")):
326
+ return report
327
+
328
+ try:
329
+ probe = await self.ffprobe.probe(path)
330
+ except Exception as exc:
331
+ if isinstance(exc, ProcessingError):
332
+ raise
333
+ raise ProcessingError("Output media failed integrity validation") from exc
334
+
335
+ video_streams = probe.get("video_streams") or []
336
+ audio_streams = probe.get("audio_streams") or []
337
+ if mime_type.startswith("video/") and not video_streams:
338
+ raise ProcessingError("Output is declared as video but contains no video stream")
339
+ if mime_type.startswith("audio/") and not audio_streams:
340
+ raise ProcessingError("Output is declared as audio but contains no audio stream")
341
+ if mime_type.startswith("image/") and not (video_streams or audio_streams):
342
+ raise ProcessingError("Output is declared as an image but contains no decodable stream")
343
+ try:
344
+ self.validator.validate_probe(probe)
345
+ except Exception as exc:
346
+ raise ProcessingError("Output media failed integrity validation") from exc
347
+
348
+ duration = probe.get("duration")
349
+ if mime_type.startswith(("audio/", "video/")):
350
+ try:
351
+ valid_duration = duration is not None and math.isfinite(float(duration)) and float(duration) > 0
352
+ except (TypeError, ValueError):
353
+ valid_duration = False
354
+ if not valid_duration:
355
+ raise ProcessingError("Output media has an invalid duration")
356
+
357
+ report.update({
358
+ "probed": True,
359
+ "container": probe.get("container"),
360
+ "duration_seconds": duration,
361
+ "video_stream_count": len(video_streams),
362
+ "audio_stream_count": len(audio_streams),
363
+ "resolution": probe.get("resolution"),
364
+ })
365
+ return report
366
+
367
  @staticmethod
368
  def _optional_int(
369
  value: Any, name: str, minimum: int, maximum: int
app/services/media_url_signer.py CHANGED
@@ -24,7 +24,11 @@ class MediaURLSigner:
24
  key = self._key()
25
  if key is None:
26
  return None
27
- expires = int(time.time()) + (ttl or self.settings.media_signed_url_ttl_seconds)
 
 
 
 
28
  message = f"{request_id}\n{filename}\n{expires}".encode("utf-8")
29
  digest = hmac.new(key, message, hashlib.sha256).digest()
30
  token = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
@@ -33,8 +37,15 @@ class MediaURLSigner:
33
 
34
  def verify(self, request_id: str, filename: str, expires: int, signature: str) -> bool:
35
  key = self._key()
36
- if key is None or expires < int(time.time()):
 
 
 
 
 
37
  return False
38
  message = f"{request_id}\n{filename}\n{expires}".encode("utf-8")
39
- expected = base64.urlsafe_b64encode(hmac.new(key, message, hashlib.sha256).digest()).decode("ascii").rstrip("=")
 
 
40
  return hmac.compare_digest(expected, signature)
 
24
  key = self._key()
25
  if key is None:
26
  return None
27
+ requested_ttl = self.settings.media_signed_url_ttl_seconds if ttl is None else ttl
28
+ # A caller may request a shorter link, but cannot mint a link longer
29
+ # than the configured maximum lifetime.
30
+ lifetime = max(1, min(int(requested_ttl), self.settings.media_signed_url_ttl_seconds))
31
+ expires = int(time.time()) + lifetime
32
  message = f"{request_id}\n{filename}\n{expires}".encode("utf-8")
33
  digest = hmac.new(key, message, hashlib.sha256).digest()
34
  token = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
 
37
 
38
  def verify(self, request_id: str, filename: str, expires: int, signature: str) -> bool:
39
  key = self._key()
40
+ now = int(time.time())
41
+ if (
42
+ key is None
43
+ or expires <= now
44
+ or expires > now + self.settings.media_signed_url_ttl_seconds
45
+ ):
46
  return False
47
  message = f"{request_id}\n{filename}\n{expires}".encode("utf-8")
48
+ expected = base64.urlsafe_b64encode(
49
+ hmac.new(key, message, hashlib.sha256).digest()
50
+ ).decode("ascii").rstrip("=")
51
  return hmac.compare_digest(expected, signature)
app/services/validator.py CHANGED
@@ -9,44 +9,11 @@ from app.core.config import Settings
9
  from app.core.exceptions import InputError
10
 
11
  MEDIA_EXTENSIONS = {
12
- ".3gp",
13
- ".aac",
14
- ".aiff",
15
- ".apng",
16
- ".avi",
17
- ".bin",
18
- ".bmp",
19
- ".flac",
20
- ".gif",
21
- ".heic",
22
- ".jpeg",
23
- ".jpg",
24
- ".m4a",
25
- ".m4v",
26
- ".mkv",
27
- ".m3u",
28
- ".m3u8",
29
- ".mov",
30
- ".mp3",
31
- ".mp4",
32
- ".mpeg",
33
- ".mpg",
34
- ".oga",
35
- ".ogg",
36
- ".opus",
37
- ".png",
38
- ".svg",
39
- ".tif",
40
- ".tiff",
41
- ".ts",
42
- ".vtt",
43
- ".wav",
44
- ".webm",
45
- ".webp",
46
- ".wmv",
47
- ".srt",
48
- ".ass",
49
- ".ssa",
50
  }
51
 
52
 
@@ -55,8 +22,16 @@ class MediaValidator:
55
  self.settings = settings
56
 
57
  def safe_filename(self, filename: str | None, fallback: str = "input.bin") -> str:
58
- name = Path(filename or fallback).name.replace("\x00", "")
59
- if not name or name in {".", ".."}:
 
 
 
 
 
 
 
 
60
  raise InputError("Invalid filename")
61
  return name[:240]
62
 
@@ -72,22 +47,15 @@ class MediaValidator:
72
  if suffix and suffix not in MEDIA_EXTENSIONS:
73
  raise InputError("Unsupported media file extension", details={"extension": suffix})
74
  normalized_mime = mime_type.split(";", 1)[0].lower()
75
- allowed = normalized_mime.startswith(
76
- ("audio/", "video/", "image/", "text/")
77
- ) or normalized_mime in {
78
- "application/octet-stream",
79
- "application/x-subrip",
80
- "application/vnd.apple.mpegurl",
81
- "application/x-mpegurl",
82
  }
83
  if normalized_mime and not allowed:
84
  raise InputError("Unsupported media MIME type", details={"mime_type": mime_type})
85
 
86
  def validate_probe(self, metadata: dict[str, Any]) -> None:
87
- streams = [
88
- *(metadata.get("video_streams") or []),
89
- *(metadata.get("audio_streams") or []),
90
- ]
91
  if not streams:
92
  raise InputError("The input has no decodable video or audio stream")
93
  for stream in streams:
 
9
  from app.core.exceptions import InputError
10
 
11
  MEDIA_EXTENSIONS = {
12
+ ".3gp", ".aac", ".aiff", ".apng", ".avi", ".bin", ".bmp", ".flac",
13
+ ".gif", ".heic", ".jpeg", ".jpg", ".m4a", ".m4v", ".mkv", ".m3u",
14
+ ".m3u8", ".mov", ".mp3", ".mp4", ".mpeg", ".mpg", ".oga", ".ogg",
15
+ ".opus", ".png", ".svg", ".tif", ".tiff", ".ts", ".vtt", ".wav",
16
+ ".webm", ".webp", ".wmv", ".srt", ".ass", ".ssa",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  }
18
 
19
 
 
22
  self.settings = settings
23
 
24
  def safe_filename(self, filename: str | None, fallback: str = "input.bin") -> str:
25
+ # Treat both POSIX and Windows separators as path delimiters regardless
26
+ # of the server OS; never allow a client path to become a stored name.
27
+ raw = str(filename or fallback).replace("\\", "/")
28
+ name = raw.rsplit("/", 1)[-1].replace("\x00", "")
29
+ if (
30
+ not name
31
+ or name in {".", ".."}
32
+ or any(ord(char) < 32 or ord(char) == 127 for char in name)
33
+ or '"' in name
34
+ ):
35
  raise InputError("Invalid filename")
36
  return name[:240]
37
 
 
47
  if suffix and suffix not in MEDIA_EXTENSIONS:
48
  raise InputError("Unsupported media file extension", details={"extension": suffix})
49
  normalized_mime = mime_type.split(";", 1)[0].lower()
50
+ allowed = normalized_mime.startswith(("audio/", "video/", "image/", "text/")) or normalized_mime in {
51
+ "application/octet-stream", "application/x-subrip",
52
+ "application/vnd.apple.mpegurl", "application/x-mpegurl",
 
 
 
 
53
  }
54
  if normalized_mime and not allowed:
55
  raise InputError("Unsupported media MIME type", details={"mime_type": mime_type})
56
 
57
  def validate_probe(self, metadata: dict[str, Any]) -> None:
58
+ streams = [*(metadata.get("video_streams") or []), *(metadata.get("audio_streams") or [])]
 
 
 
59
  if not streams:
60
  raise InputError("The input has no decodable video or audio stream")
61
  for stream in streams:
app/templates/categories/motivation/templates.yaml CHANGED
@@ -1,4 +1,42 @@
1
  templates:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  - id: motivational_video
3
  name: Motivational Video
4
  category: motivation
 
1
  templates:
2
+ - id: motivational_word_captions
3
+ name: Motivational Video — Word-by-Word Captions
4
+ category: motivation
5
+ description: Burn compact, individually timed narration words onto a vertical motivational video using Whisper word timestamps.
6
+ author: Enterprise Media API
7
+ version: 1
8
+ tags: [motivation, captions, word-by-word, vertical, narration]
9
+ estimated_runtime: medium
10
+ supported_inputs: [video, url]
11
+ supported_outputs: [mp4]
12
+ parameters:
13
+ model: {type: string, default: tiny, enum: [tiny, base, small]}
14
+ crf: {type: integer, default: 23, minimum: 0, maximum: 51}
15
+ font_size: {type: integer, default: 32, minimum: 16, maximum: 40}
16
+ caption_margin: {type: integer, default: 260, minimum: 40, maximum: 400}
17
+ pipeline:
18
+ - operation: resize
19
+ width: 1080
20
+ height: 1920
21
+ fit: cover
22
+ save_as: vertical_video
23
+ - operation: transcribe
24
+ inputs: [original]
25
+ model: "{{ model }}"
26
+ output_format: srt
27
+ word_timestamps: true
28
+ caption_mode: word
29
+ save_as: captions
30
+ - operation: burn_subtitles
31
+ inputs: [artifact:vertical_video, artifact:captions]
32
+ style: "FontName=Arial,FontSize={{ font_size }},Bold=1,PrimaryColour=&H0000FFFF,Outline=2,Shadow=0,Alignment=2,MarginV={{ caption_margin }}"
33
+ - {operation: normalize_video}
34
+ - {operation: compress, crf: "{{ crf }}", preset: veryfast, format: mp4}
35
+ output: {format: mp4}
36
+ examples:
37
+ - input: {url: "https://example.com/motivation.mp4"}
38
+ parameters: {model: tiny, crf: 23, font_size: 32, caption_margin: 260}
39
+
40
  - id: motivational_video
41
  name: Motivational Video
42
  category: motivation
app/templates/categories/podcast/templates.yaml CHANGED
@@ -97,3 +97,31 @@ templates:
97
  - {url: "https://example.com/waveform.png"}
98
  - {url: "https://example.com/podcast.mp3"}
99
  parameters: {duration: 60}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  - {url: "https://example.com/waveform.png"}
98
  - {url: "https://example.com/podcast.mp3"}
99
  parameters: {duration: 60}
100
+
101
+ - id: voiceover_music_ducking
102
+ name: Voiceover + Background Music Ducking
103
+ category: podcast
104
+ description: Mix background music beneath narration with automatic side-chain ducking and preserve the source video stream.
105
+ author: Enterprise Media API
106
+ version: 1
107
+ tags: [audio, ducking, voiceover, background-music, podcast, motivation]
108
+ estimated_runtime: medium
109
+ supported_inputs: [video, audio]
110
+ supported_outputs: [mp4]
111
+ parameters:
112
+ music_volume: {type: number, default: 0.22, minimum: 0, maximum: 2}
113
+ voice_volume: {type: number, default: 1, minimum: 0, maximum: 4}
114
+ threshold: {type: number, default: 0.03, minimum: 0.001, maximum: 1}
115
+ ratio: {type: number, default: 8, minimum: 1, maximum: 20}
116
+ attack: {type: number, default: 20, minimum: 1, maximum: 2000}
117
+ release: {type: number, default: 300, minimum: 10, maximum: 5000}
118
+ audio_bitrate: {type: string, default: "192k"}
119
+ pipeline:
120
+ - operation: duck_audio
121
+ inputs: [original, original:1]
122
+ output: {format: mp4}
123
+ examples:
124
+ - inputs:
125
+ - {url: "https://example.com/narrated-video.mp4"}
126
+ - {url: "https://example.com/background-music.mp3"}
127
+ parameters: {music_volume: 0.22, ratio: 8, attack: 20, release: 300}
app/templates/executor.py CHANGED
@@ -37,6 +37,7 @@ from app.operations.extract_audio import (
37
  set_volume,
38
  )
39
  from app.operations.merge import merge_audio, merge_video
 
40
  from app.operations.resize import pad_video, resize_image, resize_video, scale_video
41
  from app.operations.rotate import (
42
  change_bitrate,
@@ -137,6 +138,7 @@ OPERATION_BINDINGS: Mapping[str, OperationBinding] = MappingProxyType(
137
  "normalize_audio": _ffmpeg(normalize_audio),
138
  "trim_audio": _ffmpeg(trim_audio),
139
  "merge_audio": _ffmpeg(merge_audio, input_mode="all"),
 
140
  "concat_audio": _ffmpeg(concat_audio, input_mode="all"),
141
  "fade_audio": _ffmpeg(fade_audio),
142
  "volume": _ffmpeg(set_volume),
@@ -270,11 +272,12 @@ class TemplateExecutor:
270
  if not operations:
271
  raise TemplateExecutionError("All template pipeline operations were disabled")
272
  self._validate_output(prepared, current)
 
273
  result = OperationResult(
274
  path=current.temp_path,
275
  filename=prepared.output.filename or current.filename,
276
  mime_type=current.mime_type,
277
- metadata=current.metadata,
278
  )
279
  response = await self.processor.finish_result(
280
  resolved,
@@ -409,6 +412,14 @@ class TemplateExecutor:
409
 
410
  @staticmethod
411
  def _validate_output(prepared: PreparedTemplate, media: InputMedia) -> None:
 
 
 
 
 
 
 
 
412
  if prepared.output.filename:
413
  filename = prepared.output.filename
414
  if Path(filename).name != filename or len(filename) > 255:
@@ -424,6 +435,40 @@ class TemplateExecutor:
424
  details={"expected": expected, "actual": actual},
425
  )
426
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  @staticmethod
428
  def _log_data(
429
  prepared: PreparedTemplate | None,
 
37
  set_volume,
38
  )
39
  from app.operations.merge import merge_audio, merge_video
40
+ from app.operations.mix_audio import duck_background_music
41
  from app.operations.resize import pad_video, resize_image, resize_video, scale_video
42
  from app.operations.rotate import (
43
  change_bitrate,
 
138
  "normalize_audio": _ffmpeg(normalize_audio),
139
  "trim_audio": _ffmpeg(trim_audio),
140
  "merge_audio": _ffmpeg(merge_audio, input_mode="all"),
141
+ "duck_audio": _ffmpeg(duck_background_music, input_mode="all"),
142
  "concat_audio": _ffmpeg(concat_audio, input_mode="all"),
143
  "fade_audio": _ffmpeg(fade_audio),
144
  "volume": _ffmpeg(set_volume),
 
272
  if not operations:
273
  raise TemplateExecutionError("All template pipeline operations were disabled")
274
  self._validate_output(prepared, current)
275
+ output_validation = self._output_validation(current)
276
  result = OperationResult(
277
  path=current.temp_path,
278
  filename=prepared.output.filename or current.filename,
279
  mime_type=current.mime_type,
280
+ metadata={**current.metadata, "output_validation": output_validation},
281
  )
282
  response = await self.processor.finish_result(
283
  resolved,
 
412
 
413
  @staticmethod
414
  def _validate_output(prepared: PreparedTemplate, media: InputMedia) -> None:
415
+ if not media.temp_path.is_file():
416
+ raise TemplateExecutionError("Template output artifact is missing")
417
+ try:
418
+ size = media.temp_path.stat().st_size
419
+ except OSError as exc:
420
+ raise TemplateExecutionError("Unable to inspect template output artifact") from exc
421
+ if size <= 0:
422
+ raise TemplateExecutionError("Template output artifact is empty")
423
  if prepared.output.filename:
424
  filename = prepared.output.filename
425
  if Path(filename).name != filename or len(filename) > 255:
 
435
  details={"expected": expected, "actual": actual},
436
  )
437
 
438
+ @staticmethod
439
+ def _output_validation(media: InputMedia) -> dict[str, object]:
440
+ """Summarize checks performed on the final artifact for automation clients."""
441
+ metadata = media.metadata
442
+ size = media.temp_path.stat().st_size
443
+ video_streams = metadata.get("video_streams")
444
+ audio_streams = metadata.get("audio_streams")
445
+ if media.mime_type.startswith("video/") and video_streams is not None and not video_streams:
446
+ raise TemplateExecutionError(
447
+ "Template output is declared as video but contains no video stream"
448
+ )
449
+ if media.mime_type.startswith("audio/") and audio_streams is not None and not audio_streams:
450
+ raise TemplateExecutionError(
451
+ "Template output is declared as audio but contains no audio stream"
452
+ )
453
+ duration = metadata.get("duration")
454
+ if media.mime_type.startswith(("video/", "audio/")) and duration is not None:
455
+ try:
456
+ valid_duration = float(duration) > 0
457
+ except (TypeError, ValueError):
458
+ valid_duration = False
459
+ if not valid_duration:
460
+ raise TemplateExecutionError("Template output has an invalid media duration")
461
+ return {
462
+ "valid": True,
463
+ "size_bytes": size,
464
+ "mime_type": media.mime_type,
465
+ "container": metadata.get("container"),
466
+ "duration_seconds": duration,
467
+ "video_stream_count": len(video_streams) if isinstance(video_streams, list) else None,
468
+ "audio_stream_count": len(audio_streams) if isinstance(audio_streams, list) else None,
469
+ "resolution": metadata.get("resolution"),
470
+ }
471
+
472
  @staticmethod
473
  def _log_data(
474
  prepared: PreparedTemplate | None,
docs/api-wide-output-validation.md ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API-wide media output validation
2
+
3
+ Every file-producing operation that reaches `MediaProcessor.finish_result` now validates its final artifact before publication. This covers the shared execution path used by the standalone video, audio, image, conversion, and template APIs.
4
+
5
+ ## Validation sequence
6
+
7
+ 1. Verify that the output path exists and is a regular file.
8
+ 2. Reject zero-byte artifacts.
9
+ 3. For audio, video, and image MIME types, run FFprobe on the actual output file.
10
+ 4. Require a video stream for video outputs, an audio stream for audio outputs, and at least one decodable stream for image outputs.
11
+ 5. Apply the shared media-probe constraints and require a finite, positive duration for audio/video outputs.
12
+ 6. Publish only after validation succeeds.
13
+
14
+ A failed check raises a processing error; the file is not published as a successful response.
15
+
16
+ ## Response metadata
17
+
18
+ Successful file responses include `metadata.output_validation`:
19
+
20
+ - `valid`, `file_artifact`, `size_bytes`, and `mime_type`
21
+ - `probed` indicates whether FFprobe ran
22
+ - `container`, `duration_seconds`, `video_stream_count`, `audio_stream_count`, and `resolution` are populated when probing is applicable and successful
23
+
24
+ Text-like outputs such as SRT/VTT and JSON artifacts are checked for file existence and non-zero size, but are not sent through FFprobe. Responses without a file artifact are not subject to file-output validation.
25
+
26
+ ## Operational note
27
+
28
+ FFprobe validation adds work after each render. The output must be fully readable by FFprobe before the API publishes it. Automated tests and a live render should be run before merging this change request.
docs/artifact-download-hardening.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Artifact download hardening
2
+
3
+ MediaRouter download paths are treated as untrusted locators, not permissions.
4
+
5
+ The download boundary now:
6
+ - validates request IDs as UUIDs and filenames as single safe path components;
7
+ - rejects slash, backslash, control characters, quotes, traversal names, and empty filenames;
8
+ - rejects symlinked request directories and symlinked artifact files before resolving a published path;
9
+ - preserves workspace ownership and content-digest verification for authenticated direct downloads;
10
+ - verifies signed URLs with HMAC, rejects expired links, and caps requested link lifetime at `MEDIA_SIGNED_URL_TTL_SECONDS`;
11
+ - sends `Cache-Control: private, no-store` and `X-Content-Type-Options: nosniff` on direct and signed download responses.
12
+
13
+ Signed URLs remain bearer capabilities: anyone who receives a valid URL can use it until it expires. Do not put signed URLs in public logs, analytics, or long-lived messages.
14
+
15
+ ## Verification status
16
+
17
+ Regression tests were added for traversal and symlink rejection, expired signatures, and TTL limits. They have not been executed in the remote environment; previous hosted test execution was blocked by a payment-required response. Treat this as source-level hardening pending CI and deployed integration tests.
docs/input-ingestion-security.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Input-ingestion security hardening
2
+
3
+ The input pipeline now applies these protections at the resolver boundary:
4
+
5
+ - URL fetching accepts only HTTP(S), rejects URL credentials and malformed ports, resolves using the scheme's correct default port, and rejects hostnames with any non-global DNS answer when private URLs are disabled.
6
+ - Redirects are handled manually, with each destination revalidated before the next request.
7
+ - HTTPX environment proxy settings are not inherited for remote media fetches, avoiding an ambient proxy changing the destination semantics.
8
+ - Remote downloads remain streamed to disk with a hard byte limit, including responses that omit or misstate Content-Length. Partial files are removed on download/validation failures.
9
+ - Client filenames are normalized across POSIX and Windows separators and reject control characters and quote characters that can corrupt response headers.
10
+
11
+ ## Deployment notes and remaining limits
12
+
13
+ - `ALLOW_PRIVATE_URLS=true` intentionally disables the private-address block; only enable it in a trusted, isolated environment.
14
+ - DNS validation and the HTTP client's subsequent connection resolution are separate operations. For hostile URL-fetching environments, production-grade SSRF protection should additionally pin the connection to a validated IP while preserving TLS hostname verification, or enforce egress restrictions at the network layer.
15
+ - Multipart parsing can spool uploads before application-level per-file limits are applied. Enforce request-body limits at the reverse proxy/ASGI boundary as well as in the resolver.
16
+ - Source-level regression tests were added but have not been run in the hosted environment. Deployment and live SSRF tests remain unverified.
docs/template-output-validation.md ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Template final-output validation
2
+
3
+ Template runs validate the final artifact before publishing it to the media download layer.
4
+
5
+ ## Checks
6
+
7
+ - The final artifact exists and has a non-zero file size.
8
+ - The artifact's extension matches the template's declared output format (except `source`, which preserves the input format).
9
+ - Probeable media is passed through the existing FFprobe validation path. The probe rejects outputs without decodable audio/video streams.
10
+ - When stream metadata is available, the final check confirms that audio and video MIME types have their corresponding stream and that reported audio/video duration is positive.
11
+
12
+ A failed check raises a template execution error, so the output is not published as a successful result.
13
+
14
+ ## Response metadata
15
+
16
+ Successful template responses include an `output_validation` object in `metadata`, for example:
17
+
18
+ ```json
19
+ {
20
+ "output_validation": {
21
+ "valid": true,
22
+ "size_bytes": 4821093,
23
+ "mime_type": "video/mp4",
24
+ "container": "mov,mp4,m4a,3gp,3g2,mj2",
25
+ "duration_seconds": 31.25,
26
+ "video_stream_count": 1,
27
+ "audio_stream_count": 1,
28
+ "resolution": {
29
+ "width": 1080,
30
+ "height": 1920
31
+ }
32
+ }
33
+ }
34
+ ```
35
+
36
+ Stream counts, container, duration, and resolution can be `null` when the output type is not probeable or a metadata field is unavailable. The file size and MIME type are always reported for a valid file artifact.
37
+
38
+ This validation is applied to template pipeline outputs; it does not imply that every standalone media endpoint has the same response metadata.
docs/voiceover-music-ducking.md ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Voiceover + background music ducking
2
+
3
+ The `voiceover_music_ducking` template mixes a video containing narration with a separate background-music file. FFmpeg side-chain compression lowers the music while narration is active, then mixes the narration and ducked music into the output. The source video stream is copied without re-encoding.
4
+
5
+ ## Endpoint
6
+
7
+ `POST https://basyx-mediarouter.hf.space/v1/templates/run`
8
+
9
+ ## Request example
10
+
11
+ ```json
12
+ {
13
+ "template": "voiceover_music_ducking",
14
+ "inputs": [
15
+ {
16
+ "url": "https://example.com/narrated-video.mp4"
17
+ },
18
+ {
19
+ "url": "https://example.com/background-music.mp3"
20
+ }
21
+ ],
22
+ "parameters": {
23
+ "music_volume": 0.22,
24
+ "voice_volume": 1,
25
+ "threshold": 0.03,
26
+ "ratio": 8,
27
+ "attack": 20,
28
+ "release": 300,
29
+ "audio_bitrate": "192k"
30
+ }
31
+ }
32
+ ```
33
+
34
+ Use the standard MediaRouter input contract for multiple media inputs; if your client uses a nested `input` object, supply the equivalent two-item input collection supported by the shared resolver.
35
+
36
+ ## Parameters
37
+
38
+ | Parameter | Default | Range | Purpose |
39
+ |---|---:|---:|---|
40
+ | `music_volume` | 0.22 | 0–2 | Linear gain applied to the music before ducking |
41
+ | `voice_volume` | 1 | 0–4 | Linear gain applied to narration |
42
+ | `threshold` | 0.03 | 0.001–1 | Side-chain threshold; lower values trigger ducking more readily |
43
+ | `ratio` | 8 | 1–20 | Compression ratio for the music |
44
+ | `attack` | 20 ms | 1–2000 ms | How quickly music is reduced after narration starts |
45
+ | `release` | 300 ms | 10–5000 ms | How quickly music returns after narration pauses |
46
+ | `audio_bitrate` | `192k` | Supported AAC bitrates | Output audio bitrate |
47
+
48
+ The source video must contain an audio stream with the narration, and its duration must be available from probing. This template does not generate narration or music. Listen to the rendered output and adjust music gain and compression settings for the particular mix.
49
+
50
+ ## Template discovery
51
+
52
+ - `GET /v1/templates/voiceover_music_ducking`
53
+ - `GET /v1/templates?category=podcast`
docs/word-by-word-captions.md ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Word-by-word motivational captions
2
+
3
+ MediaRouter provides the `motivational_word_captions` template for producing a vertical MP4 with one timed caption cue per recognized word.
4
+
5
+ ## Endpoint
6
+
7
+ `POST https://basyx-mediarouter.hf.space/v1/templates/run`
8
+
9
+ Use the template ID `motivational_word_captions`. The template resizes the source to 1080×1920, transcribes the original media with Whisper word timestamps, burns the resulting SRT captions onto the resized video, normalizes the output, and encodes an MP4.
10
+
11
+ ## Request example
12
+
13
+ Provide a publicly reachable video URL in the standard template input contract:
14
+
15
+ ```json
16
+ {
17
+ "template": "motivational_word_captions",
18
+ "input": {
19
+ "url": "https://example.com/your-video.mp4"
20
+ },
21
+ "parameters": {
22
+ "model": "tiny",
23
+ "crf": 23,
24
+ "font_size": 32,
25
+ "caption_margin": 260
26
+ }
27
+ }
28
+ ```
29
+
30
+ You can also submit media through the other input transports supported by MediaRouter's shared input resolver.
31
+
32
+ ## Parameters
33
+
34
+ | Parameter | Default | Allowed range / values | Purpose |
35
+ |---|---:|---|---|
36
+ | `model` | `tiny` | `tiny`, `base`, `small` | Whisper model size; larger models generally use more compute |
37
+ | `crf` | `23` | 0–51 | H.264 quality/size trade-off; lower values preserve more quality and increase file size |
38
+ | `font_size` | `32` | 16–40 | Caption font size for the 1080×1920 output |
39
+ | `caption_margin` | `260` | 40–400 | Vertical margin from the bottom edge; increase it to move captions higher |
40
+
41
+ Captions are individually timed from Whisper's word timestamps. Timing is model-estimated, not guaranteed to match every pronunciation perfectly; review the final render before publishing, especially for names, accents, music-heavy audio, or overlapping speech.
42
+
43
+ ## Inspect the template
44
+
45
+ - `GET /v1/templates/motivational_word_captions`
46
+ - `GET /v1/templates?category=motivation`
47
+
48
+ This template processes an already assembled video. It does not generate scenes, narration, or background music.
tests/test_artifact_download_security.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from uuid import uuid4
5
+
6
+ import pytest
7
+
8
+ from app.core.exceptions import NotFoundError
9
+ from app.services.cleanup import CleanupService
10
+
11
+
12
+ def test_resolve_download_returns_only_file_in_request_directory(settings) -> None:
13
+ cleanup = CleanupService(settings)
14
+ request_id = str(uuid4())
15
+ request_dir = settings.output_dir / request_id
16
+ request_dir.mkdir(parents=True)
17
+ artifact = request_dir / "clip.mp4"
18
+ artifact.write_bytes(b"media")
19
+
20
+ assert cleanup.resolve_download(request_id, "clip.mp4") == artifact.resolve()
21
+
22
+
23
+ @pytest.mark.parametrize("filename", ["../secret.txt", "sub/file.mp4", "sub\\file.mp4", 'bad"name.mp4', "bad\nname.mp4", ""])
24
+ def test_resolve_download_rejects_unsafe_filenames(settings, filename: str) -> None:
25
+ cleanup = CleanupService(settings)
26
+ with pytest.raises(NotFoundError):
27
+ cleanup.resolve_download(str(uuid4()), filename)
28
+
29
+
30
+ def test_resolve_download_rejects_symlinked_artifact(settings, tmp_path: Path) -> None:
31
+ cleanup = CleanupService(settings)
32
+ request_id = str(uuid4())
33
+ request_dir = settings.output_dir / request_id
34
+ request_dir.mkdir(parents=True)
35
+ outside = tmp_path / "outside.mp4"
36
+ outside.write_bytes(b"not published")
37
+ link = request_dir / "clip.mp4"
38
+ try:
39
+ link.symlink_to(outside)
40
+ except (OSError, NotImplementedError):
41
+ pytest.skip("Symlink creation is unavailable on this platform")
42
+
43
+ with pytest.raises(NotFoundError):
44
+ cleanup.resolve_download(request_id, "clip.mp4")
45
+
46
+
47
+ def test_resolve_download_rejects_symlinked_request_directory(settings, tmp_path: Path) -> None:
48
+ cleanup = CleanupService(settings)
49
+ request_id = str(uuid4())
50
+ outside = tmp_path / "outside"
51
+ outside.mkdir()
52
+ (outside / "clip.mp4").write_bytes(b"not published")
53
+ settings.output_dir.mkdir(parents=True, exist_ok=True)
54
+ try:
55
+ (settings.output_dir / request_id).symlink_to(outside, target_is_directory=True)
56
+ except (OSError, NotImplementedError):
57
+ pytest.skip("Symlink creation is unavailable on this platform")
58
+
59
+ with pytest.raises(NotFoundError):
60
+ cleanup.resolve_download(request_id, "clip.mp4")
tests/test_downloader.py CHANGED
@@ -1,8 +1,13 @@
 
 
 
1
  from unittest.mock import AsyncMock
2
 
 
3
  import respx
4
  from httpx import Response
5
 
 
6
  from app.services.downloader import Downloader
7
  from app.services.validator import MediaValidator
8
 
@@ -18,3 +23,52 @@ async def test_url_download_streams_to_disk(settings, tmp_path) -> None:
18
  path, mime_type = await downloader.download(url, tmp_path)
19
  assert path.read_bytes() == b"ID3data"
20
  assert mime_type == "audio/mpeg"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import socket
4
  from unittest.mock import AsyncMock
5
 
6
+ import pytest
7
  import respx
8
  from httpx import Response
9
 
10
+ from app.core.exceptions import InputError
11
  from app.services.downloader import Downloader
12
  from app.services.validator import MediaValidator
13
 
 
23
  path, mime_type = await downloader.download(url, tmp_path)
24
  assert path.read_bytes() == b"ID3data"
25
  assert mime_type == "audio/mpeg"
26
+
27
+
28
+ async def test_validate_url_rejects_non_global_dns_answer(settings, monkeypatch) -> None:
29
+ settings.allow_private_urls = False
30
+ downloader = Downloader(settings, MediaValidator(settings))
31
+
32
+ def private_answer(*args, **kwargs):
33
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 443))]
34
+
35
+ monkeypatch.setattr(socket, "getaddrinfo", private_answer)
36
+ with pytest.raises(InputError, match="Private, loopback"):
37
+ await downloader.validate_url("https://example.test/media.mp4")
38
+
39
+
40
+ async def test_validate_url_rejects_mixed_public_and_private_dns(settings, monkeypatch) -> None:
41
+ settings.allow_private_urls = False
42
+ downloader = Downloader(settings, MediaValidator(settings))
43
+
44
+ def mixed_answers(*args, **kwargs):
45
+ return [
46
+ (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)),
47
+ (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.4", 443)),
48
+ ]
49
+
50
+ monkeypatch.setattr(socket, "getaddrinfo", mixed_answers)
51
+ with pytest.raises(InputError, match="Private, loopback"):
52
+ await downloader.validate_url("https://example.test/media.mp4")
53
+
54
+
55
+ @pytest.mark.parametrize("url", [
56
+ "file:///etc/passwd",
57
+ "https://user:password@example.test/media.mp4",
58
+ "https://example.test:invalid/media.mp4",
59
+ "https://example.test:99999/media.mp4",
60
+ ])
61
+ async def test_validate_url_rejects_invalid_or_credentialed_urls(settings, url: str) -> None:
62
+ settings.allow_private_urls = False
63
+ downloader = Downloader(settings, MediaValidator(settings))
64
+ with pytest.raises(InputError):
65
+ await downloader.validate_url(url)
66
+
67
+
68
+ def test_safe_filename_normalizes_windows_paths_and_rejects_header_injection(settings) -> None:
69
+ validator = MediaValidator(settings)
70
+ assert validator.safe_filename(r"C:\\temp\\clip.mp4") == "clip.mp4"
71
+ with pytest.raises(InputError):
72
+ validator.safe_filename("clip.mp4\r\nX-Evil: yes")
73
+ with pytest.raises(InputError):
74
+ validator.safe_filename('clip".mp4')
tests/test_media_url_signer.py CHANGED
@@ -1,5 +1,8 @@
1
  from __future__ import annotations
2
 
 
 
 
3
  from app.services.media_url_signer import MediaURLSigner
4
 
5
 
@@ -9,18 +12,36 @@ def test_signed_media_url_round_trip(settings) -> None:
9
  signer = MediaURLSigner(settings)
10
  url = signer.sign("123e4567-e89b-12d3-a456-426614174000", "clip.mp4", ttl=3600)
11
  assert url is not None
12
- from urllib.parse import parse_qs, urlparse
13
  parsed = urlparse(url)
14
  params = parse_qs(parsed.query)
15
- assert signer.verify(
16
- "123e4567-e89b-12d3-a456-426614174000",
17
- "clip.mp4",
18
- int(params["expires"][0]),
19
- params["signature"][0],
20
- )
21
- assert not signer.verify(
22
- "123e4567-e89b-12d3-a456-426614174000",
23
- "other.mp4",
24
- int(params["expires"][0]),
25
- params["signature"][0],
26
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import time
4
+ from urllib.parse import parse_qs, urlparse
5
+
6
  from app.services.media_url_signer import MediaURLSigner
7
 
8
 
 
12
  signer = MediaURLSigner(settings)
13
  url = signer.sign("123e4567-e89b-12d3-a456-426614174000", "clip.mp4", ttl=3600)
14
  assert url is not None
 
15
  parsed = urlparse(url)
16
  params = parse_qs(parsed.query)
17
+ expires = int(params["expires"][0])
18
+ signature = params["signature"][0]
19
+ assert signer.verify("123e4567-e89b-12d3-a456-426614174000", "clip.mp4", expires, signature)
20
+ assert not signer.verify("123e4567-e89b-12d3-a456-426614174000", "other.mp4", expires, signature)
21
+
22
+
23
+ def test_signed_media_url_rejects_expired_and_overlong_expiry(settings) -> None:
24
+ settings.media_signing_secret = "a" * 32
25
+ signer = MediaURLSigner(settings)
26
+ assert not signer.verify("request", "clip.mp4", int(time.time()) - 1, "x" * 43)
27
+
28
+ expiry = int(time.time()) + settings.media_signed_url_ttl_seconds + 120
29
+ import base64
30
+ import hashlib
31
+ import hmac
32
+
33
+ message = f"request\nclip.mp4\n{expiry}".encode()
34
+ signature = base64.urlsafe_b64encode(
35
+ hmac.new(b"a" * 32, message, hashlib.sha256).digest()
36
+ ).decode().rstrip("=")
37
+ assert not signer.verify("request", "clip.mp4", expiry, signature)
38
+
39
+
40
+ def test_signer_caps_requested_ttl_to_configured_maximum(settings) -> None:
41
+ settings.media_signing_secret = "a" * 32
42
+ settings.base_url = "https://media.example"
43
+ signer = MediaURLSigner(settings)
44
+ url = signer.sign("request", "clip.mp4", ttl=settings.media_signed_url_ttl_seconds * 10)
45
+ assert url is not None
46
+ expiry = int(parse_qs(urlparse(url).query)["expires"][0])
47
+ assert expiry <= int(time.time()) + settings.media_signed_url_ttl_seconds
tests/test_mix_audio.py CHANGED
@@ -95,3 +95,36 @@ async def test_add_audio_rejects_invalid_bitrate(tmp_path: Path) -> None:
95
  sfx = _media(tmp_path / "one.wav", "one.wav", "audio/wav")
96
  with pytest.raises(InputError, match="supported AAC bitrate"):
97
  await add_audio(FakeFFmpeg(), [video, sfx], {"audio_bitrate": "999k"}, tmp_path / "out")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  sfx = _media(tmp_path / "one.wav", "one.wav", "audio/wav")
96
  with pytest.raises(InputError, match="supported AAC bitrate"):
97
  await add_audio(FakeFFmpeg(), [video, sfx], {"audio_bitrate": "999k"}, tmp_path / "out")
98
+
99
+
100
+ @pytest.mark.asyncio
101
+ async def test_duck_background_music_uses_voice_sidechain_and_preserves_video(tmp_path: Path) -> None:
102
+ from app.operations.mix_audio import duck_background_music
103
+
104
+ video = _media(
105
+ tmp_path / "voice.mp4", "voice.mp4", "video/mp4", duration=18.0,
106
+ metadata={"audio_streams": [{"codec": "aac"}]},
107
+ )
108
+ music = _media(tmp_path / "music.mp3", "music.mp3", "audio/mpeg")
109
+ ffmpeg = FakeFFmpeg()
110
+ result = await duck_background_music(
111
+ ffmpeg, [video, music], {"music_volume": 0.2, "ratio": 8, "attack": 20, "release": 300},
112
+ tmp_path / "out",
113
+ )
114
+ command = [str(value) for value in ffmpeg.args]
115
+ filt = command[command.index("-filter_complex") + 1]
116
+ assert "[music][voice]sidechaincompress=threshold=0.03:ratio=8:attack=20:release=300[ducked]" in filt
117
+ assert "[voice][ducked]amix=inputs=2:duration=first:normalize=0[mixed]" in filt
118
+ assert command[command.index("-c:v") + 1] == "copy"
119
+ assert result.metadata["mix_mode"] == "sidechain_ducking"
120
+
121
+
122
+ @pytest.mark.asyncio
123
+ async def test_duck_background_music_rejects_silent_video(tmp_path: Path) -> None:
124
+ from app.operations.mix_audio import duck_background_music
125
+
126
+ video = _media(tmp_path / "silent.mp4", "silent.mp4", "video/mp4", duration=8.0,
127
+ metadata={"audio_streams": []})
128
+ music = _media(tmp_path / "music.mp3", "music.mp3", "audio/mpeg")
129
+ with pytest.raises(InputError, match="requires narration audio"):
130
+ await duck_background_music(FakeFFmpeg(), [video, music], {}, tmp_path / "out")
tests/test_output_validation.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from app.container import build_container
8
+ from app.core.exceptions import ProcessingError
9
+ from app.models.media import OperationResult
10
+
11
+
12
+ @pytest.mark.asyncio
13
+ async def test_output_validation_probes_audio_artifact(settings, tmp_path: Path, monkeypatch) -> None:
14
+ container = build_container(settings)
15
+ output = tmp_path / "final.mp3"
16
+ output.write_bytes(b"audio-placeholder")
17
+
18
+ async def fake_probe(path: Path):
19
+ assert path == output
20
+ return {
21
+ "duration": 12.5,
22
+ "container": "mp3",
23
+ "video_streams": [],
24
+ "audio_streams": [{"codec": "mp3"}],
25
+ "resolution": None,
26
+ }
27
+
28
+ monkeypatch.setattr(container.processor.ffprobe, "probe", fake_probe)
29
+ report = await container.processor.validate_output_result(
30
+ OperationResult(path=output, filename=output.name, mime_type="audio/mpeg")
31
+ )
32
+
33
+ assert report["valid"] is True
34
+ assert report["probed"] is True
35
+ assert report["size_bytes"] == output.stat().st_size
36
+ assert report["audio_stream_count"] == 1
37
+ assert report["duration_seconds"] == 12.5
38
+
39
+
40
+ @pytest.mark.asyncio
41
+ async def test_output_validation_rejects_audio_without_audio_stream(settings, tmp_path: Path, monkeypatch) -> None:
42
+ container = build_container(settings)
43
+ output = tmp_path / "broken.mp3"
44
+ output.write_bytes(b"not-real-audio")
45
+
46
+ async def fake_probe(path: Path):
47
+ return {
48
+ "duration": 1.0,
49
+ "container": "mp3",
50
+ "video_streams": [],
51
+ "audio_streams": [],
52
+ "resolution": None,
53
+ }
54
+
55
+ monkeypatch.setattr(container.processor.ffprobe, "probe", fake_probe)
56
+ with pytest.raises(ProcessingError, match="no audio stream"):
57
+ await container.processor.validate_output_result(
58
+ OperationResult(path=output, filename=output.name, mime_type="audio/mpeg")
59
+ )
60
+
61
+
62
+ @pytest.mark.asyncio
63
+ async def test_output_validation_rejects_empty_artifact(settings, tmp_path: Path) -> None:
64
+ container = build_container(settings)
65
+ output = tmp_path / "empty.mp4"
66
+ output.write_bytes(b"")
67
+
68
+ with pytest.raises(ProcessingError, match="empty"):
69
+ await container.processor.validate_output_result(
70
+ OperationResult(path=output, filename=output.name, mime_type="video/mp4")
71
+ )
tests/test_templates.py CHANGED
@@ -7,14 +7,25 @@ import pytest
7
  from fastapi.testclient import TestClient
8
 
9
  from app.container import build_container
10
- from app.core.exceptions import TemplateValidationError
11
  from app.models.media import InputMedia, MediaSource, ResolvedRequest
12
- from app.templates.executor import OPERATION_BINDINGS
13
  from app.templates.loader import TemplateLoader
14
  from app.templates.registry import TemplateRegistry
15
  from app.templates.validator import TemplateValidator
16
  from main import create_app
17
 
 
 
 
 
 
 
 
 
 
 
 
18
  EXPECTED_CATEGORIES = {
19
  "branding",
20
  "conversion",
@@ -32,7 +43,7 @@ EXPECTED_CATEGORIES = {
32
  def test_builtin_templates_are_loaded_dynamically(settings) -> None:
33
  registry = build_container(settings).template_registry
34
 
35
- assert registry.count == 71
36
  assert set(registry.categories()) == EXPECTED_CATEGORIES
37
  assert registry.get("instagram_reel").version == 1
38
  assert registry.get("instagram_reel@latest").version == 1
@@ -52,6 +63,22 @@ def test_parameter_substitution_preserves_declared_types(settings) -> None:
52
  assert isinstance(compress_step.parameters["crf"], int)
53
 
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  def test_template_registry_keeps_old_versions(tmp_path: Path) -> None:
56
  root = tmp_path / "templates"
57
  root.mkdir()
@@ -192,6 +219,7 @@ async def test_template_executor_calls_existing_operation(settings, tmp_path, mo
192
  output.write_bytes(b"ID3-template-output")
193
 
194
  monkeypatch.setattr(container.processor, "probe_inputs", fake_probe)
 
195
  monkeypatch.setattr(container.ffmpeg, "run", fake_ffmpeg)
196
  resolved = ResolvedRequest(
197
  request_id="5fdbe750-4cb7-4f87-aa5c-3df50c3a6629",
@@ -238,6 +266,7 @@ def test_template_rest_endpoints_and_nested_input(settings, monkeypatch) -> None
238
  output.write_bytes(b"ID3-rest-template")
239
 
240
  monkeypatch.setattr(container.processor, "probe_inputs", fake_probe)
 
241
  monkeypatch.setattr(container.ffmpeg, "run", fake_ffmpeg)
242
 
243
  with TestClient(application) as client:
@@ -258,8 +287,62 @@ def test_template_rest_endpoints_and_nested_input(settings, monkeypatch) -> None
258
  )
259
 
260
  assert listing.status_code == 200
261
- assert listing.json()["metadata"]["count"] == 71
262
  assert set(categories.json()["metadata"]["categories"]) == EXPECTED_CATEGORIES
263
  assert details.json()["metadata"]["template"]["version"] == 1
264
  assert execution.status_code == 200
265
  assert execution.json()["metadata"]["template"]["id"] == "mp3"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  from fastapi.testclient import TestClient
8
 
9
  from app.container import build_container
10
+ from app.core.exceptions import TemplateExecutionError, TemplateValidationError
11
  from app.models.media import InputMedia, MediaSource, ResolvedRequest
12
+ from app.templates.executor import OPERATION_BINDINGS, TemplateExecutor
13
  from app.templates.loader import TemplateLoader
14
  from app.templates.registry import TemplateRegistry
15
  from app.templates.validator import TemplateValidator
16
  from main import create_app
17
 
18
+
19
+ async def fake_audio_output_probe(path: Path):
20
+ return {
21
+ "duration": 1.0,
22
+ "container": "mp3",
23
+ "video_streams": [],
24
+ "audio_streams": [{"codec": "mp3"}],
25
+ "resolution": None,
26
+ }
27
+
28
+
29
  EXPECTED_CATEGORIES = {
30
  "branding",
31
  "conversion",
 
43
  def test_builtin_templates_are_loaded_dynamically(settings) -> None:
44
  registry = build_container(settings).template_registry
45
 
46
+ assert registry.count == 73
47
  assert set(registry.categories()) == EXPECTED_CATEGORIES
48
  assert registry.get("instagram_reel").version == 1
49
  assert registry.get("instagram_reel@latest").version == 1
 
63
  assert isinstance(compress_step.parameters["crf"], int)
64
 
65
 
66
+
67
+ def test_motivational_word_caption_template_is_validated(settings) -> None:
68
+ registry = build_container(settings).template_registry
69
+
70
+ prepared = registry.prepare("motivational_word_captions", {})
71
+ operations = [step.operation for step in prepared.pipeline]
72
+
73
+ assert operations == ["resize", "transcribe", "burn_subtitles", "normalize_video", "compress"]
74
+ assert prepared.pipeline[1].parameters["word_timestamps"] is True
75
+ assert prepared.pipeline[1].parameters["caption_mode"] == "word"
76
+ assert prepared.pipeline[1].parameters["output_format"] == "srt"
77
+ assert prepared.pipeline[2].inputs == ["artifact:vertical_video", "artifact:captions"]
78
+ assert prepared.pipeline[2].parameters["style"].find("PrimaryColour=") >= 0
79
+ assert prepared.output.format == "mp4"
80
+
81
+
82
  def test_template_registry_keeps_old_versions(tmp_path: Path) -> None:
83
  root = tmp_path / "templates"
84
  root.mkdir()
 
219
  output.write_bytes(b"ID3-template-output")
220
 
221
  monkeypatch.setattr(container.processor, "probe_inputs", fake_probe)
222
+ monkeypatch.setattr(container.processor.ffprobe, "probe", fake_audio_output_probe)
223
  monkeypatch.setattr(container.ffmpeg, "run", fake_ffmpeg)
224
  resolved = ResolvedRequest(
225
  request_id="5fdbe750-4cb7-4f87-aa5c-3df50c3a6629",
 
266
  output.write_bytes(b"ID3-rest-template")
267
 
268
  monkeypatch.setattr(container.processor, "probe_inputs", fake_probe)
269
+ monkeypatch.setattr(container.processor.ffprobe, "probe", fake_audio_output_probe)
270
  monkeypatch.setattr(container.ffmpeg, "run", fake_ffmpeg)
271
 
272
  with TestClient(application) as client:
 
287
  )
288
 
289
  assert listing.status_code == 200
290
+ assert listing.json()["metadata"]["count"] == 73
291
  assert set(categories.json()["metadata"]["categories"]) == EXPECTED_CATEGORIES
292
  assert details.json()["metadata"]["template"]["version"] == 1
293
  assert execution.status_code == 200
294
  assert execution.json()["metadata"]["template"]["id"] == "mp3"
295
+
296
+
297
+
298
+ def test_voiceover_music_ducking_template_uses_two_original_inputs(settings) -> None:
299
+ registry = build_container(settings).template_registry
300
+ prepared = registry.prepare("voiceover_music_ducking", {})
301
+ assert [step.operation for step in prepared.pipeline] == ["duck_audio"]
302
+ assert prepared.pipeline[0].inputs == ["original", "original:1"]
303
+ assert prepared.pipeline[0].parameters["music_volume"] == 0.22
304
+ assert prepared.output.format == "mp4"
305
+
306
+
307
+ def test_final_output_validation_reports_media_metadata(settings, tmp_path: Path) -> None:
308
+ output = tmp_path / "final.mp3"
309
+ output.write_bytes(b"non-empty-output")
310
+ media = InputMedia(
311
+ source=MediaSource.LOCAL_PATH,
312
+ filename=output.name,
313
+ mime_type="audio/mpeg",
314
+ temp_path=output,
315
+ size=output.stat().st_size,
316
+ duration=12.5,
317
+ metadata={
318
+ "duration": 12.5,
319
+ "container": "mp3",
320
+ "audio_streams": [{"codec": "mp3"}],
321
+ "video_streams": [],
322
+ "resolution": None,
323
+ },
324
+ )
325
+
326
+ report = TemplateExecutor._output_validation(media)
327
+
328
+ assert report["valid"] is True
329
+ assert report["size_bytes"] == output.stat().st_size
330
+ assert report["duration_seconds"] == 12.5
331
+ assert report["audio_stream_count"] == 1
332
+ assert report["video_stream_count"] == 0
333
+
334
+
335
+ def test_final_output_validation_rejects_empty_artifact(settings, tmp_path: Path) -> None:
336
+ output = tmp_path / "empty.mp3"
337
+ output.write_bytes(b"")
338
+ media = InputMedia(
339
+ source=MediaSource.LOCAL_PATH,
340
+ filename=output.name,
341
+ mime_type="audio/mpeg",
342
+ temp_path=output,
343
+ size=0,
344
+ )
345
+ prepared = build_container(settings).template_registry.prepare("mp3@1", {})
346
+
347
+ with pytest.raises(TemplateExecutionError, match="empty"):
348
+ TemplateExecutor._validate_output(prepared, media)