From 115842bc93a7c51106545e2d45451426c22b0bd6 Mon Sep 17 00:00:00 2001 From: Steven Pan Date: Fri, 4 Sep 2026 15:26:46 -0700 Subject: [PATCH] Dubbing: add lipsync, defaulting to on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lipsync=False` (`--no-lipsync` on the CLI) asks the backend to translate the audio without re-rendering the speaker's mouth. The video comes back at its original resolution and frame rate rather than re-rendered, and only the audio is replaced — so the mouths keep moving to the original language. Omitted from the form body when unset, like every other optional field here, so the server keeps owning the default and this package does not need republishing if that default ever moves. The CLI gets the negative flag only. Unlike `--ducking`, which is default-off server-side and so only useful in the positive direction, lipsync is default-on and the only direction worth spelling is turning it off. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Y2tCTSPesvLbw3hcnQ5ct --- README.md | 13 +++++++++++- sonilo-cli/README.md | 5 +++++ sonilo-cli/src/sonilo_cli/__main__.py | 13 ++++++++++++ src/sonilo/_requests.py | 6 ++++++ src/sonilo/resources/dubbing.py | 29 ++++++++++++++++++++++----- tests/test_dubbing.py | 18 +++++++++++++++++ 6 files changed, 78 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ab4d08b..015dc56 100644 --- a/README.md +++ b/README.md @@ -396,7 +396,18 @@ Portuguese and `es_419` Latin American Spanish; plain `pt` and `es` stay unqualified, as does `ar`). The optional `ducking` boolean (default off, free) ducks the background music/effects bed under the dubbed voice while it speaks; when off the bed is kept at a constant level. (Every endpoint's `ducking` defaults off, -so this one is no exception.) Source videos may be +so this one is no exception.) + +The optional `lipsync` boolean is the one parameter here that defaults **on**: +the speaker's mouth is re-rendered to match the dubbed speech. Pass +`lipsync=False` to leave the picture completely untouched instead — the video +comes back at its original resolution and frame rate rather than re-rendered, +and only the audio is replaced, so the mouths keep moving to the original +language. Reach for it on footage with no on-camera speaker, or when +preserving the exact original picture matters more than matching lip movement. +The background bed is rebuilt either way, so `ducking` behaves the same. + +Source videos may be at most 300 seconds long, and billing is per language: a 3-language call costs three times as much as one. Dubbing has no free trial allowance — see [Free trial](#free-trial). diff --git a/sonilo-cli/README.md b/sonilo-cli/README.md index d0bef08..a22e90f 100644 --- a/sonilo-cli/README.md +++ b/sonilo-cli/README.md @@ -277,6 +277,11 @@ command that produces no media file — nothing is generated: tr, vi, id` (`pt_br` is Brazilian Portuguese and `es_419` Latin American Spanish; plain `pt` and `es` stay unqualified, as does `ar`). - Source videos may be at most 300 seconds long. +- `--no-lipsync` leaves the picture completely untouched. By default the speaker's mouth is + re-rendered to match the dubbed speech; with this flag the video comes back at its original + resolution and frame rate and only the audio is replaced, so the mouths keep moving to the + original language. Use it for footage with no on-camera speaker, or when preserving the exact + original picture matters more than matching lip movement. - `--output` is a filename template, not a single destination: a dubbing task returns one video per language, so `--output clip.mp4` writes `clip.es.mp4`, `clip.fr.mp4`, etc. - Billing is per language, and dubbing has **no free trial runs** — see [Free trial](#free-trial) diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index e42ff6b..6bb8d7d 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -635,6 +635,9 @@ def cmd_dubbing(client: Sonilo, args: argparse.Namespace) -> None: video=args.video, video_url=args.video_url, languages=languages, + # Only sent when the flag is present, so the server keeps owning the + # default (lip sync on). + lipsync=False if args.no_lipsync else None, timeout=args.timeout, ) if not result.outputs: @@ -1010,6 +1013,16 @@ def build_parser() -> argparse.ArgumentParser: "es_419 Latin American Spanish; plain pt and es stay " "unqualified, as does ar.", ) + p_dub.add_argument( + "--no-lipsync", dest="no_lipsync", action="store_true", + help="Leave the picture completely untouched. By default the speaker's " + "mouth is re-rendered to match the dubbed speech; with this the " + "video comes back at its original resolution and frame rate and " + "only the audio is replaced, so the mouths keep moving to the " + "original language. Use it for footage with no on-camera speaker, " + "or when preserving the exact original picture matters more than " + "matching lip movement.", + ) p_dub.add_argument( "--output", default=None, help="Filename template; one file is written per language with the code " diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index d36e58a..fdfcbaa 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -174,6 +174,7 @@ def build_dubbing_parts( video_url: Optional[str], languages: Optional[List[str]], ducking: Optional[bool] = None, + lipsync: Optional[bool] = None, ) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: """Build the multipart parts for POST /v1/dubbing. @@ -206,6 +207,11 @@ def build_dubbing_parts( # when unset so the server default applies. if ducking is not None: data["ducking"] = "true" if ducking else "false" + # Default-ON server-side, unlike ducking — this is the one parameter here + # whose useful direction is turning it off. Omitted when unset all the + # same, so the server keeps owning the default. + if lipsync is not None: + data["lipsync"] = "true" if lipsync else "false" # Now open files (only after data is fully assembled) files: Optional[Dict[str, tuple]] = None diff --git a/src/sonilo/resources/dubbing.py b/src/sonilo/resources/dubbing.py index d5b5adf..3ca409e 100644 --- a/src/sonilo/resources/dubbing.py +++ b/src/sonilo/resources/dubbing.py @@ -25,7 +25,16 @@ class Dubbing: `ducking` (default off, free) ducks the background music/effects bed under the dubbed voice while it speaks; when off the bed is kept at a constant level. Every endpoint's `ducking` defaults off, so this one is - no exception.""" + no exception. + + `lipsync` is the one parameter here that defaults **on**: the speaker's + mouth is re-rendered to match the dubbed speech. Pass `lipsync=False` to + leave the picture completely untouched instead — the video comes back at + its original resolution and frame rate rather than re-rendered, and only + the audio is replaced, so the mouths keep moving to the original language. + Reach for it on footage with no on-camera speaker, or when preserving the + exact original picture matters more than matching lip movement. The + background bed is rebuilt either way, so `ducking` behaves the same.""" def __init__(self, client: "Sonilo") -> None: self._client = client @@ -37,8 +46,11 @@ def submit( video_url: Optional[str] = None, languages: Optional[List[str]] = None, ducking: Optional[bool] = None, + lipsync: Optional[bool] = None, ) -> SfxTask: - data, files, opened = build_dubbing_parts(video, video_url, languages, ducking) + data, files, opened = build_dubbing_parts( + video, video_url, languages, ducking, lipsync + ) 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) @@ -51,11 +63,13 @@ def generate( video_url: Optional[str] = None, languages: Optional[List[str]] = None, ducking: Optional[bool] = None, + lipsync: Optional[bool] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> DubbingResult: task = self.submit( - video=video, video_url=video_url, languages=languages, ducking=ducking + video=video, video_url=video_url, languages=languages, + ducking=ducking, lipsync=lipsync, ) return self._client.tasks.wait( task.task_id, @@ -76,8 +90,11 @@ async def submit( video_url: Optional[str] = None, languages: Optional[List[str]] = None, ducking: Optional[bool] = None, + lipsync: Optional[bool] = None, ) -> SfxTask: - data, files, opened = build_dubbing_parts(video, video_url, languages, ducking) + data, files, opened = build_dubbing_parts( + video, video_url, languages, ducking, lipsync + ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( await self._client._post_json( @@ -92,11 +109,13 @@ async def generate( video_url: Optional[str] = None, languages: Optional[List[str]] = None, ducking: Optional[bool] = None, + lipsync: Optional[bool] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> DubbingResult: task = await self.submit( - video=video, video_url=video_url, languages=languages, ducking=ducking + video=video, video_url=video_url, languages=languages, + ducking=ducking, lipsync=lipsync, ) return await self._client.tasks.wait( task.task_id, diff --git a/tests/test_dubbing.py b/tests/test_dubbing.py index ddd5706..41b843b 100644 --- a/tests/test_dubbing.py +++ b/tests/test_dubbing.py @@ -119,6 +119,24 @@ def test_submit_sends_ducking_only_when_set(): assert "ducking" not in bodies[2] +@respx.mock +def test_submit_sends_lipsync_only_when_set(): + """The mirror of ducking, with the default the other way up: absent must + mean lip sync ON, which is what every dubbing call did before the + parameter existed.""" + route = respx.post("https://api.sonilo.com/v1/dubbing").mock( + return_value=httpx.Response(202, json=ACK) + ) + with Sonilo(api_key="sk-test") as client: + client.dubbing.submit(video_url="https://x/v.mp4", lipsync=False) + client.dubbing.submit(video_url="https://x/v.mp4", lipsync=True) + client.dubbing.submit(video_url="https://x/v.mp4") + bodies = [unquote_plus(c.request.content.decode()) for c in route.calls] + assert "lipsync=false" in bodies[0] + assert "lipsync=true" in bodies[1] + assert "lipsync" not in bodies[2] + + @respx.mock def test_generate_polls_to_a_dubbing_result(): respx.post("https://api.sonilo.com/v1/dubbing").mock(