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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[dev]"
- run: sudo apt-get update && sudo apt-get install -y ffmpeg
- run: pip install -e ".[dev]" -e "./sonilo-video-kit[dev]"
- run: pytest
- run: pytest sonilo-video-kit
44 changes: 44 additions & 0 deletions .github/workflows/publish-video-kit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Publish sonilo-video-kit to PyPI

on:
workflow_dispatch:
push:
tags: ["sonilo-video-kit-v*"]

jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
# PyPI Trusted Publishing (OIDC). No PYPI_TOKEN secret needed.
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: sudo apt-get update && sudo apt-get install -y ffmpeg
- run: pip install -e ".[dev]" -e "./sonilo-video-kit[dev]" build
- run: pytest sonilo-video-kit
- name: Verify tag matches pyproject version
if: github.ref_type == 'tag'
run: |
TAG_VERSION="${GITHUB_REF_NAME#sonilo-video-kit-v}"
PKG_VERSION=$(python -c "import tomllib; print(tomllib.load(open('sonilo-video-kit/pyproject.toml','rb'))['project']['version'])")
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
echo "Tag version ($TAG_VERSION) does not match pyproject version ($PKG_VERSION)"
exit 1
fi
- run: python -m build sonilo-video-kit
- name: Publish to PyPI
# Authenticates via OIDC Trusted Publishing — configured as a pending
# publisher for project `sonilo-video-kit` (repo sonilo-python,
# workflow publish-video-kit.yml). Tag refs only: a workflow_dispatch
# on a branch runs tests/build but never publishes. To retry a failed
# publish, dispatch on the tag:
# gh workflow run publish-video-kit.yml --ref sonilo-video-kit-vX.Y.Z
if: github.ref_type == 'tag'
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: sonilo-video-kit/dist
skip-existing: true
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ dist/
build/
.pytest_cache/
.DS_Store
/docs/
21 changes: 21 additions & 0 deletions sonilo-video-kit/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Sonilo AI

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
128 changes: 128 additions & 0 deletions sonilo-video-kit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# sonilo-video-kit

Video helpers for the [Sonilo](https://sonilo.com) API: generate a soundtrack
for a video and mix it in locally with ffmpeg. Python ≥ 3.9.

Requires `ffmpeg` + `ffprobe` on your PATH (macOS: `brew install ffmpeg`,
Debian/Ubuntu: `apt-get install ffmpeg`) — or pass `ffmpeg_path`/`ffprobe_path`
to any function.

## Installation

```bash
pip install sonilo sonilo-video-kit
```

`sonilo` (the core client) is a required dependency — it is installed
automatically alongside the kit, but is shown here for clarity.

## Quickstart

```python
from sonilo_video_kit import generate_music_for_video, mix_with_video

track = generate_music_for_video("./clip.mp4", prompt="upbeat, energetic") # uses SONILO_API_KEY

mix_with_video(
video="./clip.mp4",
audio=track.audio,
output="./clip.scored.mp4",
)
```

## Loudness-matched mixing

By default the kit measures the loudness (LUFS) of your video's own audio and
of the generated music, then sits the music 4 LU below the original — so
dialogue stays intelligible without hand-tuning. The final file is normalized
to −14 LUFS (streaming-platform delivery level) with a −1 dBFS peak limiter.
The delivery-normalize boost is capped at +12 dB; attenuation (bringing an
overly loud render down to target) is uncapped.

- `music_volume` (0–1, default 0.5): 0.5 is the matched level; each step of
0.25 shifts ±6 dB (full range ±12 dB). 0 mutes the music.
- `original_volume` (0–1, default 1): absolute — 1 keeps the original exactly
as recorded, 0 removes it entirely.
- `loudness_match=False` switches both knobs to plain absolute gains.
- `normalize=False` skips the delivery-loudness pass.

If loudness measurement fails (exotic codecs, unreadable audio), the kit
silently falls back to absolute-gain behavior rather than failing your render.

## Ducking

`mix_with_video` sits the music at a fixed level under the original audio.
`duck_music_under_speech` goes further: it rides the music down whenever
someone speaks and back up in the gaps.

```python
from sonilo_video_kit import duck_music_under_speech

duck_music_under_speech(
video="./interview.mp4",
audio=track.audio,
output="./interview.ducked.mp4",
)
```

Unlike `mix_with_video`, which is entirely local and free, this calls the
Sonilo ducking API and is **billed on your video's duration**. The kit
uploads only the video's extracted audio track — your picture never leaves
the machine and is copied into the result untouched. The API sets the
ducking curve itself (speech gate, duck depth, −14 LUFS delivery, −1 dBTP
ceiling), so there are no volume knobs to pass.

Requirements are enforced locally, before anything is uploaded or charged:
the video must have an audio track and a real picture, it must run no longer
than **360 s**, `output` must carry a file extension and live in a directory
that already exists and is writable, and your picture must be
stream-copyable into `output`'s container. The ducked audio is always written
as **AAC**, so `output` must be a container that can carry AAC — `.webm`
(Vorbis/Opus only) is rejected before the API call whatever your picture
codec. Both the **360 s** limit and the amount billed are measured on the
**picture**, not the container (a video whose audio outlives its picture is
billed for the picture's length). Any failure raises before the API is called;
the kit never quietly falls back to an un-ducked mix. Use `mix_with_video` for
silent or longer videos.

### Nothing you have paid for is thrown away

The API charges when the job is **submitted**, and the task then runs to
completion server-side whatever happens to your process — so every failure
after that point keeps the mix you paid for reachable. Transient failures are
retried with backoff (a 5xx while polling, a dropped connection, a 503 from the
storage host mid-download).

If a failure after submit is **not** locally recoverable — the poll fails
terminally, the download can't complete, or the wait times out — the raised
`VideoKitError` names the **task id** and tells you the charge was already made
and the task is still finishing server-side. **No local rescue file is written
in this case.** Recover by polling `GET /v1/tasks/<task_id>` yourself until it
reports `succeeded`, then download the `output_url` it returns: that re-fetches
the mix you already paid for, instead of calling `duck_music_under_speech`
again and being billed twice.

If instead a final, purely-local step fails *after* the API call — remuxing
the ducked audio onto your picture, or placing the file at `output` (e.g. the
disk fills mid-mux) — the kit saves the downloaded ducked audio to
`<output>.ducked.wav` and raises a `VideoKitError` naming that path, so you can
fix the local problem and finish locally. A rescue never overwrites an earlier
one (`<output>.ducked.1.wav`, …), and `output` is always placed atomically.

## Errors

`VideoKitError` (invalid arguments, unreadable video), `FfmpegNotFoundError`
(ffmpeg/ffprobe missing — message includes install hints), `FfmpegError`
(ffmpeg failed — carries `exit_code` and `stderr_tail`), `DuckingFailedError`
(the ducking API accepted the job but could not finish it — carries `code`
and `refunded`). Errors from the Sonilo API pass through as the `sonilo`
package's typed errors.

`refunded` reports what the server said **at the moment the task was polled**,
not a final verdict: the backend marks a task failed before it reverses the
charge (and retries a failed reversal), so `refunded: False` means the reversal
had not landed yet, not that you were definitely billed.

## License

MIT
19 changes: 19 additions & 0 deletions sonilo-video-kit/examples/score_video.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Usage: SONILO_API_KEY=sk_... python examples/score_video.py clip.mp4 "upbeat, energetic"

Generates a soundtrack for a video and mixes it in locally with ffmpeg.
Requires ffmpeg + ffprobe on PATH.
"""
import sys

from sonilo_video_kit import generate_music_for_video, mix_with_video

video = sys.argv[1] if len(sys.argv) > 1 else "clip.mp4"
prompt = sys.argv[2] if len(sys.argv) > 2 else "cinematic orchestral score"
output = "clip.scored.mp4"

track = generate_music_for_video(video, prompt=prompt)

out = mix_with_video(video=video, audio=track.audio, output=output)

title = f' — "{track.title}"' if track.title else ""
print(f"Saved {out}{title}")
26 changes: 26 additions & 0 deletions sonilo-video-kit/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "sonilo-video-kit"
version = "0.1.0"
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.4"]
keywords = ["sonilo", "video", "music", "ffmpeg", "soundtrack", "ducking", "ai"]

[project.urls]
Repository = "https://github.com/sonilo-ai/sonilo-python"

[project.optional-dependencies]
dev = ["pytest>=8", "respx>=0.21"]

[tool.hatch.build.targets.wheel]
packages = ["src/sonilo_video_kit"]

[tool.pytest.ini_options]
testpaths = ["tests"]
20 changes: 20 additions & 0 deletions sonilo-video-kit/src/sonilo_video_kit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Video helpers for the Sonilo API (ffmpeg-based)."""
from .duck import (
MAX_DUCKED_MIX_BYTES, MAX_DUCKING_DURATION_SECONDS, duck_music_under_speech,
)
from .errors import (
DuckingFailedError, FfmpegError, FfmpegNotFoundError, VideoKitError,
)
from .generate import VideoMusicClient, generate_music_for_video
from .loudness import (
DELIVERY_TARGET_LUFS, FALLBACK_MUSIC_LUFS, GAP_BELOW_VOICE_LU, OUTPUT_CEILING_DBFS,
)
from .mix import mix_with_video

__all__ = sorted([
"DELIVERY_TARGET_LUFS", "DuckingFailedError", "FALLBACK_MUSIC_LUFS", "FfmpegError",
"FfmpegNotFoundError", "GAP_BELOW_VOICE_LU", "MAX_DUCKED_MIX_BYTES",
"MAX_DUCKING_DURATION_SECONDS", "OUTPUT_CEILING_DBFS", "VideoKitError",
"VideoMusicClient", "duck_music_under_speech", "generate_music_for_video",
"mix_with_video",
])
Loading
Loading