sonilo 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (38) hide show
  1. {sonilo-0.2.0 → sonilo-0.3.0}/PKG-INFO +35 -3
  2. {sonilo-0.2.0 → sonilo-0.3.0}/README.md +34 -2
  3. {sonilo-0.2.0 → sonilo-0.3.0}/pyproject.toml +1 -1
  4. {sonilo-0.2.0 → sonilo-0.3.0}/src/sonilo/__init__.py +6 -0
  5. {sonilo-0.2.0 → sonilo-0.3.0}/src/sonilo/_requests.py +32 -0
  6. sonilo-0.3.0/src/sonilo/_version.py +1 -0
  7. {sonilo-0.2.0 → sonilo-0.3.0}/src/sonilo/resources/tasks.py +103 -17
  8. sonilo-0.3.0/src/sonilo/resources/video_to_music.py +185 -0
  9. sonilo-0.3.0/src/sonilo/types.py +217 -0
  10. sonilo-0.3.0/tests/test_async_client.py +210 -0
  11. {sonilo-0.2.0 → sonilo-0.3.0}/tests/test_sync_client.py +224 -0
  12. sonilo-0.2.0/src/sonilo/_version.py +0 -1
  13. sonilo-0.2.0/src/sonilo/resources/video_to_music.py +0 -71
  14. sonilo-0.2.0/src/sonilo/types.py +0 -110
  15. sonilo-0.2.0/tests/test_async_client.py +0 -87
  16. {sonilo-0.2.0 → sonilo-0.3.0}/.github/workflows/ci.yml +0 -0
  17. {sonilo-0.2.0 → sonilo-0.3.0}/.github/workflows/publish.yml +0 -0
  18. {sonilo-0.2.0 → sonilo-0.3.0}/.gitignore +0 -0
  19. {sonilo-0.2.0 → sonilo-0.3.0}/LICENSE +0 -0
  20. {sonilo-0.2.0 → sonilo-0.3.0}/examples/generate.py +0 -0
  21. {sonilo-0.2.0 → sonilo-0.3.0}/examples/sfx.py +0 -0
  22. {sonilo-0.2.0 → sonilo-0.3.0}/src/sonilo/_async_client.py +0 -0
  23. {sonilo-0.2.0 → sonilo-0.3.0}/src/sonilo/_client.py +0 -0
  24. {sonilo-0.2.0 → sonilo-0.3.0}/src/sonilo/_streaming.py +0 -0
  25. {sonilo-0.2.0 → sonilo-0.3.0}/src/sonilo/errors.py +0 -0
  26. {sonilo-0.2.0 → sonilo-0.3.0}/src/sonilo/py.typed +0 -0
  27. {sonilo-0.2.0 → sonilo-0.3.0}/src/sonilo/resources/__init__.py +0 -0
  28. {sonilo-0.2.0 → sonilo-0.3.0}/src/sonilo/resources/account.py +0 -0
  29. {sonilo-0.2.0 → sonilo-0.3.0}/src/sonilo/resources/text_to_music.py +0 -0
  30. {sonilo-0.2.0 → sonilo-0.3.0}/src/sonilo/resources/text_to_sfx.py +0 -0
  31. {sonilo-0.2.0 → sonilo-0.3.0}/src/sonilo/resources/video_to_sfx.py +0 -0
  32. {sonilo-0.2.0 → sonilo-0.3.0}/tests/__init__.py +0 -0
  33. {sonilo-0.2.0 → sonilo-0.3.0}/tests/test_errors.py +0 -0
  34. {sonilo-0.2.0 → sonilo-0.3.0}/tests/test_requests.py +0 -0
  35. {sonilo-0.2.0 → sonilo-0.3.0}/tests/test_sfx.py +0 -0
  36. {sonilo-0.2.0 → sonilo-0.3.0}/tests/test_sfx_async.py +0 -0
  37. {sonilo-0.2.0 → sonilo-0.3.0}/tests/test_sfx_types.py +0 -0
  38. {sonilo-0.2.0 → sonilo-0.3.0}/tests/test_streaming.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sonilo
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Official Python client for the Sonilo API
5
5
  Project-URL: Repository, https://github.com/sonilo-ai/sonilo-python
6
6
  Author: Sonilo AI
@@ -49,6 +49,39 @@ track = client.video_to_music.generate(video="my_video.mp4", prompt="upbeat")
49
49
  track = client.video_to_music.generate(video_url="https://example.com/clip.mp4")
50
50
  ```
51
51
 
52
+ ### Vocal isolation (async)
53
+
54
+ Pass `isolate_vocals=True` to also get an isolated vocal stem and a muxed
55
+ video, alongside the scored audio. This requires async processing — submit
56
+ returns a `task_id` immediately, and `generate_async()` wraps submit + poll:
57
+
58
+ ```python
59
+ result = client.video_to_music.generate_async(
60
+ video="my_video.mp4",
61
+ prompt="upbeat",
62
+ isolate_vocals=True, # implies mode="async"; omit mode to let it auto-select
63
+ )
64
+ result.save("mix.m4a") # result.audio[0] — the full mix
65
+ result.save("vocals.m4a", which="vocals")
66
+ result.save("video.mp4", which="mux") # video muxed with the generated audio
67
+ print(result.title.title if result.title else None)
68
+ ```
69
+
70
+ Or control submission and polling yourself:
71
+
72
+ ```python
73
+ from sonilo.resources.tasks import parse_music_result
74
+
75
+ task = client.video_to_music.submit(video_url="https://example.com/clip.mp4", isolate_vocals=True)
76
+ result = client.tasks.wait(
77
+ task.task_id,
78
+ parser=parse_music_result, # required: tasks.wait()/get() default to the SFX parser
79
+ )
80
+ ```
81
+
82
+ `isolate_vocals=True` with an explicit non-async `mode` raises `SoniloError`
83
+ locally before any request is sent.
84
+
52
85
  ## Streaming
53
86
 
54
87
  ```python
@@ -107,8 +140,7 @@ task = client.video_to_sfx.submit(
107
140
  audio_format="wav",
108
141
  )
109
142
  result = client.tasks.wait(task.task_id, poll_interval=2.0, timeout=600.0)
110
- result.save("audio.wav")
111
- result.save("with_audio.mp4", which="video") # video-to-sfx also returns the muxed video
143
+ result.save("audio.wav") # video-to-sfx returns the generated audio only
112
144
  ```
113
145
 
114
146
  `tasks.get(task_id)` fetches state once and never raises on a failed task;
@@ -32,6 +32,39 @@ track = client.video_to_music.generate(video="my_video.mp4", prompt="upbeat")
32
32
  track = client.video_to_music.generate(video_url="https://example.com/clip.mp4")
33
33
  ```
34
34
 
35
+ ### Vocal isolation (async)
36
+
37
+ Pass `isolate_vocals=True` to also get an isolated vocal stem and a muxed
38
+ video, alongside the scored audio. This requires async processing — submit
39
+ returns a `task_id` immediately, and `generate_async()` wraps submit + poll:
40
+
41
+ ```python
42
+ result = client.video_to_music.generate_async(
43
+ video="my_video.mp4",
44
+ prompt="upbeat",
45
+ isolate_vocals=True, # implies mode="async"; omit mode to let it auto-select
46
+ )
47
+ result.save("mix.m4a") # result.audio[0] — the full mix
48
+ result.save("vocals.m4a", which="vocals")
49
+ result.save("video.mp4", which="mux") # video muxed with the generated audio
50
+ print(result.title.title if result.title else None)
51
+ ```
52
+
53
+ Or control submission and polling yourself:
54
+
55
+ ```python
56
+ from sonilo.resources.tasks import parse_music_result
57
+
58
+ task = client.video_to_music.submit(video_url="https://example.com/clip.mp4", isolate_vocals=True)
59
+ result = client.tasks.wait(
60
+ task.task_id,
61
+ parser=parse_music_result, # required: tasks.wait()/get() default to the SFX parser
62
+ )
63
+ ```
64
+
65
+ `isolate_vocals=True` with an explicit non-async `mode` raises `SoniloError`
66
+ locally before any request is sent.
67
+
35
68
  ## Streaming
36
69
 
37
70
  ```python
@@ -90,8 +123,7 @@ task = client.video_to_sfx.submit(
90
123
  audio_format="wav",
91
124
  )
92
125
  result = client.tasks.wait(task.task_id, poll_interval=2.0, timeout=600.0)
93
- result.save("audio.wav")
94
- result.save("with_audio.mp4", which="video") # video-to-sfx also returns the muxed video
126
+ result.save("audio.wav") # video-to-sfx returns the generated audio only
95
127
  ```
96
128
 
97
129
  `tasks.get(task_id)` fetches state once and never raises on a failed task;
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sonilo"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Official Python client for the Sonilo API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -13,6 +13,9 @@ from sonilo.errors import (
13
13
  TaskTimeoutError,
14
14
  )
15
15
  from sonilo.types import (
16
+ MusicAudioMedia,
17
+ MusicResult,
18
+ MusicTitle,
16
19
  Segment,
17
20
  SfxMedia,
18
21
  SfxResult,
@@ -28,6 +31,9 @@ __all__ = [
28
31
  "AuthenticationError",
29
32
  "BadRequestError",
30
33
  "GenerationError",
34
+ "MusicAudioMedia",
35
+ "MusicResult",
36
+ "MusicTitle",
31
37
  "PaymentRequiredError",
32
38
  "RateLimitError",
33
39
  "Segment",
@@ -66,6 +66,38 @@ def build_v2m_parts(
66
66
  return data, files, opened
67
67
 
68
68
 
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.
75
+ """
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"
81
+
82
+
83
+ def build_v2m_async_parts(
84
+ video: Any,
85
+ video_url: Optional[str],
86
+ prompt: Optional[str],
87
+ segments: Optional[List[Segment]],
88
+ mode: Optional[str],
89
+ isolate_vocals: Optional[bool],
90
+ ) -> 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)
94
+ data, files, opened = build_v2m_parts(video, video_url, prompt, segments)
95
+ data["mode"] = resolved_mode
96
+ if isolate_vocals is not None:
97
+ data["isolate_vocals"] = "true" if isolate_vocals else "false"
98
+ return data, files, opened
99
+
100
+
69
101
  def build_sfx_t2s_data(
70
102
  prompt: str, duration: int, audio_format: Optional[str]
71
103
  ) -> Dict[str, str]:
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -2,11 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import time
5
- from typing import TYPE_CHECKING, Any, Dict, Optional
5
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Protocol, TypeVar
6
6
  from urllib.parse import quote
7
7
 
8
8
  from sonilo.errors import SoniloError, TaskFailedError, TaskTimeoutError
9
- from sonilo.types import SfxMedia, SfxResult, SfxTask
9
+ from sonilo.types import MusicAudioMedia, MusicResult, MusicTitle, SfxMedia, SfxResult, SfxTask
10
10
 
11
11
  if TYPE_CHECKING:
12
12
  from sonilo._async_client import AsyncSonilo
@@ -21,6 +21,19 @@ _async_sleep = asyncio.sleep
21
21
  _monotonic = time.monotonic
22
22
 
23
23
 
24
+ class _PollableResult(Protocol):
25
+ """Structural shape Tasks.get()/wait() need from any parsed result,
26
+ regardless of which endpoint produced it (SFX vs. music)."""
27
+
28
+ task_id: str
29
+ status: str
30
+ error: Optional[Dict[str, Any]]
31
+ refunded: Optional[bool]
32
+
33
+
34
+ ResultT = TypeVar("ResultT", bound=_PollableResult)
35
+
36
+
24
37
  def _media_from(data: Any) -> Optional[SfxMedia]:
25
38
  if not isinstance(data, dict) or "url" not in data:
26
39
  return None
@@ -48,6 +61,61 @@ def parse_sfx_result(body: Dict[str, Any]) -> SfxResult:
48
61
  raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e
49
62
 
50
63
 
64
+ def _music_audio_from(data: Any) -> Optional[MusicAudioMedia]:
65
+ if not isinstance(data, dict) or "url" not in data:
66
+ return None
67
+ return MusicAudioMedia(
68
+ stream_index=data.get("stream_index", 0),
69
+ url=data["url"],
70
+ content_type=data.get("content_type"),
71
+ file_size=data.get("file_size"),
72
+ sample_rate=data.get("sample_rate"),
73
+ channels=data.get("channels"),
74
+ )
75
+
76
+
77
+ def _music_audio_list_from(data: Any) -> Optional[List[MusicAudioMedia]]:
78
+ if not isinstance(data, list):
79
+ return None
80
+ items = [item for item in (_music_audio_from(entry) for entry in data) if item is not None]
81
+ return items
82
+
83
+
84
+ def _music_title_from(data: Any) -> Optional[MusicTitle]:
85
+ if not isinstance(data, dict):
86
+ return None
87
+ return MusicTitle(
88
+ title=data.get("title"),
89
+ summary=data.get("summary"),
90
+ display_tags=data.get("display_tags"),
91
+ )
92
+
93
+
94
+ def parse_music_result(body: Dict[str, Any]) -> MusicResult:
95
+ """Map a GET /v1/tasks/{id} body for a video-to-music task to
96
+ MusicResult; unknown fields are ignored.
97
+
98
+ `audio` is always a list; `vocals`/`mux` are only populated when the
99
+ task was submitted with isolate_vocals=True.
100
+ """
101
+ try:
102
+ return MusicResult(
103
+ task_id=body["task_id"],
104
+ status=body["status"],
105
+ type=body.get("type"),
106
+ audio=_music_audio_list_from(body.get("audio")),
107
+ vocals=_media_from(body.get("vocals")),
108
+ mux=_music_audio_list_from(body.get("mux")),
109
+ title=_music_title_from(body.get("title")),
110
+ duration_seconds=body.get("duration_seconds"),
111
+ cost=body.get("cost"),
112
+ error=body.get("error"),
113
+ refunded=body.get("refunded"),
114
+ )
115
+ except KeyError as e:
116
+ raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e
117
+
118
+
51
119
  def parse_sfx_task(body: Dict[str, Any]) -> SfxTask:
52
120
  """Map a submission ack to SfxTask."""
53
121
  try:
@@ -56,7 +124,7 @@ def parse_sfx_task(body: Dict[str, Any]) -> SfxTask:
56
124
  raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e
57
125
 
58
126
 
59
- def _raise_if_failed(result: SfxResult) -> None:
127
+ def _raise_if_failed(result: _PollableResult) -> None:
60
128
  if result.status == "failed":
61
129
  error = result.error if isinstance(result.error, dict) else {}
62
130
  message = error.get("message") or "Generation failed"
@@ -87,11 +155,19 @@ class Tasks:
87
155
  def __init__(self, client: "Sonilo") -> None:
88
156
  self._client = client
89
157
 
90
- def get(self, task_id: str) -> SfxResult:
91
- """Fetch current task state. Never raises on a failed status."""
92
- return parse_sfx_result(
93
- self._client._get_json(f"/v1/tasks/{quote(task_id, safe='')}")
94
- )
158
+ def get(
159
+ self,
160
+ task_id: str,
161
+ *,
162
+ parser: Callable[[Dict[str, Any]], ResultT] = parse_sfx_result, # type: ignore[assignment]
163
+ ) -> ResultT:
164
+ """Fetch current task state. Never raises on a failed status.
165
+
166
+ `parser` maps the raw response body to a result type; it defaults to
167
+ the SFX parser for back-compat. Pass `parse_music_result` for
168
+ video-to-music async tasks.
169
+ """
170
+ return parser(self._client._get_json(f"/v1/tasks/{quote(task_id, safe='')}"))
95
171
 
96
172
  def wait(
97
173
  self,
@@ -99,12 +175,13 @@ class Tasks:
99
175
  *,
100
176
  poll_interval: float = DEFAULT_POLL_INTERVAL,
101
177
  timeout: float = DEFAULT_WAIT_TIMEOUT,
102
- ) -> SfxResult:
178
+ parser: Callable[[Dict[str, Any]], ResultT] = parse_sfx_result, # type: ignore[assignment]
179
+ ) -> ResultT:
103
180
  """Poll until the task is terminal; raise on failure or deadline."""
104
181
  _validate_wait_args(poll_interval, timeout)
105
182
  deadline = _monotonic() + timeout
106
183
  while True:
107
- result = self.get(task_id)
184
+ result = self.get(task_id, parser=parser)
108
185
  if result.status == "succeeded":
109
186
  return result
110
187
  _raise_if_failed(result)
@@ -118,11 +195,19 @@ class AsyncTasks:
118
195
  def __init__(self, client: "AsyncSonilo") -> None:
119
196
  self._client = client
120
197
 
121
- async def get(self, task_id: str) -> SfxResult:
122
- """Fetch current task state. Never raises on a failed status."""
123
- return parse_sfx_result(
124
- await self._client._get_json(f"/v1/tasks/{quote(task_id, safe='')}")
125
- )
198
+ async def get(
199
+ self,
200
+ task_id: str,
201
+ *,
202
+ parser: Callable[[Dict[str, Any]], ResultT] = parse_sfx_result, # type: ignore[assignment]
203
+ ) -> ResultT:
204
+ """Fetch current task state. Never raises on a failed status.
205
+
206
+ `parser` maps the raw response body to a result type; it defaults to
207
+ the SFX parser for back-compat. Pass `parse_music_result` for
208
+ video-to-music async tasks.
209
+ """
210
+ return parser(await self._client._get_json(f"/v1/tasks/{quote(task_id, safe='')}"))
126
211
 
127
212
  async def wait(
128
213
  self,
@@ -130,12 +215,13 @@ class AsyncTasks:
130
215
  *,
131
216
  poll_interval: float = DEFAULT_POLL_INTERVAL,
132
217
  timeout: float = DEFAULT_WAIT_TIMEOUT,
133
- ) -> SfxResult:
218
+ parser: Callable[[Dict[str, Any]], ResultT] = parse_sfx_result, # type: ignore[assignment]
219
+ ) -> ResultT:
134
220
  """Poll until the task is terminal; raise on failure or deadline."""
135
221
  _validate_wait_args(poll_interval, timeout)
136
222
  deadline = _monotonic() + timeout
137
223
  while True:
138
- result = await self.get(task_id)
224
+ result = await self.get(task_id, parser=parser)
139
225
  if result.status == "succeeded":
140
226
  return result
141
227
  _raise_if_failed(result)
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator, List, Optional
4
+
5
+ from sonilo._requests import build_v2m_async_parts, build_v2m_parts
6
+ from sonilo._streaming import acollect_track, collect_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
14
+
15
+ if TYPE_CHECKING:
16
+ from sonilo._async_client import AsyncSonilo
17
+ from sonilo._client import Sonilo
18
+
19
+ PATH = "/v1/video-to-music"
20
+
21
+
22
+ class VideoToMusic:
23
+ def __init__(self, client: "Sonilo") -> None:
24
+ self._client = client
25
+
26
+ def stream(
27
+ self,
28
+ *,
29
+ video: Any = None,
30
+ video_url: Optional[str] = None,
31
+ prompt: Optional[str] = None,
32
+ segments: Optional[List[Segment]] = None,
33
+ ) -> Iterator[StreamEvent]:
34
+ data, files, opened = build_v2m_parts(video, video_url, prompt, segments)
35
+ close_after = files["video"][1] if files is not None and opened else None
36
+ return self._client._stream_events(PATH, data=data, files=files, close_after=close_after)
37
+
38
+ def generate(
39
+ self,
40
+ *,
41
+ video: Any = None,
42
+ video_url: Optional[str] = None,
43
+ prompt: Optional[str] = None,
44
+ segments: Optional[List[Segment]] = None,
45
+ ) -> Track:
46
+ return collect_track(
47
+ self.stream(video=video, video_url=video_url, prompt=prompt, segments=segments)
48
+ )
49
+
50
+ def submit(
51
+ self,
52
+ *,
53
+ video: Any = None,
54
+ video_url: Optional[str] = None,
55
+ prompt: Optional[str] = None,
56
+ segments: Optional[List[Segment]] = None,
57
+ isolate_vocals: Optional[bool] = None,
58
+ mode: Optional[str] = None,
59
+ ) -> SfxTask:
60
+ """Submit an async video-to-music task and return its ack.
61
+
62
+ isolate_vocals=True requires mode="async" (auto-selected if `mode`
63
+ is omitted); passing an explicit non-async mode alongside
64
+ isolate_vocals raises a SoniloError before any request is made. Poll
65
+ with `client.tasks.wait(task_id, parser=sonilo.resources.tasks.parse_music_result)`
66
+ or use `generate_async()` to submit and wait in one call.
67
+ """
68
+ data, files, opened = build_v2m_async_parts(
69
+ video, video_url, prompt, segments, mode, isolate_vocals
70
+ )
71
+ close_after = files["video"][1] if files is not None and opened else None
72
+ return parse_sfx_task(
73
+ self._client._post_json(PATH, data=data, files=files, close_after=close_after)
74
+ )
75
+
76
+ def generate_async(
77
+ self,
78
+ *,
79
+ video: Any = None,
80
+ video_url: Optional[str] = None,
81
+ prompt: Optional[str] = None,
82
+ segments: Optional[List[Segment]] = None,
83
+ isolate_vocals: Optional[bool] = None,
84
+ mode: Optional[str] = None,
85
+ poll_interval: float = DEFAULT_POLL_INTERVAL,
86
+ timeout: float = DEFAULT_WAIT_TIMEOUT,
87
+ ) -> MusicResult:
88
+ """submit() + tasks.wait(), returning the parsed MusicResult."""
89
+ task = self.submit(
90
+ video=video,
91
+ video_url=video_url,
92
+ prompt=prompt,
93
+ segments=segments,
94
+ isolate_vocals=isolate_vocals,
95
+ mode=mode,
96
+ )
97
+ return self._client.tasks.wait(
98
+ task.task_id,
99
+ poll_interval=poll_interval,
100
+ timeout=timeout,
101
+ parser=parse_music_result,
102
+ )
103
+
104
+
105
+ class AsyncVideoToMusic:
106
+ def __init__(self, client: "AsyncSonilo") -> None:
107
+ self._client = client
108
+
109
+ def stream(
110
+ self,
111
+ *,
112
+ video: Any = None,
113
+ video_url: Optional[str] = None,
114
+ prompt: Optional[str] = None,
115
+ segments: Optional[List[Segment]] = None,
116
+ ) -> AsyncIterator[StreamEvent]:
117
+ data, files, opened = build_v2m_parts(video, video_url, prompt, segments)
118
+ close_after = files["video"][1] if files is not None and opened else None
119
+ return self._client._stream_events(PATH, data=data, files=files, close_after=close_after)
120
+
121
+ async def generate(
122
+ self,
123
+ *,
124
+ video: Any = None,
125
+ video_url: Optional[str] = None,
126
+ prompt: Optional[str] = None,
127
+ segments: Optional[List[Segment]] = None,
128
+ ) -> Track:
129
+ return await acollect_track(
130
+ self.stream(video=video, video_url=video_url, prompt=prompt, segments=segments)
131
+ )
132
+
133
+ async def submit(
134
+ self,
135
+ *,
136
+ video: Any = None,
137
+ video_url: Optional[str] = None,
138
+ prompt: Optional[str] = None,
139
+ segments: Optional[List[Segment]] = None,
140
+ isolate_vocals: Optional[bool] = None,
141
+ mode: Optional[str] = None,
142
+ ) -> SfxTask:
143
+ """Submit an async video-to-music task and return its ack.
144
+
145
+ isolate_vocals=True requires mode="async" (auto-selected if `mode`
146
+ is omitted); passing an explicit non-async mode alongside
147
+ isolate_vocals raises a SoniloError before any request is made.
148
+ """
149
+ data, files, opened = build_v2m_async_parts(
150
+ video, video_url, prompt, segments, mode, isolate_vocals
151
+ )
152
+ close_after = files["video"][1] if files is not None and opened else None
153
+ return parse_sfx_task(
154
+ await self._client._post_json(
155
+ PATH, data=data, files=files, close_after=close_after
156
+ )
157
+ )
158
+
159
+ async def generate_async(
160
+ self,
161
+ *,
162
+ video: Any = None,
163
+ video_url: Optional[str] = None,
164
+ prompt: Optional[str] = None,
165
+ segments: Optional[List[Segment]] = None,
166
+ isolate_vocals: Optional[bool] = None,
167
+ mode: Optional[str] = None,
168
+ poll_interval: float = DEFAULT_POLL_INTERVAL,
169
+ timeout: float = DEFAULT_WAIT_TIMEOUT,
170
+ ) -> MusicResult:
171
+ """submit() + tasks.wait(), returning the parsed MusicResult."""
172
+ task = await self.submit(
173
+ video=video,
174
+ video_url=video_url,
175
+ prompt=prompt,
176
+ segments=segments,
177
+ isolate_vocals=isolate_vocals,
178
+ mode=mode,
179
+ )
180
+ return await self._client.tasks.wait(
181
+ task.task_id,
182
+ poll_interval=poll_interval,
183
+ timeout=timeout,
184
+ parser=parse_music_result,
185
+ )