sonilo-cli 0.15.0__tar.gz → 0.16.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.
@@ -1,3 +1,19 @@
1
+ Metadata-Version: 2.5
2
+ Name: sonilo-cli
3
+ Version: 0.16.0
4
+ Summary: Command-line interface for the Sonilo API: generate music and sound effects from text or video
5
+ Project-URL: Repository, https://github.com/sonilo-ai/sonilo-python
6
+ Author: Sonilo AI
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: ai,cli,music,sfx,sonilo,text-to-music,video-to-music
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: sonilo<0.18,>=0.17.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8; extra == 'dev'
14
+ Requires-Dist: respx>=0.21; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
1
17
  # sonilo-cli
2
18
 
3
19
  Command-line interface for the [Sonilo API](https://github.com/sonilo-ai/sonilo-python) — generate music and sound effects from text or video.
@@ -284,6 +300,20 @@ command that produces no media file — nothing is generated:
284
300
  original picture matters more than matching lip movement.
285
301
  - `--output` is a filename template, not a single destination: a dubbing task returns one video
286
302
  per language, so `--output clip.mp4` writes `clip.es.mp4`, `clip.fr.mp4`, etc.
303
+ - `--ducking` ducks the background music/effects bed under the dubbed voice while it speaks;
304
+ off by default, so the bed otherwise stays at a static level. `--no-ducking` states that
305
+ default explicitly.
306
+ - `--subtitle <language>=<path-or-url>` gives one language the script to speak, as an `.srt`/`.vtt`
307
+ file or an https URL. Repeat it once per language; the set must match `--languages` exactly.
308
+ The scripts are in the **target** language, not the source's:
309
+
310
+ sonilo dubbing --video clip.mp4 --languages es,fr \
311
+ --subtitle es=spanish.srt --subtitle fr=https://example.com/french.vtt --export-srt
312
+
313
+ - `--export-srt` (requires `--subtitle`) returns a re-timed `.srt` per language, aligned to the
314
+ delivered audio with your lines kept verbatim. Each one is written beside its video
315
+ (`clip.es.mp4` -> `clip.es.srt`), and one status line per language is printed. A language whose
316
+ export is blocked still gets its video — only the `.srt` is missing.
287
317
  - Billing is per language, and dubbing has **no free trial runs** — see [Free trial](#free-trial)
288
318
  below.
289
319
  - `--timeout` defaults to 7200 seconds, matching the backend's own ceiling for a dubbing job
@@ -1,19 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: sonilo-cli
3
- Version: 0.15.0
4
- Summary: Command-line interface for the Sonilo API: generate music and sound effects from text or video
5
- Project-URL: Repository, https://github.com/sonilo-ai/sonilo-python
6
- Author: Sonilo AI
7
- License-Expression: MIT
8
- License-File: LICENSE
9
- Keywords: ai,cli,music,sfx,sonilo,text-to-music,video-to-music
10
- Requires-Python: >=3.9
11
- Requires-Dist: sonilo<0.17,>=0.16.0
12
- Provides-Extra: dev
13
- Requires-Dist: pytest>=8; extra == 'dev'
14
- Requires-Dist: respx>=0.21; extra == 'dev'
15
- Description-Content-Type: text/markdown
16
-
17
1
  # sonilo-cli
18
2
 
19
3
  Command-line interface for the [Sonilo API](https://github.com/sonilo-ai/sonilo-python) — generate music and sound effects from text or video.
@@ -300,6 +284,20 @@ command that produces no media file — nothing is generated:
300
284
  original picture matters more than matching lip movement.
301
285
  - `--output` is a filename template, not a single destination: a dubbing task returns one video
302
286
  per language, so `--output clip.mp4` writes `clip.es.mp4`, `clip.fr.mp4`, etc.
287
+ - `--ducking` ducks the background music/effects bed under the dubbed voice while it speaks;
288
+ off by default, so the bed otherwise stays at a static level. `--no-ducking` states that
289
+ default explicitly.
290
+ - `--subtitle <language>=<path-or-url>` gives one language the script to speak, as an `.srt`/`.vtt`
291
+ file or an https URL. Repeat it once per language; the set must match `--languages` exactly.
292
+ The scripts are in the **target** language, not the source's:
293
+
294
+ sonilo dubbing --video clip.mp4 --languages es,fr \
295
+ --subtitle es=spanish.srt --subtitle fr=https://example.com/french.vtt --export-srt
296
+
297
+ - `--export-srt` (requires `--subtitle`) returns a re-timed `.srt` per language, aligned to the
298
+ delivered audio with your lines kept verbatim. Each one is written beside its video
299
+ (`clip.es.mp4` -> `clip.es.srt`), and one status line per language is printed. A language whose
300
+ export is blocked still gets its video — only the `.srt` is missing.
303
301
  - Billing is per language, and dubbing has **no free trial runs** — see [Free trial](#free-trial)
304
302
  below.
305
303
  - `--timeout` defaults to 7200 seconds, matching the backend's own ceiling for a dubbing job
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sonilo-cli"
7
- version = "0.15.0"
7
+ version = "0.16.0"
8
8
  description = "Command-line interface for the Sonilo API: generate music and sound effects from text or video"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.9"
12
12
  authors = [{ name = "Sonilo AI" }]
13
- dependencies = ["sonilo>=0.16.0,<0.17"]
13
+ dependencies = ["sonilo>=0.17.0,<0.18"]
14
14
  keywords = ["sonilo", "cli", "music", "sfx", "text-to-music", "video-to-music", "ai"]
15
15
 
16
16
  [project.urls]
@@ -1,3 +1,3 @@
1
- __version__ = "0.14.3"
1
+ __version__ = "0.16.0"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -624,6 +624,44 @@ def _language_path(out: str, language: str) -> str:
624
624
  return str(base.with_name(f"{base.stem}.{language}{base.suffix or '.mp4'}"))
625
625
 
626
626
 
627
+ def _subtitles(values: Optional[List[str]]) -> Optional[Dict[str, str]]:
628
+ """Turn repeated `--subtitle <lang>=<path-or-url>` values into the map the
629
+ SDK takes. The `=` is split once only, so a Windows path or a URL with its
630
+ own `=` survives. Neither the codes nor the language set are checked here —
631
+ the server owns both rules and names what it refused."""
632
+ if not values:
633
+ return None
634
+ subtitles: Dict[str, str] = {}
635
+ for value in values:
636
+ language, _, source = value.partition("=")
637
+ language = language.strip()
638
+ if not language or not source:
639
+ _fail(
640
+ f"--subtitle needs <language>=<path-or-url>, got {value!r} "
641
+ "(e.g. --subtitle es=spanish.srt)"
642
+ )
643
+ if language in subtitles:
644
+ _fail(f"--subtitle {language} given twice; one script per language")
645
+ subtitles[language] = source
646
+ return subtitles
647
+
648
+
649
+ def _export_line(language: str, report: Dict[str, Any]) -> str:
650
+ """One status line per language. `alignment_loss` arrives as a float or as
651
+ a string — the pipeline stores numbers as strings — so it is formatted
652
+ through float() and simply omitted when it is neither."""
653
+ status = report.get("status") or "unknown"
654
+ line = f"Subtitle {language}: {status}"
655
+ try:
656
+ line += f" (alignment loss {float(report['alignment_loss']):.3f})"
657
+ except (KeyError, TypeError, ValueError):
658
+ pass
659
+ error = report.get("error")
660
+ if error:
661
+ line += f" — {error}"
662
+ return line
663
+
664
+
627
665
  def cmd_dubbing(client: Sonilo, args: argparse.Namespace) -> None:
628
666
  out = args.output if args.output is not None else "output.mp4"
629
667
  languages = None
@@ -631,13 +669,29 @@ def cmd_dubbing(client: Sonilo, args: argparse.Namespace) -> None:
631
669
  languages = [code.strip() for code in args.languages.split(",") if code.strip()]
632
670
  if not languages:
633
671
  _fail("--languages needs at least one language code, e.g. --languages es,fr")
672
+ subtitles = _subtitles(args.subtitle)
673
+ if args.export_srt and subtitles is None:
674
+ _fail("--export-srt needs --subtitle <language>=<path-or-url> for each language")
675
+ # Both files are derived from the same template, and the subtitle is
676
+ # written second: an .srt template would have clip.es.srt overwrite the
677
+ # video that had just been saved to clip.es.srt, reporting both writes as
678
+ # successes. Refuse it here rather than destroy the deliverable.
679
+ if args.export_srt and Path(out).suffix.lower() == ".srt":
680
+ _fail(
681
+ f"--output {out} ends in .srt, which --export-srt would overwrite with "
682
+ "the subtitle — name the video (e.g. --output clip.mp4) and the .srt "
683
+ "is written beside it"
684
+ )
634
685
  result = client.dubbing.generate(
635
686
  video=args.video,
636
687
  video_url=args.video_url,
637
688
  languages=languages,
689
+ ducking=_ducking(args),
638
690
  # Only sent when the flag is present, so the server keeps owning the
639
691
  # default (lip sync on).
640
692
  lipsync=False if args.no_lipsync else None,
693
+ subtitles=subtitles,
694
+ export_srt=True if args.export_srt else None,
641
695
  timeout=args.timeout,
642
696
  )
643
697
  if not result.outputs:
@@ -645,6 +699,17 @@ def cmd_dubbing(client: Sonilo, args: argparse.Namespace) -> None:
645
699
  for language in sorted(result.outputs):
646
700
  path = result.save(language, _language_path(out, language))
647
701
  _wrote(path, path.stat().st_size)
702
+ if not args.export_srt:
703
+ return
704
+ # The .srt lands beside its video (clip.es.mp4 -> clip.es.srt). A blocked
705
+ # export still delivers the video, so every requested language gets a
706
+ # status line whether or not a file came back with it.
707
+ for language in sorted(result.subtitle_export or result.subtitles):
708
+ if language in result.subtitles:
709
+ srt = Path(_language_path(out, language)).with_suffix(".srt")
710
+ path = result.save_subtitle(language, srt)
711
+ _wrote(path, path.stat().st_size)
712
+ print(_export_line(language, result.subtitle_export.get(language, {})))
648
713
 
649
714
 
650
715
  def _identity(body: Any) -> Any:
@@ -1023,6 +1088,29 @@ def build_parser() -> argparse.ArgumentParser:
1023
1088
  "or when preserving the exact original picture matters more than "
1024
1089
  "matching lip movement.",
1025
1090
  )
1091
+ p_dub.add_argument(
1092
+ "--ducking", dest="ducking", action="store_true",
1093
+ help="Duck the background music/effects bed under the dubbed voice while "
1094
+ "it speaks, instead of keeping it at a static level. Off by default.",
1095
+ )
1096
+ p_dub.add_argument(
1097
+ "--no-ducking", dest="no_ducking", action="store_true",
1098
+ help="Explicit opt-out. Same as the default; kept so existing "
1099
+ "scripts keep working.",
1100
+ )
1101
+ p_dub.add_argument(
1102
+ "--subtitle", dest="subtitle", action="append", default=None,
1103
+ metavar="LANG=SOURCE",
1104
+ help="Script to speak in one target language, as <language>=<path> or "
1105
+ "<language>=<https URL>. Repeat once per language. Scripts are "
1106
+ ".srt or .vtt in the TARGET language, not source transcripts.",
1107
+ )
1108
+ p_dub.add_argument(
1109
+ "--export-srt", dest="export_srt", action="store_true",
1110
+ help="Return a re-timed .srt per language, aligned to the delivered "
1111
+ "audio and keeping your lines verbatim. Written beside each video "
1112
+ "(clip.es.mp4 -> clip.es.srt). Requires --subtitle.",
1113
+ )
1026
1114
  p_dub.add_argument(
1027
1115
  "--output", default=None,
1028
1116
  help="Filename template; one file is written per language with the code "
@@ -720,6 +720,249 @@ def test_dubbing_without_languages_omits_the_field(tmp_path):
720
720
  assert b"languages" not in route.calls.last.request.content
721
721
 
722
722
 
723
+ # --- dubbing ducking -------------------------------------------------------
724
+
725
+
726
+ def _stub_plain_dubbing():
727
+ route = respx.post(f"{BASE}/v1/dubbing").mock(
728
+ return_value=httpx.Response(202, json={"task_id": "db1", "status": "processing"})
729
+ )
730
+ respx.get(f"{BASE}/v1/tasks/db1").mock(
731
+ return_value=httpx.Response(200, json={
732
+ "task_id": "db1", "status": "succeeded",
733
+ "outputs": {"es": "https://r2/es.mp4"},
734
+ })
735
+ )
736
+ respx.get("https://r2/es.mp4").mock(
737
+ return_value=httpx.Response(200, content=b"es-bytes")
738
+ )
739
+ return route
740
+
741
+
742
+ @respx.mock
743
+ @pytest.mark.parametrize(
744
+ "flags, wire",
745
+ [
746
+ # ducking is default-OFF server-side, so each flag is only ever sent
747
+ # to change that default.
748
+ (["--ducking"], "ducking=true"),
749
+ (["--no-ducking"], "ducking=false"),
750
+ ],
751
+ )
752
+ def test_dubbing_ducking_flags(tmp_path, flags, wire):
753
+ route = _stub_plain_dubbing()
754
+ run([
755
+ "dubbing", "--video-url", "https://x/v.mp4",
756
+ "--output", str(tmp_path / "clip.mp4"),
757
+ ] + flags)
758
+ assert wire in unquote_plus(route.calls.last.request.content.decode())
759
+
760
+
761
+ @respx.mock
762
+ def test_dubbing_omits_ducking_when_unset(tmp_path):
763
+ route = _stub_plain_dubbing()
764
+ run([
765
+ "dubbing", "--video-url", "https://x/v.mp4",
766
+ "--output", str(tmp_path / "clip.mp4"),
767
+ ])
768
+ # Absent must stay absent: the server default (ducking off) only applies
769
+ # when the field is not sent at all.
770
+ assert "ducking=" not in route.calls.last.request.content.decode()
771
+
772
+
773
+ def test_dubbing_rejects_both_ducking_flags(tmp_path):
774
+ with pytest.raises(SystemExit) as exc:
775
+ run(["dubbing", "--video-url", "https://x/v.mp4",
776
+ "--output", str(tmp_path / "clip.mp4"), "--ducking", "--no-ducking"])
777
+ # The helper's own message, not argparse's: matching only the exit would
778
+ # also pass against a build with neither flag defined.
779
+ assert "pass at most one of --ducking or --no-ducking" in str(exc.value)
780
+
781
+
782
+ # --- dubbing subtitles ----------------------------------------------------
783
+
784
+ SUBTITLED_BODY = {
785
+ "task_id": "db1",
786
+ "type": "dubbing",
787
+ "status": "succeeded",
788
+ "outputs": {"es": "https://r2/es.mp4", "fr": "https://r2/fr.mp4"},
789
+ # fr's export was blocked: its video is still delivered, with no .srt.
790
+ "subtitles": {"es": "https://r2/es.srt"},
791
+ "subtitle_export": {
792
+ # The pipeline stores numbers as strings, so the loss arrives as one.
793
+ "es": {"status": "exported", "alignment_loss": "0.6305176995017312"},
794
+ "fr": {"status": "blocked", "error": "alignment failed"},
795
+ },
796
+ }
797
+
798
+
799
+ def _stub_subtitled_dubbing():
800
+ route = respx.post(f"{BASE}/v1/dubbing").mock(
801
+ return_value=httpx.Response(202, json={"task_id": "db1", "status": "processing"})
802
+ )
803
+ respx.get(f"{BASE}/v1/tasks/db1").mock(
804
+ return_value=httpx.Response(200, json=SUBTITLED_BODY)
805
+ )
806
+ for language in ("es", "fr"):
807
+ respx.get(f"https://r2/{language}.mp4").mock(
808
+ return_value=httpx.Response(200, content=f"{language}-bytes".encode())
809
+ )
810
+ respx.get("https://r2/es.srt").mock(
811
+ return_value=httpx.Response(200, content=b"1\nhola\n")
812
+ )
813
+ return route
814
+
815
+
816
+ @respx.mock
817
+ def test_dubbing_sends_subtitle_urls(tmp_path):
818
+ route = _stub_subtitled_dubbing()
819
+ run([
820
+ "dubbing",
821
+ "--video-url", "https://x/v.mp4",
822
+ "--languages", "es,fr",
823
+ "--subtitle", "es=https://x/es.srt",
824
+ "--subtitle", "fr=https://x/fr.vtt",
825
+ "--output", str(tmp_path / "clip.mp4"),
826
+ ])
827
+ body = unquote_plus(route.calls.last.request.content.decode())
828
+ assert "subtitles[es]=https://x/es.srt" in body
829
+ assert "subtitles[fr]=https://x/fr.vtt" in body
830
+
831
+
832
+ @respx.mock
833
+ def test_dubbing_uploads_local_subtitle_files(tmp_path):
834
+ route = _stub_subtitled_dubbing()
835
+ script = tmp_path / "spanish.srt"
836
+ script.write_text("1\n")
837
+ run([
838
+ "dubbing",
839
+ "--video-url", "https://x/v.mp4",
840
+ "--languages", "es,fr",
841
+ "--subtitle", f"es={script}",
842
+ "--subtitle", "fr=https://x/fr.vtt",
843
+ "--output", str(tmp_path / "clip.mp4"),
844
+ ])
845
+ body = route.calls.last.request.content.decode(errors="replace")
846
+ assert 'name="subtitles[es]"' in body
847
+ assert 'filename="spanish.srt"' in body
848
+
849
+
850
+ @respx.mock
851
+ def test_dubbing_export_srt_writes_the_srt_beside_each_video(tmp_path, capsys):
852
+ _stub_subtitled_dubbing()
853
+ run([
854
+ "dubbing",
855
+ "--video-url", "https://x/v.mp4",
856
+ "--languages", "es,fr",
857
+ "--subtitle", "es=https://x/es.srt",
858
+ "--subtitle", "fr=https://x/fr.vtt",
859
+ "--export-srt",
860
+ "--output", str(tmp_path / "clip.mp4"),
861
+ ])
862
+ assert (tmp_path / "clip.es.mp4").read_bytes() == b"es-bytes"
863
+ assert (tmp_path / "clip.es.srt").read_bytes() == b"1\nhola\n"
864
+ # fr's export was blocked, so there is a video but no .srt for it.
865
+ assert (tmp_path / "clip.fr.mp4").exists()
866
+ assert not (tmp_path / "clip.fr.srt").exists()
867
+ out = capsys.readouterr().out
868
+ assert "Subtitle es: exported (alignment loss 0.631)" in out
869
+ assert "Subtitle fr: blocked" in out
870
+
871
+
872
+ @respx.mock
873
+ def test_dubbing_export_srt_tolerates_a_numeric_alignment_loss(tmp_path, capsys):
874
+ respx.post(f"{BASE}/v1/dubbing").mock(
875
+ return_value=httpx.Response(202, json={"task_id": "db1", "status": "processing"})
876
+ )
877
+ respx.get(f"{BASE}/v1/tasks/db1").mock(
878
+ return_value=httpx.Response(200, json={
879
+ "task_id": "db1", "status": "succeeded",
880
+ "outputs": {"es": "https://r2/es.mp4"},
881
+ "subtitles": {"es": "https://r2/es.srt"},
882
+ "subtitle_export": {"es": {"status": "exported", "alignment_loss": 0.25}},
883
+ })
884
+ )
885
+ respx.get("https://r2/es.mp4").mock(
886
+ return_value=httpx.Response(200, content=b"es-bytes")
887
+ )
888
+ respx.get("https://r2/es.srt").mock(
889
+ return_value=httpx.Response(200, content=b"1\nhola\n")
890
+ )
891
+ run([
892
+ "dubbing",
893
+ "--video-url", "https://x/v.mp4",
894
+ "--subtitle", "es=https://x/es.srt",
895
+ "--export-srt",
896
+ "--output", str(tmp_path / "clip.mp4"),
897
+ ])
898
+ assert "Subtitle es: exported (alignment loss 0.250)" in capsys.readouterr().out
899
+
900
+
901
+ def test_dubbing_export_srt_refuses_an_srt_output_template(capsys, tmp_path):
902
+ """Both files come from one template and the subtitle is written second, so
903
+ an .srt template would silently overwrite the dubbed video."""
904
+ with pytest.raises(SystemExit) as exc:
905
+ main([
906
+ "--api-key", "sk-test", "dubbing", "--video-url", "https://x/v.mp4",
907
+ "--subtitle", "es=https://x/es.srt", "--export-srt",
908
+ "--output", str(tmp_path / "clip.SRT"),
909
+ ])
910
+ assert exc.value.code == 1
911
+ err = capsys.readouterr().err
912
+ assert "--export-srt would overwrite" in err
913
+ assert not (tmp_path / "clip.es.SRT").exists()
914
+
915
+
916
+ def test_dubbing_export_srt_without_a_subtitle_exits_1(capsys):
917
+ with pytest.raises(SystemExit) as exc:
918
+ main([
919
+ "--api-key", "sk-test", "dubbing",
920
+ "--video-url", "https://x/v.mp4", "--export-srt",
921
+ ])
922
+ assert exc.value.code == 1
923
+ assert "--subtitle" in capsys.readouterr().err
924
+
925
+
926
+ def test_dubbing_rejects_a_subtitle_without_a_language(capsys):
927
+ with pytest.raises(SystemExit) as exc:
928
+ main([
929
+ "--api-key", "sk-test", "dubbing",
930
+ "--video-url", "https://x/v.mp4", "--subtitle", "spanish.srt",
931
+ ])
932
+ assert exc.value.code == 1
933
+ # Match our own message, not just the flag name: argparse's "unrecognized
934
+ # arguments" error quotes the flag too, so a laxer assertion would pass
935
+ # against a build where --subtitle does not exist at all.
936
+ err = capsys.readouterr().err
937
+ assert "--subtitle needs <language>=<path-or-url>" in err
938
+ assert "'spanish.srt'" in err
939
+
940
+
941
+ def test_dubbing_rejects_the_same_language_twice(capsys):
942
+ with pytest.raises(SystemExit) as exc:
943
+ main([
944
+ "--api-key", "sk-test", "dubbing", "--video-url", "https://x/v.mp4",
945
+ "--subtitle", "es=a.srt", "--subtitle", "es=b.srt",
946
+ ])
947
+ assert exc.value.code == 1
948
+ assert "twice" in capsys.readouterr().err
949
+
950
+
951
+ @respx.mock
952
+ def test_dubbing_rejects_a_subtitle_that_is_not_srt_or_vtt(tmp_path, capsys):
953
+ route = respx.post(f"{BASE}/v1/dubbing")
954
+ script = tmp_path / "es.txt"
955
+ script.write_text("hola")
956
+ with pytest.raises(SystemExit) as exc:
957
+ main([
958
+ "--api-key", "sk-test", "dubbing",
959
+ "--video-url", "https://x/v.mp4", "--subtitle", f"es={script}",
960
+ ])
961
+ assert exc.value.code == 1
962
+ assert "'es.txt' must be .srt or .vtt" in capsys.readouterr().err
963
+ assert not route.called
964
+
965
+
723
966
  # --- --segments -----------------------------------------------------------
724
967
  #
725
968
  # The two shapes are not interchangeable: music segments are
File without changes
File without changes