sonilo-video-kit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ """Video helpers for the Sonilo API (ffmpeg-based)."""
2
+ from .duck import (
3
+ MAX_DUCKED_MIX_BYTES, MAX_DUCKING_DURATION_SECONDS, duck_music_under_speech,
4
+ )
5
+ from .errors import (
6
+ DuckingFailedError, FfmpegError, FfmpegNotFoundError, VideoKitError,
7
+ )
8
+ from .generate import VideoMusicClient, generate_music_for_video
9
+ from .loudness import (
10
+ DELIVERY_TARGET_LUFS, FALLBACK_MUSIC_LUFS, GAP_BELOW_VOICE_LU, OUTPUT_CEILING_DBFS,
11
+ )
12
+ from .mix import mix_with_video
13
+
14
+ __all__ = sorted([
15
+ "DELIVERY_TARGET_LUFS", "DuckingFailedError", "FALLBACK_MUSIC_LUFS", "FfmpegError",
16
+ "FfmpegNotFoundError", "GAP_BELOW_VOICE_LU", "MAX_DUCKED_MIX_BYTES",
17
+ "MAX_DUCKING_DURATION_SECONDS", "OUTPUT_CEILING_DBFS", "VideoKitError",
18
+ "VideoMusicClient", "duck_music_under_speech", "generate_music_for_video",
19
+ "mix_with_video",
20
+ ])
@@ -0,0 +1,496 @@
1
+ """Ducking API transport: submit / poll / download (ported from ducking-api.ts).
2
+
3
+ Self-contained HTTP layer for the async `/v1/audio-ducking` endpoint. Three
4
+ concerns live here, matching the JS reference:
5
+
6
+ - submit_ducking_job: POSTs the voice+music tracks. This is what CHARGES
7
+ the account, so it is deliberately NEVER retried (see its docstring).
8
+ - await_ducking_result: polls GET /v1/tasks/{id} until the task leaves
9
+ "processing", retrying transient (5xx / network) failures — the task
10
+ keeps running server-side no matter what happens to this client.
11
+ - download_ducked_mix: fetches the finished mix from its presigned URL
12
+ (a DIFFERENT, unauthenticated host — never routed through client._http,
13
+ so the customer's API key never reaches it), guarded against SSRF and
14
+ bounded by a byte cap / exact-size check.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import ipaddress
19
+ import os
20
+ import time
21
+ import uuid
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Any, Callable, Optional, Protocol
25
+ from urllib.parse import quote, urlsplit
26
+
27
+ import httpx
28
+ from sonilo.errors import error_from_response
29
+
30
+ from ._ffmpeg import StrPath
31
+ from .errors import DuckingFailedError, VideoKitError
32
+
33
+ Sleep = Callable[[float], None]
34
+
35
+
36
+ class DuckingClient(Protocol):
37
+ """The minimal client surface the ducking calls need — satisfied by
38
+ `sonilo.Sonilo`, whose `_http` is a configured httpx.Client (base_url
39
+ https://api.sonilo.com, Authorization header already set)."""
40
+
41
+ _http: httpx.Client
42
+
43
+
44
+ @dataclass
45
+ class DuckingResult:
46
+ output_url: str
47
+ output_type: str
48
+ output_bytes: Optional[int] = None
49
+
50
+
51
+ # One initial try plus three retries.
52
+ _MAX_ATTEMPTS = 4
53
+ _RETRY_BASE_SECONDS = 0.5
54
+ _RETRY_MAX_SECONDS = 4.0
55
+
56
+ # Task/submit JSON envelopes are a few hundred bytes; 1 MB is orders of
57
+ # magnitude of headroom while still capping a compromised API trying to OOM
58
+ # the client.
59
+ MAX_JSON_BYTES = 1024 * 1024
60
+
61
+ # Per-attempt wall-clock cap for the mix download, mirroring ducking-api.ts's
62
+ # DEFAULT_DOWNLOAD_TIMEOUT_MS.
63
+ DEFAULT_DOWNLOAD_TIMEOUT_SECONDS = 120.0
64
+
65
+
66
+ class _HttpStatusError(VideoKitError):
67
+ """A non-2xx from the (unauthed) download host. Carries `status_code` so
68
+ `_is_transient` classifies it exactly like the sonilo SDK's own
69
+ APIError (which also carries `status_code`)."""
70
+
71
+ def __init__(self, status_code: int, what: str) -> None:
72
+ super().__init__(f"{what} (HTTP {status_code})")
73
+ self.status_code = status_code
74
+
75
+
76
+ class _DownloadTimeoutError(VideoKitError):
77
+ """A download attempt that blew its per-attempt wall-clock deadline.
78
+ Transient on purpose (see `_is_transient`): a stalled/slow attempt is
79
+ retried with a FRESH deadline, mirroring ducking-api.ts's
80
+ DownloadTimeoutError. httpx's own `timeout` only bounds the gap BETWEEN
81
+ chunks (it resets on every byte received), so a server dribbling bytes
82
+ just under that gap would otherwise stream forever; this bounds the
83
+ whole attempt's wall-clock lifetime instead."""
84
+
85
+ def __init__(self, timeout_seconds: float) -> None:
86
+ super().__init__(
87
+ f"The ducked-mix download did not complete within its {timeout_seconds}s "
88
+ "deadline and was aborted."
89
+ )
90
+ self.timeout_seconds = timeout_seconds
91
+
92
+
93
+ def _retry_delay_seconds(attempt: int) -> float:
94
+ return min(_RETRY_BASE_SECONDS * (2 ** (attempt - 1)), _RETRY_MAX_SECONDS)
95
+
96
+
97
+ def _is_transient(err: BaseException) -> bool:
98
+ """Worth another go? Mirrors ducking-api.ts's isTransient: a blown
99
+ per-attempt download deadline is retried with a fresh one; a numeric
100
+ status >= 500 is transient (covers both the sonilo SDK's APIError and our
101
+ own _HttpStatusError); any other VideoKitError is terminal; anything else
102
+ (a network-level failure — connection reset, DNS, TLS) is transient."""
103
+ if isinstance(err, _DownloadTimeoutError):
104
+ return True
105
+ status = getattr(err, "status_code", None)
106
+ if isinstance(status, int):
107
+ return status >= 500
108
+ if isinstance(err, VideoKitError):
109
+ return False
110
+ return isinstance(err, Exception)
111
+
112
+
113
+ def _with_retry(op: "Callable[[], Any]", *, sleep: Sleep) -> Any:
114
+ attempt = 1
115
+ while True:
116
+ try:
117
+ return op()
118
+ except Exception as err: # noqa: BLE001 - reclassified below
119
+ if attempt >= _MAX_ATTEMPTS or not _is_transient(err):
120
+ raise
121
+ sleep(_retry_delay_seconds(attempt))
122
+ attempt += 1
123
+
124
+
125
+ def _parse_json_capped(response: httpx.Response, what: str) -> Any:
126
+ """`response.json()`, but refusing to trust a body over MAX_JSON_BYTES."""
127
+ if len(response.content) > MAX_JSON_BYTES:
128
+ raise VideoKitError(
129
+ f"The ducking API's {what} response exceeded {MAX_JSON_BYTES} bytes; "
130
+ "refusing to buffer it."
131
+ )
132
+ return response.json()
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # submit_ducking_job
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ def submit_ducking_job(client: DuckingClient, voice_path: StrPath, music_path: StrPath) -> str:
141
+ """POST the voice and music tracks to /v1/audio-ducking. Returns the task
142
+ id; the endpoint is async (submit + poll).
143
+
144
+ Deliberately NOT retried, unlike the poll and the download below: the
145
+ POST is what CHARGES the account (the backend charges in the request
146
+ handler, before the background job is even spawned), and it carries no
147
+ idempotency key. A retry after a response we failed to read would risk
148
+ submitting — and paying for — the same job twice. Failing here is safe;
149
+ failing after here is what has to be recovered (see duck.py's rescue
150
+ path)."""
151
+ voice = Path(voice_path)
152
+ music = Path(music_path)
153
+ files = {
154
+ "voice_file": (voice.name, voice.read_bytes()),
155
+ "music_file": (music.name, music.read_bytes()),
156
+ }
157
+ response = client._http.post("/v1/audio-ducking", files=files)
158
+ if response.status_code >= 400:
159
+ raise error_from_response(response)
160
+ body = _parse_json_capped(response, "submit")
161
+ task_id = body.get("task_id") if isinstance(body, dict) else None
162
+ if not task_id:
163
+ raise VideoKitError("The ducking API accepted the request but returned no task_id")
164
+ return task_id
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # await_ducking_result
169
+ # ---------------------------------------------------------------------------
170
+
171
+
172
+ def _extract_error_fields(body: "dict[str, Any]") -> "tuple[str, str]":
173
+ """The task's failure code/message. The backend's documented envelope
174
+ nests them under `error: {code, message}` (matching the sonilo SDK's
175
+ other task endpoints and ducking-api.ts's TaskBody); tolerate a flat
176
+ top-level `code`/`message` too, in case an older or alternate envelope
177
+ omits the wrapper."""
178
+ error = body.get("error")
179
+ if isinstance(error, dict):
180
+ code = error.get("code") or "DUCKING_FAILED"
181
+ message = error.get("message") or "the ducking task failed"
182
+ else:
183
+ code = body.get("code") or "DUCKING_FAILED"
184
+ message = body.get("message") or "the ducking task failed"
185
+ return str(code), str(message)
186
+
187
+
188
+ def _dedup_failed_message(message: str, code: str, refunded: bool) -> str:
189
+ """Compose the final DuckingFailedError message, ported from errors.ts's
190
+ DuckingFailedError constructor (kept here rather than in errors.py: the
191
+ Python DuckingFailedError, ported earlier, stores its message as given —
192
+ this is the one call site that needs the composed text).
193
+
194
+ The server derives `code` by splitting its own error_message on ":"
195
+ (error_message = "DUCKING_FAILED: audio processing failed"), so both
196
+ fields carry the code and a naive compose renders it twice. Strip the
197
+ "{code}:" prefix from `message` first."""
198
+ prefix = f"{code}:"
199
+ detail = message[len(prefix):].strip() if message.startswith(prefix) else message
200
+ detail = detail or message
201
+ note = (
202
+ " — the charge was refunded"
203
+ if refunded
204
+ else (
205
+ " — the charge had not been reversed yet when the task was polled; the server "
206
+ "reverses it after marking the task failed, and retries a reversal that fails, "
207
+ "so it may still land. Check your usage before assuming you were billed for this."
208
+ )
209
+ )
210
+ return f"Ducking failed [{code}]: {detail}{note}"
211
+
212
+
213
+ def _poll_task(client: DuckingClient, task_id: str) -> "dict[str, Any]":
214
+ response = client._http.get(f"/v1/tasks/{quote(task_id, safe='')}")
215
+ if response.status_code >= 400:
216
+ raise error_from_response(response)
217
+ body = _parse_json_capped(response, "task")
218
+ if not isinstance(body, dict):
219
+ raise VideoKitError(f"Ducking task {task_id} returned a malformed response")
220
+ return body
221
+
222
+
223
+ def _sanitize_output_bytes(value: Any) -> Optional[int]:
224
+ """Only ever a POSITIVE integer or None. A real ducking artifact is never
225
+ 0 bytes, and a fractional/negative/wrong-typed value is not a byte
226
+ count — see the extensive comment on this exact check in ducking-api.ts."""
227
+ if isinstance(value, bool):
228
+ return None
229
+ if isinstance(value, int) and value > 0:
230
+ return value
231
+ return None
232
+
233
+
234
+ def await_ducking_result(
235
+ client: DuckingClient,
236
+ task_id: str,
237
+ *,
238
+ poll_interval: float,
239
+ timeout: float,
240
+ deadline: Optional[float] = None,
241
+ sleep: Sleep = time.sleep,
242
+ ) -> DuckingResult:
243
+ """Poll GET /v1/tasks/{id} until the task leaves `processing`.
244
+
245
+ `deadline` is an absolute `time.monotonic()` ceiling; when omitted it is
246
+ computed from `timeout`. duck.py passes the same deadline into both this
247
+ call and download_ducked_mix, so one budget governs polling AND the
248
+ download together (see duck.ts's "ONE deadline governs the WHOLE
249
+ post-submit collection")."""
250
+ effective_deadline = deadline if deadline is not None else time.monotonic() + timeout
251
+
252
+ while True:
253
+ body = _with_retry(lambda: _poll_task(client, task_id), sleep=sleep)
254
+ status = body.get("status")
255
+
256
+ if status == "succeeded":
257
+ output_url = body.get("output_url")
258
+ if not output_url:
259
+ raise VideoKitError(f"Ducking task {task_id} succeeded but carried no output_url")
260
+ return DuckingResult(
261
+ output_url=output_url,
262
+ output_type=body.get("output_type") or "audio",
263
+ output_bytes=_sanitize_output_bytes(body.get("output_bytes")),
264
+ )
265
+
266
+ if status == "failed":
267
+ code, message = _extract_error_fields(body)
268
+ refunded = bool(body.get("refunded", False))
269
+ raise DuckingFailedError(
270
+ _dedup_failed_message(message, code, refunded), code=code, refunded=refunded
271
+ )
272
+
273
+ if time.monotonic() + poll_interval >= effective_deadline:
274
+ raise VideoKitError(f"Ducking task {task_id} did not finish within {timeout}s")
275
+ sleep(poll_interval)
276
+
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # assert_safe_download_url / download_ducked_mix
280
+ # ---------------------------------------------------------------------------
281
+
282
+ _DECIMAL_DIGITS = set("0123456789")
283
+ _OCTAL_DIGITS = set("01234567")
284
+ _HEX_DIGITS = set("0123456789abcdefABCDEF")
285
+
286
+
287
+ def _parse_ipv4_number(label: str) -> Optional[int]:
288
+ """Parse one dot-separated label (or a whole no-dot host) as an IPv4
289
+ "number", per the WHATWG URL standard's IPv4 number parser: a decimal,
290
+ `0x`/`0X`-prefixed hex, or legacy leading-zero octal integer. Returns
291
+ None if `label` is empty or is not such a number — i.e. an ordinary
292
+ domain label, which the WHATWG parser also leaves untouched.
293
+
294
+ This is what a browser's URL parser runs BEFORE the IP-literal check
295
+ ever sees the host — see ducking-api.ts's isIpLiteralHost comment.
296
+ httpx/urlsplit + ipaddress.ip_address do none of this canonicalization,
297
+ so this Python port has to do it itself."""
298
+ if not label:
299
+ return None
300
+ if len(label) >= 2 and label[0] == "0" and label[1] in ("x", "X"):
301
+ digits, base, allowed = label[2:], 16, _HEX_DIGITS
302
+ elif len(label) >= 2 and label[0] == "0":
303
+ digits, base, allowed = label[1:], 8, _OCTAL_DIGITS
304
+ else:
305
+ digits, base, allowed = label, 10, _DECIMAL_DIGITS
306
+ if digits == "":
307
+ return 0
308
+ if not all(ch in allowed for ch in digits):
309
+ return None
310
+ return int(digits, base)
311
+
312
+
313
+ def _is_ip_literal_host(host: str) -> bool:
314
+ """True if `host` (already lowercased) denotes an IPv4/IPv6 literal in
315
+ ANY form a WHATWG-compliant URL parser (what JS's `new URL()` uses)
316
+ would canonicalize into one — not just the strict textual forms
317
+ `ipaddress.ip_address` accepts on its own. Mirrors ducking-api.ts's
318
+ `isIpLiteralHost`, whose intent relies on the browser URL parser having
319
+ already canonicalized the host before that check runs; this port has no
320
+ such parser in front of it, so it reimplements the canonicalization.
321
+
322
+ Catches, in addition to the textbook dotted-quad / bracketed-v6 forms:
323
+ - a bare integer host in decimal, hex (`0x...`), or octal (leading
324
+ `0`) that fits in 32 bits, e.g. "2130706433" / "0x7f000001" /
325
+ "017700000001" — all 127.0.0.1;
326
+ - a dotted host whose every label is itself such a number, e.g.
327
+ "0x7f.0.0.1" or "0177.0.0.1";
328
+ - a trailing dot on a dotted-quad, e.g. "127.0.0.1." (the DNS-root
329
+ dot; "127.0.0.1." resolves identically to "127.0.0.1", but
330
+ `ipaddress.ip_address` rejects the trailing-dot form outright).
331
+
332
+ A host that is NOT an IP literal in any of these forms (an ordinary
333
+ domain) returns False and is left alone — a numeric-only label is not
334
+ a legal DNS TLD, so this cannot misclassify a real hostname."""
335
+ stripped = host.rstrip(".")
336
+ if not stripped:
337
+ return False
338
+
339
+ try:
340
+ ipaddress.ip_address(stripped)
341
+ return True
342
+ except ValueError:
343
+ pass
344
+
345
+ if "." not in stripped:
346
+ # A bare integer host is the WHATWG IPv4 number parser applied to a
347
+ # single label: "2130706433", "0x7f000001", "017700000001" all take
348
+ # this path (and all denote 127.0.0.1).
349
+ number = _parse_ipv4_number(stripped)
350
+ return number is not None and 0 <= number <= 0xFFFFFFFF
351
+
352
+ labels = stripped.split(".")
353
+ return all(_parse_ipv4_number(label) is not None for label in labels)
354
+
355
+
356
+ def assert_safe_download_url(url: str) -> None:
357
+ """Validate the presigned download URL before fetching it.
358
+
359
+ `output_url` arrives in the task body, i.e. from the API — which a
360
+ compromise could turn hostile. Unchecked, it is an SSRF primitive. This
361
+ raises the bar against the obvious payloads:
362
+ - only `https` is allowed (a presigned R2 GET always is);
363
+ - IP-literal hosts (v4/v6) — including decimal/hex/octal and
364
+ trailing-dot encodings a browser's URL parser would canonicalize
365
+ into one, see `_is_ip_literal_host` — plus `localhost` and
366
+ `*.local` / `*.internal`, are refused: the cloud-metadata
367
+ (169.254.169.254), loopback, and internal-DNS targets an SSRF aims
368
+ at, never a real presigned host.
369
+
370
+ What it does NOT stop: a public hostname that resolves to a private
371
+ address (DNS rebinding) — out of scope for a zero-dependency kit; the
372
+ download additionally disables redirects so a 200-looking URL cannot
373
+ 302 into internal infrastructure.
374
+
375
+ On rejection: name only the scheme or host, never the full URL — its
376
+ query string carries the signing signature (a capability), and errors
377
+ get logged."""
378
+ try:
379
+ parsed = urlsplit(url)
380
+ except ValueError as exc:
381
+ raise VideoKitError(
382
+ "The ducking API returned an output_url that is not a valid URL."
383
+ ) from exc
384
+ if parsed.scheme != "https":
385
+ raise VideoKitError(
386
+ f'The ducking API\'s output_url uses an unsupported scheme "{parsed.scheme}"; '
387
+ "only https is allowed for the presigned download."
388
+ )
389
+ host = (parsed.hostname or "").lower()
390
+ if not host:
391
+ raise VideoKitError("The ducking API returned an output_url with no host.")
392
+
393
+ # Strip the DNS-root trailing dot(s) before the named-host blocklist, so
394
+ # "localhost." (which resolves identically to "localhost") cannot sneak
395
+ # through. Comparison-only: the fetch below still uses the original URL.
396
+ named_host = host.rstrip(".")
397
+ if (
398
+ _is_ip_literal_host(host)
399
+ or named_host == "localhost"
400
+ or named_host.endswith(".local")
401
+ or named_host.endswith(".internal")
402
+ ):
403
+ raise VideoKitError(
404
+ f'The ducking API\'s output_url points at a non-public host "{host}", which is '
405
+ "never a legitimate presigned download location; refusing to fetch it."
406
+ )
407
+
408
+
409
+ def download_ducked_mix(
410
+ url: str,
411
+ dest_path: StrPath,
412
+ *,
413
+ max_bytes: int,
414
+ expected_bytes: Optional[int] = None,
415
+ timeout: Optional[float] = None,
416
+ deadline: Optional[float] = None,
417
+ sleep: Sleep = time.sleep,
418
+ ) -> None:
419
+ """Download the finished mix, streamed to `dest_path` without ever
420
+ leaving partial bytes there.
421
+
422
+ Deliberately uses a FRESH, unauthenticated httpx.Client, never
423
+ `client._http`: `url` is a presigned link on a different host, and
424
+ routing it through the authenticated client would put the customer's
425
+ API key on a request to that host. Redirects are disabled (a presigned
426
+ GET never legitimately 302s). Retried through transient failures — by
427
+ the time there is something to download, the task has succeeded and the
428
+ account has been charged, so losing the mix to one 503 would mean
429
+ paying twice for it."""
430
+ assert_safe_download_url(url)
431
+ per_attempt = timeout if timeout is not None else DEFAULT_DOWNLOAD_TIMEOUT_SECONDS
432
+ dest = Path(dest_path)
433
+
434
+ def _attempt() -> None:
435
+ attempt_timeout = per_attempt
436
+ if deadline is not None:
437
+ remaining = deadline - time.monotonic()
438
+ if remaining <= 0:
439
+ raise VideoKitError(
440
+ "The ducked-mix download did not complete within the overall time "
441
+ "budget for this operation (shared with polling) and was stopped "
442
+ "before starting a fresh attempt past it."
443
+ )
444
+ attempt_timeout = min(per_attempt, remaining)
445
+
446
+ # Wall-clock ceiling for this WHOLE attempt (connect + stream), not
447
+ # just the inter-chunk gap httpx's own `timeout` bounds — see
448
+ # _DownloadTimeoutError. Read with time.monotonic() only, never
449
+ # wall-clock time.time(), so an NTP step can't shorten or extend it.
450
+ attempt_deadline = time.monotonic() + attempt_timeout
451
+
452
+ tmp_path = dest.parent / f".{dest.name}.{uuid.uuid4().hex}.part"
453
+ with httpx.Client(follow_redirects=False, timeout=attempt_timeout) as http:
454
+ try:
455
+ with http.stream("GET", url) as response:
456
+ # >= 300, not just >= 400: `follow_redirects=False` means
457
+ # a 3xx comes back as an ordinary response object here
458
+ # (unlike JS's `redirect:"error"` fetch, which throws),
459
+ # so it must be rejected explicitly or a redirect body
460
+ # would be written out as the "mix".
461
+ if response.status_code >= 300:
462
+ raise _HttpStatusError(
463
+ response.status_code, "Could not download the ducked mix"
464
+ )
465
+ total = 0
466
+ with open(tmp_path, "wb") as handle:
467
+ for chunk in response.iter_bytes():
468
+ # A server dribbling chunks just under httpx's
469
+ # inter-chunk read timeout never trips it (that
470
+ # timeout resets on every byte received); this
471
+ # wall-clock check bounds the attempt regardless
472
+ # of how the bytes are paced.
473
+ if time.monotonic() >= attempt_deadline:
474
+ raise _DownloadTimeoutError(attempt_timeout)
475
+ total += len(chunk)
476
+ if total > max_bytes:
477
+ raise VideoKitError(
478
+ "The ducked mix exceeded the maximum allowed size "
479
+ f"({max_bytes} bytes) and was refused."
480
+ )
481
+ handle.write(chunk)
482
+ if expected_bytes is not None and total != expected_bytes:
483
+ raise VideoKitError(
484
+ f"The ducked mix download was {total} bytes but the ducking "
485
+ f"API declared exactly {expected_bytes} bytes; refusing this "
486
+ "truncated or altered download."
487
+ )
488
+ os.replace(tmp_path, dest)
489
+ except Exception:
490
+ try:
491
+ os.remove(tmp_path)
492
+ except OSError:
493
+ pass
494
+ raise
495
+
496
+ _with_retry(_attempt, sleep=sleep)