Skip to content

Commit 79d594b

Browse files
feat: video-to-video endpoints + v2m ducking/preserve_speech/output_format (#8)
* feat(sonilo): add VideoResult, parse_video_result, v2v builders, v2m async fields * feat(sonilo): add video_to_video_music and video_to_video_sfx resources * feat(sonilo): add preserve_speech/output_format/ducking to video_to_music and async text_to_music * chore(sonilo): export VideoResult, bump to 0.4.0, widen video-kit core pin
1 parent 1e2c0db commit 79d594b

16 files changed

Lines changed: 694 additions & 34 deletions

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "sonilo"
7-
version = "0.3.0"
7+
version = "0.4.0"
88
description = "Official Python client for the Sonilo API"
99
readme = "README.md"
1010
license = "MIT"

‎sonilo-video-kit/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ readme = "README.md"
1010
license = "MIT"
1111
requires-python = ">=3.9"
1212
authors = [{ name = "Sonilo AI" }]
13-
dependencies = ["sonilo>=0.3,<0.4"]
13+
dependencies = ["sonilo>=0.3,<0.5"]
1414
keywords = ["sonilo", "video", "music", "ffmpeg", "soundtrack", "ducking", "ai"]
1515

1616
[project.urls]

‎src/sonilo/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
SfxTask,
2424
StreamEvent,
2525
Track,
26+
VideoResult,
2627
)
2728

2829
__all__ = [
@@ -47,5 +48,6 @@
4748
"TaskFailedError",
4849
"TaskTimeoutError",
4950
"Track",
51+
"VideoResult",
5052
"__version__",
5153
]

‎src/sonilo/_async_client.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from sonilo.resources.text_to_sfx import AsyncTextToSfx
1414
from sonilo.resources.video_to_music import AsyncVideoToMusic
1515
from sonilo.resources.video_to_sfx import AsyncVideoToSfx
16+
from sonilo.resources.video_to_video_music import AsyncVideoToVideoMusic
17+
from sonilo.resources.video_to_video_sfx import AsyncVideoToVideoSfx
1618
from sonilo.types import StreamEvent
1719

1820

@@ -35,6 +37,8 @@ def __init__(
3537
self.video_to_music = AsyncVideoToMusic(self)
3638
self.text_to_sfx = AsyncTextToSfx(self)
3739
self.video_to_sfx = AsyncVideoToSfx(self)
40+
self.video_to_video_music = AsyncVideoToVideoMusic(self)
41+
self.video_to_video_sfx = AsyncVideoToVideoSfx(self)
3842
self.account = AsyncAccount(self)
3943
self.tasks = AsyncTasks(self)
4044

‎src/sonilo/_client.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
from sonilo.resources.text_to_sfx import TextToSfx
1515
from sonilo.resources.video_to_music import VideoToMusic
1616
from sonilo.resources.video_to_sfx import VideoToSfx
17+
from sonilo.resources.video_to_video_music import VideoToVideoMusic
18+
from sonilo.resources.video_to_video_sfx import VideoToVideoSfx
1719
from sonilo.types import StreamEvent
1820

1921
DEFAULT_BASE_URL = "https://api.sonilo.com"
@@ -56,6 +58,8 @@ def __init__(
5658
self.video_to_music = VideoToMusic(self)
5759
self.text_to_sfx = TextToSfx(self)
5860
self.video_to_sfx = VideoToSfx(self)
61+
self.video_to_video_music = VideoToVideoMusic(self)
62+
self.video_to_video_sfx = VideoToVideoSfx(self)
5963
self.account = Account(self)
6064
self.tasks = Tasks(self)
6165

‎src/sonilo/_requests.py‎

Lines changed: 80 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,23 @@ def build_t2m_data(
1919
return data
2020

2121

22+
def build_t2m_async_data(
23+
prompt: str,
24+
duration: int,
25+
segments: Optional[List[Segment]],
26+
mode: Optional[str],
27+
output_format: Optional[str],
28+
) -> Dict[str, str]:
29+
data = build_t2m_data(prompt, duration, segments)
30+
resolved = mode or "async"
31+
if resolved != "async":
32+
raise SoniloError("text-to-music submit() requires mode='async'")
33+
data["mode"] = resolved
34+
if output_format is not None:
35+
data["output_format"] = output_format
36+
return data
37+
38+
2239
def normalize_video(video: Any) -> Tuple[str, Any, bool]:
2340
"""Normalize a video input into (filename, httpx-uploadable, opened_here).
2441
@@ -66,18 +83,31 @@ def build_v2m_parts(
6683
return data, files, opened
6784

6885

69-
def _resolve_music_mode(mode: Optional[str], isolate_vocals: Optional[bool]) -> str:
70-
"""isolate_vocals only works with async processing: auto-select mode
71-
"async" when the caller didn't specify one, but fail fast (mirroring the
72-
video/video_url XOR check above) if they explicitly asked for anything
73-
else. Without isolate_vocals, submit() still needs an async response
74-
(a task_id ack, not a stream), so "async" is also the default there.
86+
def _resolve_music_mode(
87+
mode: Optional[str],
88+
isolate_vocals: Optional[bool],
89+
preserve_speech: Optional[bool] = None,
90+
output_format: Optional[str] = None,
91+
ducking: Optional[bool] = None,
92+
) -> str:
93+
"""isolate_vocals/preserve_speech/ducking/output_format='wav' only work
94+
with async processing: auto-select mode "async" when the caller didn't
95+
specify one, but fail fast if they explicitly asked for anything else.
96+
submit() also needs an async response (a task_id ack, not a stream), so
97+
"async" is the default regardless.
7598
"""
76-
if isolate_vocals:
77-
if mode is not None and mode != "async":
78-
raise SoniloError("isolate_vocals=True requires mode='async'")
79-
return "async"
80-
return mode or "async"
99+
needs_async = (
100+
bool(isolate_vocals)
101+
or bool(preserve_speech)
102+
or output_format == "wav"
103+
or ducking is not None
104+
)
105+
if needs_async and mode is not None and mode != "async":
106+
raise SoniloError(
107+
"isolate_vocals/preserve_speech/ducking/output_format='wav' "
108+
"require mode='async'"
109+
)
110+
return "async" if needs_async else (mode or "async")
81111

82112

83113
def build_v2m_async_parts(
@@ -87,17 +117,53 @@ def build_v2m_async_parts(
87117
segments: Optional[List[Segment]],
88118
mode: Optional[str],
89119
isolate_vocals: Optional[bool],
120+
preserve_speech: Optional[bool] = None,
121+
output_format: Optional[str] = None,
122+
ducking: Optional[bool] = None,
90123
) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]:
91-
"""Like build_v2m_parts, plus the async-only `mode`/`isolate_vocals`
92-
fields used by the video-to-music submit()/generate_async() path."""
93-
resolved_mode = _resolve_music_mode(mode, isolate_vocals)
124+
"""Like build_v2m_parts, plus the async-only fields for the
125+
video-to-music submit()/generate_async() path."""
126+
resolved_mode = _resolve_music_mode(
127+
mode, isolate_vocals, preserve_speech, output_format, ducking
128+
)
94129
data, files, opened = build_v2m_parts(video, video_url, prompt, segments)
95130
data["mode"] = resolved_mode
96131
if isolate_vocals is not None:
97132
data["isolate_vocals"] = "true" if isolate_vocals else "false"
133+
if preserve_speech is not None:
134+
data["preserve_speech"] = "true" if preserve_speech else "false"
135+
if output_format is not None:
136+
data["output_format"] = output_format
137+
if ducking is not None:
138+
data["ducking"] = "true" if ducking else "false"
98139
return data, files, opened
99140

100141

142+
def build_v2v_music_parts(
143+
video: Any,
144+
video_url: Optional[str],
145+
prompt: Optional[str],
146+
preserve_speech: Optional[bool],
147+
isolate_vocals: Optional[bool],
148+
) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]:
149+
data, files, opened = build_v2m_parts(video, video_url, prompt, None)
150+
if preserve_speech is not None:
151+
data["preserve_speech"] = "true" if preserve_speech else "false"
152+
if isolate_vocals is not None:
153+
data["isolate_vocals"] = "true" if isolate_vocals else "false"
154+
return data, files, opened
155+
156+
157+
def build_v2v_sfx_parts(
158+
video: Any,
159+
video_url: Optional[str],
160+
prompt: Optional[str],
161+
segments: Optional[List[Segment]],
162+
) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]:
163+
# build_v2m_parts JSON-serializes `segments` — fine for SFX start/end segments too.
164+
return build_v2m_parts(video, video_url, prompt, segments)
165+
166+
101167
def build_sfx_t2s_data(
102168
prompt: str, duration: int, audio_format: Optional[str]
103169
) -> Dict[str, str]:

‎src/sonilo/_version.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.3.0"
1+
__version__ = "0.4.0"

‎src/sonilo/resources/tasks.py‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@
66
from urllib.parse import quote
77

88
from sonilo.errors import SoniloError, TaskFailedError, TaskTimeoutError
9-
from sonilo.types import MusicAudioMedia, MusicResult, MusicTitle, SfxMedia, SfxResult, SfxTask
9+
from sonilo.types import (
10+
MusicAudioMedia,
11+
MusicResult,
12+
MusicTitle,
13+
SfxMedia,
14+
SfxResult,
15+
SfxTask,
16+
VideoResult,
17+
)
1018

1119
if TYPE_CHECKING:
1220
from sonilo._async_client import AsyncSonilo
@@ -106,6 +114,7 @@ def parse_music_result(body: Dict[str, Any]) -> MusicResult:
106114
audio=_music_audio_list_from(body.get("audio")),
107115
vocals=_media_from(body.get("vocals")),
108116
mux=_music_audio_list_from(body.get("mux")),
117+
ducked=_music_audio_list_from(body.get("ducked")),
109118
title=_music_title_from(body.get("title")),
110119
duration_seconds=body.get("duration_seconds"),
111120
cost=body.get("cost"),
@@ -116,6 +125,24 @@ def parse_music_result(body: Dict[str, Any]) -> MusicResult:
116125
raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e
117126

118127

128+
def parse_video_result(body: Dict[str, Any]) -> "VideoResult":
129+
"""Map a GET /v1/tasks/{id} body for a video-to-video task to VideoResult;
130+
unknown fields are ignored."""
131+
try:
132+
return VideoResult(
133+
task_id=body["task_id"],
134+
status=body["status"],
135+
type=body.get("type"),
136+
video=_media_from(body.get("video")),
137+
duration_seconds=body.get("duration_seconds"),
138+
cost=body.get("cost"),
139+
error=body.get("error"),
140+
refunded=body.get("refunded"),
141+
)
142+
except KeyError as e:
143+
raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e
144+
145+
119146
def parse_sfx_task(body: Dict[str, Any]) -> SfxTask:
120147
"""Map a submission ack to SfxTask."""
121148
try:

‎src/sonilo/resources/text_to_music.py‎

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22

33
from typing import TYPE_CHECKING, AsyncIterator, Iterator, List, Optional
44

5-
from sonilo._requests import build_t2m_data
5+
from sonilo._requests import build_t2m_async_data, build_t2m_data
66
from sonilo._streaming import acollect_track, collect_track
7-
from sonilo.types import Segment, StreamEvent, Track
7+
from sonilo.resources.tasks import (
8+
DEFAULT_POLL_INTERVAL,
9+
DEFAULT_WAIT_TIMEOUT,
10+
parse_music_result,
11+
parse_sfx_task,
12+
)
13+
from sonilo.types import MusicResult, Segment, SfxTask, StreamEvent, Track
814

915
if TYPE_CHECKING:
1016
from sonilo._async_client import AsyncSonilo
@@ -36,6 +42,44 @@ def generate(
3642
) -> Track:
3743
return collect_track(self.stream(prompt=prompt, duration=duration, segments=segments))
3844

45+
def submit(
46+
self,
47+
*,
48+
prompt: str,
49+
duration: int,
50+
segments: Optional[List[Segment]] = None,
51+
mode: Optional[str] = None,
52+
output_format: Optional[str] = None,
53+
) -> SfxTask:
54+
"""Submit an async text-to-music task; poll with
55+
`client.tasks.wait(task_id, parser=sonilo.resources.tasks.parse_music_result)`.
56+
Required for output_format="wav". `stream()`/`generate()` remain the
57+
streaming path.
58+
"""
59+
data = build_t2m_async_data(prompt, duration, segments, mode, output_format)
60+
return parse_sfx_task(self._client._post_json(PATH, data=data))
61+
62+
def generate_async(
63+
self,
64+
*,
65+
prompt: str,
66+
duration: int,
67+
segments: Optional[List[Segment]] = None,
68+
mode: Optional[str] = None,
69+
output_format: Optional[str] = None,
70+
poll_interval: float = DEFAULT_POLL_INTERVAL,
71+
timeout: float = DEFAULT_WAIT_TIMEOUT,
72+
) -> MusicResult:
73+
"""submit() + tasks.wait(), returning the parsed MusicResult."""
74+
task = self.submit(
75+
prompt=prompt, duration=duration, segments=segments,
76+
mode=mode, output_format=output_format,
77+
)
78+
return self._client.tasks.wait(
79+
task.task_id, poll_interval=poll_interval, timeout=timeout,
80+
parser=parse_music_result,
81+
)
82+
3983

4084
class AsyncTextToMusic:
4185
def __init__(self, client: "AsyncSonilo") -> None:
@@ -61,3 +105,41 @@ async def generate(
61105
return await acollect_track(
62106
self.stream(prompt=prompt, duration=duration, segments=segments)
63107
)
108+
109+
async def submit(
110+
self,
111+
*,
112+
prompt: str,
113+
duration: int,
114+
segments: Optional[List[Segment]] = None,
115+
mode: Optional[str] = None,
116+
output_format: Optional[str] = None,
117+
) -> SfxTask:
118+
"""Submit an async text-to-music task; poll with
119+
`client.tasks.wait(task_id, parser=sonilo.resources.tasks.parse_music_result)`.
120+
Required for output_format="wav". `stream()`/`generate()` remain the
121+
streaming path.
122+
"""
123+
data = build_t2m_async_data(prompt, duration, segments, mode, output_format)
124+
return parse_sfx_task(await self._client._post_json(PATH, data=data))
125+
126+
async def generate_async(
127+
self,
128+
*,
129+
prompt: str,
130+
duration: int,
131+
segments: Optional[List[Segment]] = None,
132+
mode: Optional[str] = None,
133+
output_format: Optional[str] = None,
134+
poll_interval: float = DEFAULT_POLL_INTERVAL,
135+
timeout: float = DEFAULT_WAIT_TIMEOUT,
136+
) -> MusicResult:
137+
"""submit() + tasks.wait(), returning the parsed MusicResult."""
138+
task = await self.submit(
139+
prompt=prompt, duration=duration, segments=segments,
140+
mode=mode, output_format=output_format,
141+
)
142+
return await self._client.tasks.wait(
143+
task.task_id, poll_interval=poll_interval, timeout=timeout,
144+
parser=parse_music_result,
145+
)

0 commit comments

Comments
 (0)