From dd806b622777efed44da4a701819a52a38696199 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sat, 18 Jul 2026 15:38:26 -0700 Subject: [PATCH 1/4] feat(sonilo): add VideoResult, parse_video_result, v2v builders, v2m async fields --- src/sonilo/_requests.py | 77 ++++++++++++++++++++++++++++------- src/sonilo/resources/tasks.py | 29 ++++++++++++- src/sonilo/types.py | 54 ++++++++++++++++++++++-- tests/test_requests.py | 39 +++++++++++++++++- tests/test_sfx_types.py | 61 ++++++++++++++++++++++++++- 5 files changed, 240 insertions(+), 20 deletions(-) diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index 9ce13c8..e81cd0b 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -66,18 +66,31 @@ def build_v2m_parts( return data, files, opened -def _resolve_music_mode(mode: Optional[str], isolate_vocals: Optional[bool]) -> str: - """isolate_vocals only works with async processing: auto-select mode - "async" when the caller didn't specify one, but fail fast (mirroring the - video/video_url XOR check above) if they explicitly asked for anything - else. Without isolate_vocals, submit() still needs an async response - (a task_id ack, not a stream), so "async" is also the default there. +def _resolve_music_mode( + mode: Optional[str], + isolate_vocals: Optional[bool], + preserve_speech: Optional[bool] = None, + output_format: Optional[str] = None, + ducking: Optional[bool] = None, +) -> str: + """isolate_vocals/preserve_speech/ducking/output_format='wav' only work + with async processing: auto-select mode "async" when the caller didn't + specify one, but fail fast if they explicitly asked for anything else. + submit() also needs an async response (a task_id ack, not a stream), so + "async" is the default regardless. """ - if isolate_vocals: - if mode is not None and mode != "async": - raise SoniloError("isolate_vocals=True requires mode='async'") - return "async" - return mode or "async" + needs_async = ( + bool(isolate_vocals) + or bool(preserve_speech) + or output_format == "wav" + or ducking is not None + ) + if needs_async and mode is not None and mode != "async": + raise SoniloError( + "isolate_vocals/preserve_speech/ducking/output_format='wav' " + "require mode='async'" + ) + return "async" if needs_async else (mode or "async") def build_v2m_async_parts( @@ -87,17 +100,53 @@ def build_v2m_async_parts( segments: Optional[List[Segment]], mode: Optional[str], isolate_vocals: Optional[bool], + preserve_speech: Optional[bool] = None, + output_format: Optional[str] = None, + ducking: Optional[bool] = None, ) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: - """Like build_v2m_parts, plus the async-only `mode`/`isolate_vocals` - fields used by the video-to-music submit()/generate_async() path.""" - resolved_mode = _resolve_music_mode(mode, isolate_vocals) + """Like build_v2m_parts, plus the async-only fields for the + video-to-music submit()/generate_async() path.""" + resolved_mode = _resolve_music_mode( + mode, isolate_vocals, preserve_speech, output_format, ducking + ) data, files, opened = build_v2m_parts(video, video_url, prompt, segments) data["mode"] = resolved_mode if isolate_vocals is not None: data["isolate_vocals"] = "true" if isolate_vocals else "false" + if preserve_speech is not None: + data["preserve_speech"] = "true" if preserve_speech else "false" + if output_format is not None: + data["output_format"] = output_format + if ducking is not None: + data["ducking"] = "true" if ducking else "false" return data, files, opened +def build_v2v_music_parts( + video: Any, + video_url: Optional[str], + prompt: Optional[str], + preserve_speech: Optional[bool], + isolate_vocals: Optional[bool], +) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: + data, files, opened = build_v2m_parts(video, video_url, prompt, None) + if preserve_speech is not None: + data["preserve_speech"] = "true" if preserve_speech else "false" + if isolate_vocals is not None: + data["isolate_vocals"] = "true" if isolate_vocals else "false" + return data, files, opened + + +def build_v2v_sfx_parts( + video: Any, + video_url: Optional[str], + prompt: Optional[str], + segments: Optional[List[Segment]], +) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: + # build_v2m_parts JSON-serializes `segments` — fine for SFX start/end segments too. + return build_v2m_parts(video, video_url, prompt, segments) + + 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 5fe9bcc..583c230 100644 --- a/src/sonilo/resources/tasks.py +++ b/src/sonilo/resources/tasks.py @@ -6,7 +6,15 @@ from urllib.parse import quote from sonilo.errors import SoniloError, TaskFailedError, TaskTimeoutError -from sonilo.types import MusicAudioMedia, MusicResult, MusicTitle, SfxMedia, SfxResult, SfxTask +from sonilo.types import ( + MusicAudioMedia, + MusicResult, + MusicTitle, + SfxMedia, + SfxResult, + SfxTask, + VideoResult, +) if TYPE_CHECKING: from sonilo._async_client import AsyncSonilo @@ -106,6 +114,7 @@ def parse_music_result(body: Dict[str, Any]) -> MusicResult: audio=_music_audio_list_from(body.get("audio")), vocals=_media_from(body.get("vocals")), mux=_music_audio_list_from(body.get("mux")), + ducked=_music_audio_list_from(body.get("ducked")), title=_music_title_from(body.get("title")), duration_seconds=body.get("duration_seconds"), cost=body.get("cost"), @@ -116,6 +125,24 @@ def parse_music_result(body: Dict[str, Any]) -> MusicResult: raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e +def parse_video_result(body: Dict[str, Any]) -> "VideoResult": + """Map a GET /v1/tasks/{id} body for a video-to-video task to VideoResult; + unknown fields are ignored.""" + try: + return VideoResult( + task_id=body["task_id"], + status=body["status"], + type=body.get("type"), + video=_media_from(body.get("video")), + 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 3bcecc8..f780bae 100644 --- a/src/sonilo/types.py +++ b/src/sonilo/types.py @@ -154,6 +154,7 @@ class MusicResult: audio: Optional[List[MusicAudioMedia]] = None vocals: Optional[SfxMedia] = None mux: Optional[List[MusicAudioMedia]] = None + ducked: Optional[List[MusicAudioMedia]] = None title: Optional[MusicTitle] = None duration_seconds: Optional[float] = None cost: Optional[float] = None @@ -165,9 +166,9 @@ def _media(self, which: str, index: int) -> Union[SfxMedia, MusicAudioMedia]: if self.vocals is None: raise SoniloError(f"No vocals on this result (status={self.status})") return self.vocals - if which not in ("audio", "mux"): - raise SoniloError('which must be "audio", "vocals", or "mux"') - items = self.audio if which == "audio" else self.mux + if which not in ("audio", "mux", "ducked"): + raise SoniloError('which must be "audio", "vocals", "mux", or "ducked"') + items = {"audio": self.audio, "mux": self.mux, "ducked": self.ducked}[which] if not items: raise SoniloError(f"No {which} on this result (status={self.status})") try: @@ -215,3 +216,50 @@ async def asave( p = Path(path) p.write_bytes(response.content) return p + + +@dataclass +class VideoResult: + """State of an async video-to-video task (`tasks.get`) or its final result. + + The output is a re-hosted video (generated music or SFX muxed into the + source picture); `video` is a single presigned media object. + """ + + task_id: str + status: str + type: Optional[str] = None + video: Optional[SfxMedia] = None + duration_seconds: Optional[float] = None + cost: Optional[float] = None + error: Optional[Dict[str, Any]] = None + refunded: Optional[bool] = None + + def _media(self) -> SfxMedia: + if self.video is None: + raise SoniloError(f"No video on this result (status={self.status})") + return self.video + + def save(self, path: Union[str, Path], *, timeout: float = DOWNLOAD_TIMEOUT) -> Path: + """Download the result video to `path` and return it. The URL is + presigned — no API key is sent.""" + media = self._media() + response = httpx.get(media.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 asave( + self, path: Union[str, Path], *, timeout: float = DOWNLOAD_TIMEOUT + ) -> Path: + """Async variant of save().""" + media = self._media() + async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) as http: + response = await http.get(media.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 diff --git a/tests/test_requests.py b/tests/test_requests.py index ba971aa..398eb18 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -3,7 +3,14 @@ import pytest -from sonilo._requests import build_t2m_data, build_v2m_parts, normalize_video +from sonilo._requests import ( + build_t2m_data, + build_v2m_async_parts, + build_v2m_parts, + build_v2v_music_parts, + build_v2v_sfx_parts, + normalize_video, +) from sonilo.errors import SoniloError @@ -83,3 +90,33 @@ def test_build_v2m_parts_with_path_propagates_opened(tmp_path): assert data == {} finally: files["video"][1].close() + + +def test_v2m_async_parts_new_fields_and_default_mode(): + data, _, _ = build_v2m_async_parts( + None, "https://x/v.mp4", None, None, None, None, + preserve_speech=True, output_format="wav", ducking=False, + ) + assert data["mode"] == "async" + assert data["preserve_speech"] == "true" + assert data["output_format"] == "wav" + assert data["ducking"] == "false" + + +def test_v2m_async_parts_omits_ducking_when_none(): + data, _, _ = build_v2m_async_parts( + None, "https://x/v.mp4", None, None, None, None, + ) + assert "ducking" not in data + + +def test_v2v_music_parts_forwards_alias(): + data, _, _ = build_v2v_music_parts(None, "https://x/v.mp4", "p", True, None) + assert data == {"video_url": "https://x/v.mp4", "prompt": "p", "preserve_speech": "true"} + + +def test_v2v_sfx_parts_serializes_segments(): + data, _, _ = build_v2v_sfx_parts( + None, "https://x/v.mp4", None, [{"start": 0, "end": 2, "prompt": "boom"}] + ) + assert json.loads(data["segments"]) == [{"start": 0, "end": 2, "prompt": "boom"}] diff --git a/tests/test_sfx_types.py b/tests/test_sfx_types.py index 3a9753a..083afe6 100644 --- a/tests/test_sfx_types.py +++ b/tests/test_sfx_types.py @@ -10,7 +10,7 @@ TaskFailedError, TaskTimeoutError, ) -from sonilo.types import DOWNLOAD_TIMEOUT +from sonilo.types import DOWNLOAD_TIMEOUT, MusicAudioMedia, MusicResult, VideoResult AUDIO = SfxMedia(url="https://r2.example.com/audio.m4a", content_type="audio/mp4", file_size=10) @@ -127,6 +127,65 @@ def spy_init(self, *args, **kwargs): assert captured["timeout"] == DOWNLOAD_TIMEOUT +def make_video_result(**overrides) -> VideoResult: + kwargs = { + "task_id": "v1", + "status": "succeeded", + "video": SfxMedia( + url="https://r2.example.com/video.mp4", content_type="video/mp4", file_size=99 + ), + } + kwargs.update(overrides) + return VideoResult(**kwargs) + + +def test_video_result_fields(): + result = make_video_result(type="video_to_video_music", duration_seconds=5.0) + assert result.task_id == "v1" + assert result.status == "succeeded" + assert result.type == "video_to_video_music" + assert result.duration_seconds == 5.0 + + +@respx.mock +def test_video_result_save_downloads_video(tmp_path): + respx.get("https://r2.example.com/video.mp4").mock( + return_value=httpx.Response(200, content=b"videobytes") + ) + out = make_video_result().save(tmp_path / "out.mp4") + assert out.read_bytes() == b"videobytes" + assert "authorization" not in respx.calls.last.request.headers + + +@respx.mock +async def test_video_result_asave_downloads_video(tmp_path): + respx.get("https://r2.example.com/video.mp4").mock( + return_value=httpx.Response(200, content=b"videobytes") + ) + out = await make_video_result().asave(tmp_path / "out.mp4") + assert out.read_bytes() == b"videobytes" + + +def test_video_result_save_missing_media_raises(tmp_path): + result = VideoResult(task_id="v1", status="processing") + with pytest.raises(SoniloError): + result.save(tmp_path / "out.mp4") + + +@respx.mock +def test_music_result_save_which_ducked(tmp_path): + respx.get("https://r2.example.com/ducked0.m4a").mock( + return_value=httpx.Response(200, content=b"duckedbytes") + ) + result = MusicResult( + task_id="m1", + status="succeeded", + ducked=[MusicAudioMedia(stream_index=0, url="https://r2.example.com/ducked0.m4a")], + ) + out = result.save(tmp_path / "d.m4a", which="ducked") + assert out.read_bytes() == b"duckedbytes" + + def test_task_errors_carry_fields(): failed = TaskFailedError("boom", code="GENERATION_FAILED", task_id="t1", refunded=True) assert isinstance(failed, SoniloError) From 402734ee81f366394bebac4af38813758d883534 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sat, 18 Jul 2026 15:39:45 -0700 Subject: [PATCH 2/4] feat(sonilo): add video_to_video_music and video_to_video_sfx resources --- src/sonilo/_async_client.py | 4 + src/sonilo/_client.py | 4 + src/sonilo/resources/video_to_video_music.py | 114 +++++++++++++++++++ src/sonilo/resources/video_to_video_sfx.py | 104 +++++++++++++++++ tests/test_video_to_video.py | 45 ++++++++ 5 files changed, 271 insertions(+) create mode 100644 src/sonilo/resources/video_to_video_music.py create mode 100644 src/sonilo/resources/video_to_video_sfx.py create mode 100644 tests/test_video_to_video.py diff --git a/src/sonilo/_async_client.py b/src/sonilo/_async_client.py index 8612e43..79821bf 100644 --- a/src/sonilo/_async_client.py +++ b/src/sonilo/_async_client.py @@ -13,6 +13,8 @@ from sonilo.resources.text_to_sfx import AsyncTextToSfx from sonilo.resources.video_to_music import AsyncVideoToMusic 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.types import StreamEvent @@ -35,6 +37,8 @@ def __init__( self.video_to_music = AsyncVideoToMusic(self) self.text_to_sfx = AsyncTextToSfx(self) self.video_to_sfx = AsyncVideoToSfx(self) + self.video_to_video_music = AsyncVideoToVideoMusic(self) + self.video_to_video_sfx = AsyncVideoToVideoSfx(self) self.account = AsyncAccount(self) self.tasks = AsyncTasks(self) diff --git a/src/sonilo/_client.py b/src/sonilo/_client.py index c7a3607..259de1a 100644 --- a/src/sonilo/_client.py +++ b/src/sonilo/_client.py @@ -14,6 +14,8 @@ from sonilo.resources.text_to_sfx import TextToSfx from sonilo.resources.video_to_music import VideoToMusic 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.types import StreamEvent DEFAULT_BASE_URL = "https://api.sonilo.com" @@ -56,6 +58,8 @@ def __init__( self.video_to_music = VideoToMusic(self) self.text_to_sfx = TextToSfx(self) self.video_to_sfx = VideoToSfx(self) + self.video_to_video_music = VideoToVideoMusic(self) + self.video_to_video_sfx = VideoToVideoSfx(self) self.account = Account(self) self.tasks = Tasks(self) diff --git a/src/sonilo/resources/video_to_video_music.py b/src/sonilo/resources/video_to_video_music.py new file mode 100644 index 0000000..618a927 --- /dev/null +++ b/src/sonilo/resources/video_to_video_music.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + +from sonilo._requests import build_v2v_music_parts +from sonilo.resources.tasks import ( + DEFAULT_POLL_INTERVAL, + DEFAULT_WAIT_TIMEOUT, + parse_sfx_task, + parse_video_result, +) +from sonilo.types import SfxTask, VideoResult + +if TYPE_CHECKING: + from sonilo._async_client import AsyncSonilo + from sonilo._client import Sonilo + +PATH = "/v1/video-to-video-music" + + +class VideoToVideoMusic: + def __init__(self, client: "Sonilo") -> None: + self._client = client + + def submit( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + preserve_speech: Optional[bool] = None, + isolate_vocals: Optional[bool] = None, + ) -> SfxTask: + data, files, opened = build_v2v_music_parts( + video, video_url, prompt, preserve_speech, isolate_vocals + ) + 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, + prompt: Optional[str] = None, + preserve_speech: Optional[bool] = None, + isolate_vocals: Optional[bool] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> VideoResult: + task = self.submit( + video=video, + video_url=video_url, + prompt=prompt, + preserve_speech=preserve_speech, + isolate_vocals=isolate_vocals, + ) + return self._client.tasks.wait( + task.task_id, + poll_interval=poll_interval, + timeout=timeout, + parser=parse_video_result, + ) + + +class AsyncVideoToVideoMusic: + def __init__(self, client: "AsyncSonilo") -> None: + self._client = client + + async def submit( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + preserve_speech: Optional[bool] = None, + isolate_vocals: Optional[bool] = None, + ) -> SfxTask: + data, files, opened = build_v2v_music_parts( + video, video_url, prompt, preserve_speech, isolate_vocals + ) + 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, + prompt: Optional[str] = None, + preserve_speech: Optional[bool] = None, + isolate_vocals: Optional[bool] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> VideoResult: + task = await self.submit( + video=video, + video_url=video_url, + prompt=prompt, + preserve_speech=preserve_speech, + isolate_vocals=isolate_vocals, + ) + return await self._client.tasks.wait( + task.task_id, + poll_interval=poll_interval, + timeout=timeout, + parser=parse_video_result, + ) diff --git a/src/sonilo/resources/video_to_video_sfx.py b/src/sonilo/resources/video_to_video_sfx.py new file mode 100644 index 0000000..3e8baa6 --- /dev/null +++ b/src/sonilo/resources/video_to_video_sfx.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, List, Optional + +from sonilo._requests import build_v2v_sfx_parts +from sonilo.resources.tasks import ( + DEFAULT_POLL_INTERVAL, + DEFAULT_WAIT_TIMEOUT, + parse_sfx_task, + parse_video_result, +) +from sonilo.types import SfxSegment, SfxTask, VideoResult + +if TYPE_CHECKING: + from sonilo._async_client import AsyncSonilo + from sonilo._client import Sonilo + +PATH = "/v1/video-to-video-sfx" + + +class VideoToVideoSfx: + def __init__(self, client: "Sonilo") -> None: + self._client = client + + def submit( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + segments: Optional[List[SfxSegment]] = None, + ) -> SfxTask: + data, files, opened = build_v2v_sfx_parts(video, video_url, prompt, segments) + 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, + prompt: Optional[str] = None, + segments: Optional[List[SfxSegment]] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> VideoResult: + task = self.submit( + video=video, + video_url=video_url, + prompt=prompt, + segments=segments, + ) + return self._client.tasks.wait( + task.task_id, + poll_interval=poll_interval, + timeout=timeout, + parser=parse_video_result, + ) + + +class AsyncVideoToVideoSfx: + def __init__(self, client: "AsyncSonilo") -> None: + self._client = client + + async def submit( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + segments: Optional[List[SfxSegment]] = None, + ) -> SfxTask: + data, files, opened = build_v2v_sfx_parts(video, video_url, prompt, segments) + 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, + prompt: Optional[str] = None, + segments: Optional[List[SfxSegment]] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> VideoResult: + task = await self.submit( + video=video, + video_url=video_url, + prompt=prompt, + segments=segments, + ) + return await self._client.tasks.wait( + task.task_id, + poll_interval=poll_interval, + timeout=timeout, + parser=parse_video_result, + ) diff --git a/tests/test_video_to_video.py b/tests/test_video_to_video.py new file mode 100644 index 0000000..15b1076 --- /dev/null +++ b/tests/test_video_to_video.py @@ -0,0 +1,45 @@ +import httpx +import pytest +import respx + +from sonilo import Sonilo + + +@respx.mock +def test_v2v_music_submit_and_poll(): + respx.post("https://api.sonilo.com/v1/video-to-video-music").mock( + return_value=httpx.Response(202, json={"task_id": "v1", "status": "processing"}) + ) + respx.get("https://api.sonilo.com/v1/tasks/v1").mock( + return_value=httpx.Response( + 200, + json={ + "task_id": "v1", + "type": "video_to_video_music", + "status": "succeeded", + "video": {"url": "https://r2/o.mp4", "content_type": "video/mp4", "file_size": 9}, + "duration_seconds": 4.0, + }, + ) + ) + client = Sonilo(api_key="k") + result = client.video_to_video_music.generate( + video_url="https://x/v.mp4", preserve_speech=True, poll_interval=0 + ) + assert result.video.url == "https://r2/o.mp4" + assert result.duration_seconds == 4.0 + request = respx.calls[0].request + assert b"preserve_speech" in request.content + + +@respx.mock +def test_v2v_sfx_submit_serializes_segments(): + respx.post("https://api.sonilo.com/v1/video-to-video-sfx").mock( + return_value=httpx.Response(202, json={"task_id": "s1", "status": "processing"}) + ) + client = Sonilo(api_key="k") + task = client.video_to_video_sfx.submit( + video_url="https://x/v.mp4", segments=[{"start": 0, "end": 2, "prompt": "boom"}] + ) + assert task.task_id == "s1" + assert b"segments" in respx.calls[0].request.content From 951789008c078dbf34f6b762e51bb095cb94ef1b Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sat, 18 Jul 2026 15:41:21 -0700 Subject: [PATCH 3/4] feat(sonilo): add preserve_speech/output_format/ducking to video_to_music and async text_to_music --- src/sonilo/_requests.py | 17 +++++ src/sonilo/resources/text_to_music.py | 86 +++++++++++++++++++++++++- src/sonilo/resources/video_to_music.py | 44 ++++++++++--- tests/test_video_to_video.py | 42 +++++++++++++ 4 files changed, 178 insertions(+), 11 deletions(-) diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index e81cd0b..a93b8ab 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -19,6 +19,23 @@ def build_t2m_data( return data +def build_t2m_async_data( + prompt: str, + duration: int, + segments: Optional[List[Segment]], + mode: Optional[str], + output_format: Optional[str], +) -> Dict[str, str]: + data = build_t2m_data(prompt, duration, segments) + resolved = mode or "async" + if resolved != "async": + raise SoniloError("text-to-music submit() requires mode='async'") + data["mode"] = resolved + if output_format is not None: + data["output_format"] = output_format + return data + + def normalize_video(video: Any) -> Tuple[str, Any, bool]: """Normalize a video input into (filename, httpx-uploadable, opened_here). diff --git a/src/sonilo/resources/text_to_music.py b/src/sonilo/resources/text_to_music.py index ad64495..f74e87a 100644 --- a/src/sonilo/resources/text_to_music.py +++ b/src/sonilo/resources/text_to_music.py @@ -2,9 +2,15 @@ from typing import TYPE_CHECKING, AsyncIterator, Iterator, List, Optional -from sonilo._requests import build_t2m_data +from sonilo._requests import build_t2m_async_data, build_t2m_data from sonilo._streaming import acollect_track, collect_track -from sonilo.types import Segment, StreamEvent, Track +from sonilo.resources.tasks import ( + DEFAULT_POLL_INTERVAL, + DEFAULT_WAIT_TIMEOUT, + parse_music_result, + parse_sfx_task, +) +from sonilo.types import MusicResult, Segment, SfxTask, StreamEvent, Track if TYPE_CHECKING: from sonilo._async_client import AsyncSonilo @@ -36,6 +42,44 @@ def generate( ) -> Track: return collect_track(self.stream(prompt=prompt, duration=duration, segments=segments)) + def submit( + self, + *, + prompt: str, + duration: int, + segments: Optional[List[Segment]] = None, + mode: Optional[str] = None, + output_format: Optional[str] = None, + ) -> SfxTask: + """Submit an async text-to-music task; poll with + `client.tasks.wait(task_id, parser=sonilo.resources.tasks.parse_music_result)`. + Required for output_format="wav". `stream()`/`generate()` remain the + streaming path. + """ + data = build_t2m_async_data(prompt, duration, segments, mode, output_format) + return parse_sfx_task(self._client._post_json(PATH, data=data)) + + def generate_async( + self, + *, + prompt: str, + duration: int, + segments: Optional[List[Segment]] = None, + mode: Optional[str] = None, + output_format: Optional[str] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> MusicResult: + """submit() + tasks.wait(), returning the parsed MusicResult.""" + task = self.submit( + prompt=prompt, duration=duration, segments=segments, + mode=mode, output_format=output_format, + ) + return self._client.tasks.wait( + task.task_id, poll_interval=poll_interval, timeout=timeout, + parser=parse_music_result, + ) + class AsyncTextToMusic: def __init__(self, client: "AsyncSonilo") -> None: @@ -61,3 +105,41 @@ async def generate( return await acollect_track( self.stream(prompt=prompt, duration=duration, segments=segments) ) + + async def submit( + self, + *, + prompt: str, + duration: int, + segments: Optional[List[Segment]] = None, + mode: Optional[str] = None, + output_format: Optional[str] = None, + ) -> SfxTask: + """Submit an async text-to-music task; poll with + `client.tasks.wait(task_id, parser=sonilo.resources.tasks.parse_music_result)`. + Required for output_format="wav". `stream()`/`generate()` remain the + streaming path. + """ + data = build_t2m_async_data(prompt, duration, segments, mode, output_format) + return parse_sfx_task(await self._client._post_json(PATH, data=data)) + + async def generate_async( + self, + *, + prompt: str, + duration: int, + segments: Optional[List[Segment]] = None, + mode: Optional[str] = None, + output_format: Optional[str] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> MusicResult: + """submit() + tasks.wait(), returning the parsed MusicResult.""" + task = await self.submit( + prompt=prompt, duration=duration, segments=segments, + mode=mode, output_format=output_format, + ) + return await self._client.tasks.wait( + task.task_id, poll_interval=poll_interval, timeout=timeout, + parser=parse_music_result, + ) diff --git a/src/sonilo/resources/video_to_music.py b/src/sonilo/resources/video_to_music.py index 87110dd..67aed63 100644 --- a/src/sonilo/resources/video_to_music.py +++ b/src/sonilo/resources/video_to_music.py @@ -56,17 +56,24 @@ def submit( segments: Optional[List[Segment]] = None, isolate_vocals: Optional[bool] = None, mode: Optional[str] = None, + preserve_speech: Optional[bool] = None, + output_format: Optional[str] = None, + ducking: Optional[bool] = None, ) -> SfxTask: """Submit an async video-to-music task and return its ack. - isolate_vocals=True requires mode="async" (auto-selected if `mode` - is omitted); passing an explicit non-async mode alongside - isolate_vocals raises a SoniloError before any request is made. Poll - with `client.tasks.wait(task_id, parser=sonilo.resources.tasks.parse_music_result)` + isolate_vocals/preserve_speech/ducking/output_format="wav" require + mode="async" (auto-selected if `mode` is omitted); passing an + explicit non-async mode alongside any of them raises a SoniloError + before any request is made. Poll with + `client.tasks.wait(task_id, parser=sonilo.resources.tasks.parse_music_result)` or use `generate_async()` to submit and wait in one call. """ data, files, opened = build_v2m_async_parts( - video, video_url, prompt, segments, mode, isolate_vocals + video, video_url, prompt, segments, mode, isolate_vocals, + preserve_speech=preserve_speech, + output_format=output_format, + ducking=ducking, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -82,6 +89,9 @@ def generate_async( segments: Optional[List[Segment]] = None, isolate_vocals: Optional[bool] = None, mode: Optional[str] = None, + preserve_speech: Optional[bool] = None, + output_format: Optional[str] = None, + ducking: Optional[bool] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> MusicResult: @@ -93,6 +103,9 @@ def generate_async( segments=segments, isolate_vocals=isolate_vocals, mode=mode, + preserve_speech=preserve_speech, + output_format=output_format, + ducking=ducking, ) return self._client.tasks.wait( task.task_id, @@ -139,15 +152,22 @@ async def submit( segments: Optional[List[Segment]] = None, isolate_vocals: Optional[bool] = None, mode: Optional[str] = None, + preserve_speech: Optional[bool] = None, + output_format: Optional[str] = None, + ducking: Optional[bool] = None, ) -> SfxTask: """Submit an async video-to-music task and return its ack. - isolate_vocals=True requires mode="async" (auto-selected if `mode` - is omitted); passing an explicit non-async mode alongside - isolate_vocals raises a SoniloError before any request is made. + isolate_vocals/preserve_speech/ducking/output_format="wav" require + mode="async" (auto-selected if `mode` is omitted); passing an + explicit non-async mode alongside any of them raises a SoniloError + before any request is made. """ data, files, opened = build_v2m_async_parts( - video, video_url, prompt, segments, mode, isolate_vocals + video, video_url, prompt, segments, mode, isolate_vocals, + preserve_speech=preserve_speech, + output_format=output_format, + ducking=ducking, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -165,6 +185,9 @@ async def generate_async( segments: Optional[List[Segment]] = None, isolate_vocals: Optional[bool] = None, mode: Optional[str] = None, + preserve_speech: Optional[bool] = None, + output_format: Optional[str] = None, + ducking: Optional[bool] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> MusicResult: @@ -176,6 +199,9 @@ async def generate_async( segments=segments, isolate_vocals=isolate_vocals, mode=mode, + preserve_speech=preserve_speech, + output_format=output_format, + ducking=ducking, ) return await self._client.tasks.wait( task.task_id, diff --git a/tests/test_video_to_video.py b/tests/test_video_to_video.py index 15b1076..d59c6b3 100644 --- a/tests/test_video_to_video.py +++ b/tests/test_video_to_video.py @@ -43,3 +43,45 @@ def test_v2v_sfx_submit_serializes_segments(): ) assert task.task_id == "s1" assert b"segments" in respx.calls[0].request.content + + +@respx.mock +def test_v2m_submit_forwards_ducking_and_output_format(): + respx.post("https://api.sonilo.com/v1/video-to-music").mock( + return_value=httpx.Response(202, json={"task_id": "m1", "status": "processing"}) + ) + Sonilo(api_key="k").video_to_music.submit( + video_url="https://x/v.mp4", preserve_speech=True, output_format="wav", ducking=False + ) + content = respx.calls[0].request.content + assert b"preserve_speech" in content and b"output_format" in content and b"ducking" in content + + +@respx.mock +def test_t2m_submit_async(): + respx.post("https://api.sonilo.com/v1/text-to-music").mock( + return_value=httpx.Response(202, json={"task_id": "tm1", "status": "processing"}) + ) + task = Sonilo(api_key="k").text_to_music.submit( + prompt="lofi", duration=10, output_format="wav" + ) + assert task.task_id == "tm1" + assert b"async" in respx.calls[0].request.content + + +@respx.mock +def test_music_result_parses_ducked(): + respx.post("https://api.sonilo.com/v1/video-to-music").mock( + return_value=httpx.Response(202, json={"task_id": "d1", "status": "processing"}) + ) + respx.get("https://api.sonilo.com/v1/tasks/d1").mock( + return_value=httpx.Response(200, json={ + "task_id": "d1", "type": "video_to_music", "status": "succeeded", + "audio": [{"stream_index": 0, "url": "https://r2/a.m4a"}], + "ducked": [{"stream_index": 0, "url": "https://r2/d.m4a"}], + }) + ) + res = Sonilo(api_key="k").video_to_music.generate_async( + video_url="https://x/v.mp4", ducking=True, poll_interval=0 + ) + assert res.ducked[0].url == "https://r2/d.m4a" From 9e39afc384d00d554d8a4e3ae2d082b278b6cf56 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sat, 18 Jul 2026 15:42:14 -0700 Subject: [PATCH 4/4] chore(sonilo): export VideoResult, bump to 0.4.0, widen video-kit core pin --- pyproject.toml | 2 +- sonilo-video-kit/pyproject.toml | 2 +- src/sonilo/__init__.py | 2 ++ src/sonilo/_version.py | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0bc88bd..449e58d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.3.0" +version = "0.4.0" description = "Official Python client for the Sonilo API" readme = "README.md" license = "MIT" diff --git a/sonilo-video-kit/pyproject.toml b/sonilo-video-kit/pyproject.toml index 1eb4101..009413b 100644 --- a/sonilo-video-kit/pyproject.toml +++ b/sonilo-video-kit/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" license = "MIT" requires-python = ">=3.9" authors = [{ name = "Sonilo AI" }] -dependencies = ["sonilo>=0.3,<0.4"] +dependencies = ["sonilo>=0.3,<0.5"] keywords = ["sonilo", "video", "music", "ffmpeg", "soundtrack", "ducking", "ai"] [project.urls] diff --git a/src/sonilo/__init__.py b/src/sonilo/__init__.py index 023b8a9..9114f40 100644 --- a/src/sonilo/__init__.py +++ b/src/sonilo/__init__.py @@ -23,6 +23,7 @@ SfxTask, StreamEvent, Track, + VideoResult, ) __all__ = [ @@ -47,5 +48,6 @@ "TaskFailedError", "TaskTimeoutError", "Track", + "VideoResult", "__version__", ] diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index 493f741..6a9beea 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.3.0" +__version__ = "0.4.0"