Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions sonilo-video-kit/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions src/sonilo/_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions src/sonilo/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down
31 changes: 30 additions & 1 deletion src/sonilo/_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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]:
Expand Down
2 changes: 1 addition & 1 deletion src/sonilo/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.4.0"
__version__ = "0.5.0"
24 changes: 24 additions & 0 deletions src/sonilo/resources/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
SfxMedia,
SfxResult,
SfxTask,
SoundResult,
VideoResult,
)

Expand Down Expand Up @@ -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:
Expand Down
128 changes: 128 additions & 0 deletions src/sonilo/resources/video_to_sound.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading