From 56b49578009392f5736563b70d0b79b92cd46d84 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Wed, 22 Jul 2026 21:30:44 -0700 Subject: [PATCH 1/4] feat: add build_v2s_parts, SoundResult, and parse_sound_result --- src/sonilo/_requests.py | 31 ++++++++++- src/sonilo/resources/tasks.py | 24 +++++++++ src/sonilo/types.py | 98 +++++++++++++++++++++++++++++++++++ tests/test_video_to_sound.py | 87 +++++++++++++++++++++++++++++++ 4 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 tests/test_video_to_sound.py diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index a93b8ab..cf92b4e 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -5,7 +5,7 @@ from typing import Any, Dict, List, Optional, Tuple from sonilo.errors import SoniloError -from sonilo.types import Segment +from sonilo.types import Segment, SfxSegment DEFAULT_FILENAME = "video.mp4" @@ -164,6 +164,35 @@ def build_v2v_sfx_parts( return build_v2m_parts(video, video_url, prompt, segments) +def build_v2s_parts( + video: Any, + video_url: Optional[str], + music_prompt: Optional[str], + sfx_prompt: Optional[str], + segments: Optional[List[SfxSegment]], + preserve_speech: Optional[bool], + ducking: Optional[bool], +) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: + """Multipart parts shared by /v1/video-to-sound and + /v1/video-to-video-sound — their form fields are identical. + + These endpoints take `music_prompt`/`sfx_prompt` instead of a single + `prompt`, so build_v2m_parts is called with prompt=None. Booleans are only + emitted when explicitly passed: `ducking` is default-ON server-side, so an + unset value must not go out as "false". + """ + data, files, opened = build_v2m_parts(video, video_url, None, segments) + if music_prompt is not None: + data["music_prompt"] = music_prompt + if sfx_prompt is not None: + data["sfx_prompt"] = sfx_prompt + if preserve_speech is not None: + data["preserve_speech"] = "true" if preserve_speech else "false" + if ducking is not None: + data["ducking"] = "true" if ducking else "false" + return data, files, opened + + def build_sfx_t2s_data( prompt: str, duration: int, audio_format: Optional[str] ) -> Dict[str, str]: diff --git a/src/sonilo/resources/tasks.py b/src/sonilo/resources/tasks.py index 583c230..c0364d2 100644 --- a/src/sonilo/resources/tasks.py +++ b/src/sonilo/resources/tasks.py @@ -13,6 +13,7 @@ SfxMedia, SfxResult, SfxTask, + SoundResult, VideoResult, ) @@ -143,6 +144,29 @@ def parse_video_result(body: Dict[str, Any]) -> "VideoResult": raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e +def parse_sound_result(body: Dict[str, Any]) -> "SoundResult": + """Map a GET /v1/tasks/{id} body for a video-to-sound / video-to-video-sound + task to SoundResult; unknown fields are ignored.""" + try: + return SoundResult( + task_id=body["task_id"], + status=body["status"], + type=body.get("type"), + output_url=body.get("output_url"), + output_type=body.get("output_type"), + output_bytes=body.get("output_bytes"), + music=_media_from(body.get("music")), + music_processed=_media_from(body.get("music_processed")), + sfx=_media_from(body.get("sfx")), + duration_seconds=body.get("duration_seconds"), + cost=body.get("cost"), + error=body.get("error"), + refunded=body.get("refunded"), + ) + except KeyError as e: + raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e + + def parse_sfx_task(body: Dict[str, Any]) -> SfxTask: """Map a submission ack to SfxTask.""" try: diff --git a/src/sonilo/types.py b/src/sonilo/types.py index f780bae..24c64c3 100644 --- a/src/sonilo/types.py +++ b/src/sonilo/types.py @@ -263,3 +263,101 @@ async def asave( p = Path(path) p.write_bytes(response.content) return p + + +_SOUND_STEMS = ("music", "music_processed", "sfx") + + +def _download_to(url: str, path: Union[str, Path], timeout: float) -> Path: + """Fetch a presigned result URL to `path`. No API key is sent.""" + response = httpx.get(url, follow_redirects=True, timeout=timeout) + if response.status_code >= 400: + raise SoniloError(f"Download failed: HTTP {response.status_code}") + p = Path(path) + p.write_bytes(response.content) + return p + + +async def _adownload_to(url: str, path: Union[str, Path], timeout: float) -> Path: + """Async variant of _download_to.""" + async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) as http: + response = await http.get(url) + if response.status_code >= 400: + raise SoniloError(f"Download failed: HTTP {response.status_code}") + p = Path(path) + p.write_bytes(response.content) + return p + + +@dataclass +class SoundResult: + """State of a video-to-sound / video-to-video-sound task (`tasks.get`) or + its final result (`wait`/`generate`). + + The combined music+SFX result is `output_url` — a bare presigned URL rather + than a media object, because these endpoints render exactly one artifact + whose kind is announced by `output_type` ("audio" for /v1/video-to-sound, + "video" for /v1/video-to-video-sound). `music`, `music_processed` and `sfx` + are the individual stems; `music_processed` is present only when + preserve_speech or ducking altered the music bed. + """ + + task_id: str + status: str + type: Optional[str] = None + output_url: Optional[str] = None + output_type: Optional[str] = None + output_bytes: Optional[int] = None + music: Optional[SfxMedia] = None + music_processed: Optional[SfxMedia] = None + sfx: Optional[SfxMedia] = None + duration_seconds: Optional[float] = None + cost: Optional[float] = None + error: Optional[Dict[str, Any]] = None + refunded: Optional[bool] = None + + def _output(self) -> str: + if not self.output_url: + raise SoniloError(f"No output on this result (status={self.status})") + return self.output_url + + def _stem(self, which: str) -> str: + if which not in _SOUND_STEMS: + raise SoniloError( + f"Unknown stem {which!r}; expected one of {', '.join(_SOUND_STEMS)}" + ) + media = getattr(self, which) + if media is None: + raise SoniloError(f"No {which} stem on this result (status={self.status})") + return media.url + + def save(self, path: Union[str, Path], *, timeout: float = DOWNLOAD_TIMEOUT) -> Path: + """Download the combined result to `path` and return it. The URL is + presigned — no API key is sent.""" + return _download_to(self._output(), path, timeout) + + async def asave( + self, path: Union[str, Path], *, timeout: float = DOWNLOAD_TIMEOUT + ) -> Path: + """Async variant of save().""" + return await _adownload_to(self._output(), path, timeout) + + def save_stem( + self, + path: Union[str, Path], + *, + which: str = "music", + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Path: + """Download one stem ("music", "music_processed" or "sfx") to `path`.""" + return _download_to(self._stem(which), path, timeout) + + async def asave_stem( + self, + path: Union[str, Path], + *, + which: str = "music", + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Path: + """Async variant of save_stem().""" + return await _adownload_to(self._stem(which), path, timeout) diff --git a/tests/test_video_to_sound.py b/tests/test_video_to_sound.py new file mode 100644 index 0000000..5a8a4f8 --- /dev/null +++ b/tests/test_video_to_sound.py @@ -0,0 +1,87 @@ +import httpx +import pytest +import respx + +from sonilo import AsyncSonilo, Sonilo +from sonilo._requests import build_v2s_parts +from sonilo.errors import SoniloError +from sonilo.resources.tasks import parse_sound_result + +SUCCESS_BODY = { + "task_id": "sd1", + "type": "video_to_sound", + "status": "succeeded", + "output_url": "https://r2/sound.wav", + "output_type": "audio", + "output_bytes": 12, + "music": {"url": "https://r2/music.m4a", "content_type": "audio/mp4", "file_size": 5}, + "sfx": {"url": "https://r2/sfx.wav", "content_type": "audio/wav", "file_size": 4}, + "duration_seconds": 8.5, +} + + +def test_build_v2s_parts_emits_every_field(): + data, files, opened = build_v2s_parts( + None, + "https://x/v.mp4", + "uplifting orchestral", + "match the action", + [{"start": 0, "end": 2, "prompt": "whoosh"}], + True, + False, + ) + assert files is None and opened is False + assert data["video_url"] == "https://x/v.mp4" + assert data["music_prompt"] == "uplifting orchestral" + assert data["sfx_prompt"] == "match the action" + assert '"prompt": "whoosh"' in data["segments"] + assert data["preserve_speech"] == "true" + assert data["ducking"] == "false" + assert "prompt" not in data + assert "isolate_vocals" not in data + + +def test_build_v2s_parts_omits_unset_booleans(): + data, _, _ = build_v2s_parts(None, "https://x/v.mp4", None, None, None, None, None) + assert data == {"video_url": "https://x/v.mp4"} + + +def test_build_v2s_parts_requires_exactly_one_input(): + with pytest.raises(SoniloError): + build_v2s_parts(None, None, None, None, None, None, None) + with pytest.raises(SoniloError): + build_v2s_parts(b"bytes", "https://x/v.mp4", None, None, None, None, None) + + +def test_parse_sound_result_reads_output_and_stems(): + result = parse_sound_result(SUCCESS_BODY) + assert result.output_url == "https://r2/sound.wav" + assert result.output_type == "audio" + assert result.output_bytes == 12 + assert result.music.url == "https://r2/music.m4a" + assert result.sfx.content_type == "audio/wav" + assert result.music_processed is None + assert result.duration_seconds == 8.5 + + +@respx.mock +def test_sound_result_save_and_save_stem(tmp_path): + respx.get("https://r2/sound.wav").mock( + return_value=httpx.Response(200, content=b"mixed") + ) + respx.get("https://r2/music.m4a").mock( + return_value=httpx.Response(200, content=b"music") + ) + result = parse_sound_result(SUCCESS_BODY) + assert result.save(tmp_path / "out.wav").read_bytes() == b"mixed" + assert result.save_stem(tmp_path / "music.m4a", which="music").read_bytes() == b"music" + + +def test_sound_result_raises_for_missing_artifacts(): + result = parse_sound_result({"task_id": "sd2", "status": "processing"}) + with pytest.raises(SoniloError): + result.save("unused.wav") + with pytest.raises(SoniloError): + result.save_stem("unused.wav", which="music") + with pytest.raises(SoniloError): + result.save_stem("unused.wav", which="nonsense") From 5e54e75999a8371806461e202ffc7acdecb5c996 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Wed, 22 Jul 2026 21:32:02 -0700 Subject: [PATCH 2/4] feat: add video_to_sound and video_to_video_sound resources (sync + async) --- src/sonilo/_async_client.py | 4 + src/sonilo/_client.py | 4 + src/sonilo/resources/video_to_sound.py | 128 +++++++++++++++++++ src/sonilo/resources/video_to_video_sound.py | 128 +++++++++++++++++++ tests/test_video_to_sound.py | 60 +++++++++ 5 files changed, 324 insertions(+) create mode 100644 src/sonilo/resources/video_to_sound.py create mode 100644 src/sonilo/resources/video_to_video_sound.py diff --git a/src/sonilo/_async_client.py b/src/sonilo/_async_client.py index 79821bf..40a84d4 100644 --- a/src/sonilo/_async_client.py +++ b/src/sonilo/_async_client.py @@ -15,6 +15,8 @@ from sonilo.resources.video_to_sfx import AsyncVideoToSfx from sonilo.resources.video_to_video_music import AsyncVideoToVideoMusic from sonilo.resources.video_to_video_sfx import AsyncVideoToVideoSfx +from sonilo.resources.video_to_sound import AsyncVideoToSound +from sonilo.resources.video_to_video_sound import AsyncVideoToVideoSound from sonilo.types import StreamEvent @@ -39,6 +41,8 @@ def __init__( self.video_to_sfx = AsyncVideoToSfx(self) self.video_to_video_music = AsyncVideoToVideoMusic(self) self.video_to_video_sfx = AsyncVideoToVideoSfx(self) + self.video_to_sound = AsyncVideoToSound(self) + self.video_to_video_sound = AsyncVideoToVideoSound(self) self.account = AsyncAccount(self) self.tasks = AsyncTasks(self) diff --git a/src/sonilo/_client.py b/src/sonilo/_client.py index 259de1a..200dd4e 100644 --- a/src/sonilo/_client.py +++ b/src/sonilo/_client.py @@ -16,6 +16,8 @@ from sonilo.resources.video_to_sfx import VideoToSfx from sonilo.resources.video_to_video_music import VideoToVideoMusic from sonilo.resources.video_to_video_sfx import VideoToVideoSfx +from sonilo.resources.video_to_sound import VideoToSound +from sonilo.resources.video_to_video_sound import VideoToVideoSound from sonilo.types import StreamEvent DEFAULT_BASE_URL = "https://api.sonilo.com" @@ -60,6 +62,8 @@ def __init__( self.video_to_sfx = VideoToSfx(self) self.video_to_video_music = VideoToVideoMusic(self) self.video_to_video_sfx = VideoToVideoSfx(self) + self.video_to_sound = VideoToSound(self) + self.video_to_video_sound = VideoToVideoSound(self) self.account = Account(self) self.tasks = Tasks(self) diff --git a/src/sonilo/resources/video_to_sound.py b/src/sonilo/resources/video_to_sound.py new file mode 100644 index 0000000..4198131 --- /dev/null +++ b/src/sonilo/resources/video_to_sound.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, List, Optional + +from sonilo._requests import build_v2s_parts +from sonilo.resources.tasks import ( + DEFAULT_POLL_INTERVAL, + DEFAULT_WAIT_TIMEOUT, + parse_sfx_task, + parse_sound_result, +) +from sonilo.types import SfxSegment, SfxTask, SoundResult + +if TYPE_CHECKING: + from sonilo._async_client import AsyncSonilo + from sonilo._client import Sonilo + +PATH = "/v1/video-to-sound" + + +class VideoToSound: + def __init__(self, client: "Sonilo") -> None: + self._client = client + + def submit( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + music_prompt: Optional[str] = None, + sfx_prompt: Optional[str] = None, + segments: Optional[List[SfxSegment]] = None, + preserve_speech: Optional[bool] = None, + ducking: Optional[bool] = None, + ) -> SfxTask: + data, files, opened = build_v2s_parts( + video, video_url, music_prompt, sfx_prompt, segments, + preserve_speech, ducking, + ) + close_after = files["video"][1] if files is not None and opened else None + return parse_sfx_task( + self._client._post_json(PATH, data=data, files=files, close_after=close_after) + ) + + def generate( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + music_prompt: Optional[str] = None, + sfx_prompt: Optional[str] = None, + segments: Optional[List[SfxSegment]] = None, + preserve_speech: Optional[bool] = None, + ducking: Optional[bool] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> SoundResult: + task = self.submit( + video=video, + video_url=video_url, + music_prompt=music_prompt, + sfx_prompt=sfx_prompt, + segments=segments, + preserve_speech=preserve_speech, + ducking=ducking, + ) + return self._client.tasks.wait( + task.task_id, + poll_interval=poll_interval, + timeout=timeout, + parser=parse_sound_result, + ) + + +class AsyncVideoToSound: + def __init__(self, client: "AsyncSonilo") -> None: + self._client = client + + async def submit( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + music_prompt: Optional[str] = None, + sfx_prompt: Optional[str] = None, + segments: Optional[List[SfxSegment]] = None, + preserve_speech: Optional[bool] = None, + ducking: Optional[bool] = None, + ) -> SfxTask: + data, files, opened = build_v2s_parts( + video, video_url, music_prompt, sfx_prompt, segments, + preserve_speech, ducking, + ) + close_after = files["video"][1] if files is not None and opened else None + return parse_sfx_task( + await self._client._post_json( + PATH, data=data, files=files, close_after=close_after + ) + ) + + async def generate( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + music_prompt: Optional[str] = None, + sfx_prompt: Optional[str] = None, + segments: Optional[List[SfxSegment]] = None, + preserve_speech: Optional[bool] = None, + ducking: Optional[bool] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> SoundResult: + task = await self.submit( + video=video, + video_url=video_url, + music_prompt=music_prompt, + sfx_prompt=sfx_prompt, + segments=segments, + preserve_speech=preserve_speech, + ducking=ducking, + ) + return await self._client.tasks.wait( + task.task_id, + poll_interval=poll_interval, + timeout=timeout, + parser=parse_sound_result, + ) diff --git a/src/sonilo/resources/video_to_video_sound.py b/src/sonilo/resources/video_to_video_sound.py new file mode 100644 index 0000000..14e8c40 --- /dev/null +++ b/src/sonilo/resources/video_to_video_sound.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, List, Optional + +from sonilo._requests import build_v2s_parts +from sonilo.resources.tasks import ( + DEFAULT_POLL_INTERVAL, + DEFAULT_WAIT_TIMEOUT, + parse_sfx_task, + parse_sound_result, +) +from sonilo.types import SfxSegment, SfxTask, SoundResult + +if TYPE_CHECKING: + from sonilo._async_client import AsyncSonilo + from sonilo._client import Sonilo + +PATH = "/v1/video-to-video-sound" + + +class VideoToVideoSound: + def __init__(self, client: "Sonilo") -> None: + self._client = client + + def submit( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + music_prompt: Optional[str] = None, + sfx_prompt: Optional[str] = None, + segments: Optional[List[SfxSegment]] = None, + preserve_speech: Optional[bool] = None, + ducking: Optional[bool] = None, + ) -> SfxTask: + data, files, opened = build_v2s_parts( + video, video_url, music_prompt, sfx_prompt, segments, + preserve_speech, ducking, + ) + close_after = files["video"][1] if files is not None and opened else None + return parse_sfx_task( + self._client._post_json(PATH, data=data, files=files, close_after=close_after) + ) + + def generate( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + music_prompt: Optional[str] = None, + sfx_prompt: Optional[str] = None, + segments: Optional[List[SfxSegment]] = None, + preserve_speech: Optional[bool] = None, + ducking: Optional[bool] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> SoundResult: + task = self.submit( + video=video, + video_url=video_url, + music_prompt=music_prompt, + sfx_prompt=sfx_prompt, + segments=segments, + preserve_speech=preserve_speech, + ducking=ducking, + ) + return self._client.tasks.wait( + task.task_id, + poll_interval=poll_interval, + timeout=timeout, + parser=parse_sound_result, + ) + + +class AsyncVideoToVideoSound: + def __init__(self, client: "AsyncSonilo") -> None: + self._client = client + + async def submit( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + music_prompt: Optional[str] = None, + sfx_prompt: Optional[str] = None, + segments: Optional[List[SfxSegment]] = None, + preserve_speech: Optional[bool] = None, + ducking: Optional[bool] = None, + ) -> SfxTask: + data, files, opened = build_v2s_parts( + video, video_url, music_prompt, sfx_prompt, segments, + preserve_speech, ducking, + ) + close_after = files["video"][1] if files is not None and opened else None + return parse_sfx_task( + await self._client._post_json( + PATH, data=data, files=files, close_after=close_after + ) + ) + + async def generate( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + music_prompt: Optional[str] = None, + sfx_prompt: Optional[str] = None, + segments: Optional[List[SfxSegment]] = None, + preserve_speech: Optional[bool] = None, + ducking: Optional[bool] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> SoundResult: + task = await self.submit( + video=video, + video_url=video_url, + music_prompt=music_prompt, + sfx_prompt=sfx_prompt, + segments=segments, + preserve_speech=preserve_speech, + ducking=ducking, + ) + return await self._client.tasks.wait( + task.task_id, + poll_interval=poll_interval, + timeout=timeout, + parser=parse_sound_result, + ) diff --git a/tests/test_video_to_sound.py b/tests/test_video_to_sound.py index 5a8a4f8..66a5b2f 100644 --- a/tests/test_video_to_sound.py +++ b/tests/test_video_to_sound.py @@ -85,3 +85,63 @@ def test_sound_result_raises_for_missing_artifacts(): result.save_stem("unused.wav", which="music") with pytest.raises(SoniloError): result.save_stem("unused.wav", which="nonsense") + + +@respx.mock +def test_video_to_sound_generate_polls_to_sound_result(): + respx.post("https://api.sonilo.com/v1/video-to-sound").mock( + return_value=httpx.Response(202, json={"task_id": "sd1", "status": "processing"}) + ) + respx.get("https://api.sonilo.com/v1/tasks/sd1").mock( + return_value=httpx.Response(200, json=SUCCESS_BODY) + ) + result = Sonilo(api_key="k").video_to_sound.generate( + video_url="https://x/v.mp4", + music_prompt="uplifting", + sfx_prompt="footsteps", + ducking=False, + poll_interval=0, + ) + assert result.output_url == "https://r2/sound.wav" + assert result.music.url == "https://r2/music.m4a" + content = respx.calls[0].request.content + assert b"music_prompt" in content and b"sfx_prompt" in content + assert b"ducking" in content + + +@respx.mock +def test_video_to_video_sound_submit_hits_its_own_path(): + route = respx.post("https://api.sonilo.com/v1/video-to-video-sound").mock( + return_value=httpx.Response(202, json={"task_id": "sd2", "status": "processing"}) + ) + task = Sonilo(api_key="k").video_to_video_sound.submit( + video_url="https://x/v.mp4", preserve_speech=True + ) + assert task.task_id == "sd2" + assert route.called + assert b"preserve_speech" in respx.calls[0].request.content + + +@respx.mock +async def test_async_video_to_sound_generate(): + respx.post("https://api.sonilo.com/v1/video-to-sound").mock( + return_value=httpx.Response(202, json={"task_id": "sd1", "status": "processing"}) + ) + respx.get("https://api.sonilo.com/v1/tasks/sd1").mock( + return_value=httpx.Response(200, json=SUCCESS_BODY) + ) + async with AsyncSonilo(api_key="k") as client: + result = await client.video_to_sound.generate( + video_url="https://x/v.mp4", poll_interval=0 + ) + assert result.output_type == "audio" + + +@respx.mock +async def test_async_video_to_video_sound_submit(): + respx.post("https://api.sonilo.com/v1/video-to-video-sound").mock( + return_value=httpx.Response(202, json={"task_id": "sd2", "status": "processing"}) + ) + async with AsyncSonilo(api_key="k") as client: + task = await client.video_to_video_sound.submit(video_url="https://x/v.mp4") + assert task.task_id == "sd2" From ccc38fe5d7212a1ee70c7bd4eba73f5965aa604a Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Wed, 22 Jul 2026 21:32:45 -0700 Subject: [PATCH 3/4] docs: document video-to-sound endpoints and the free trial; bump to 0.5.0 --- README.md | 52 ++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/sonilo/_version.py | 2 +- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f02b55d..1d2d253 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,46 @@ sfx = client.video_to_video_sfx.generate( sfx.save("with_sfx.mp4") ``` +## Video to sound + +`video_to_sound` and `video_to_video_sound` generate a music bed and sound +effects for the same clip and return them mixed into a single soundtrack — one +call, one charge, instead of chaining two requests. `video_to_sound` returns the +mixed audio; `video_to_video_sound` returns the source video with that audio +muxed in. Both are async-only, and both take the same options. + +```python +from sonilo import Sonilo + +client = Sonilo() + +result = client.video_to_sound.generate( + video_url="https://example.com/clip.mp4", + music_prompt="uplifting orchestral score", + sfx_prompt="match the on-screen action", +) +result.save("soundtrack.wav") +``` + +The mixed result is `output_url` (`output_type` is `"audio"` here, `"video"` +for `video_to_video_sound`). The individual stems come back alongside it, so +you can re-balance the mix yourself: + +```python +result.save_stem("music.m4a", which="music") +result.save_stem("sfx.wav", which="sfx") +``` + +`preserve_speech=True` keeps the speech from the source video, and `ducking` +(on by default) dips the music under it — pass `ducking=False` to opt out. +`segments` takes the same `{"start", "end", "prompt"}` list as `video_to_sfx`. +Input videos may be at most 180 seconds long. + +Use `submit()` instead of `generate()` to get a `task_id` back immediately and +poll it yourself with `client.tasks.wait(task_id, parser=parse_sound_result)`. +`AsyncSonilo` exposes the same two resources with `await`-able +`submit`/`generate` and `asave`/`asave_stem`. + ## Streaming ```python @@ -196,6 +236,18 @@ result.save("audio.wav") # video-to-sfx returns the generated audio only task keeps running server-side and can still be polled afterwards. Result URLs are presigned and expire; download promptly or re-fetch via `tasks.get`. +## Free trial + +Accounts created through self-serve signup start with free runs on every +endpoint — no card required: + +| Free runs | Endpoints | +| --- | --- | +| 2 each | text-to-music, text-to-sfx, audio-ducking | +| 1 each | video-to-music, video-to-sfx, video-to-video-music, video-to-video-sfx, video-to-sound, video-to-video-sound | + +Once an endpoint's free runs are used up, calls to it bill at the normal rate. + ## Account ```python diff --git a/pyproject.toml b/pyproject.toml index 449e58d..91e01c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.4.0" +version = "0.5.0" description = "Official Python client for the Sonilo API" readme = "README.md" license = "MIT" diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index 6a9beea..3d18726 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.4.0" +__version__ = "0.5.0" From 15f4d20529b780201a53e28a9b13c554cf2a432a Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Wed, 22 Jul 2026 23:00:41 -0700 Subject: [PATCH 4/4] fix(video-kit): allow sonilo 0.5.x and release 0.1.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sonilo-video-kit pinned sonilo>=0.3,<0.5, so bumping the core package to 0.5.0 made the two packages unresolvable together — CI installs both editable in one command and failed with ResolutionImpossible on every matrix entry. Widened to <0.6 and bumped video-kit to 0.1.1, since the 0.1.0 already on PyPI carries the old ceiling: without a release, anyone installing sonilo-video-kit alongside sonilo 0.5 hits the same conflict. Verified by reproducing the CI install in a clean 3.12 venv: pip install -e ".[dev]" -e "./sonilo-video-kit[dev]" resolves, and both suites pass (167 + 52). --- sonilo-video-kit/pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sonilo-video-kit/pyproject.toml b/sonilo-video-kit/pyproject.toml index 009413b..d0dd737 100644 --- a/sonilo-video-kit/pyproject.toml +++ b/sonilo-video-kit/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "hatchling.build" [project] name = "sonilo-video-kit" -version = "0.1.0" +version = "0.1.1" description = "Video helpers for the Sonilo API: generate a soundtrack and mix it into your video with ffmpeg" readme = "README.md" license = "MIT" requires-python = ">=3.9" authors = [{ name = "Sonilo AI" }] -dependencies = ["sonilo>=0.3,<0.5"] +dependencies = ["sonilo>=0.3,<0.6"] keywords = ["sonilo", "video", "music", "ffmpeg", "soundtrack", "ducking", "ai"] [project.urls]