sonilo-cli 0.14.3__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.
@@ -277,8 +293,27 @@ command that produces no media file — nothing is generated:
277
293
  tr, vi, id` (`pt_br` is Brazilian Portuguese and `es_419` Latin American
278
294
  Spanish; plain `pt` and `es` stay unqualified, as does `ar`).
279
295
  - Source videos may be at most 300 seconds long.
296
+ - `--no-lipsync` leaves the picture completely untouched. By default the speaker's mouth is
297
+ re-rendered to match the dubbed speech; with this flag the video comes back at its original
298
+ resolution and frame rate and only the audio is replaced, so the mouths keep moving to the
299
+ original language. Use it for footage with no on-camera speaker, or when preserving the exact
300
+ original picture matters more than matching lip movement.
280
301
  - `--output` is a filename template, not a single destination: a dubbing task returns one video
281
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.
282
317
  - Billing is per language, and dubbing has **no free trial runs** — see [Free trial](#free-trial)
283
318
  below.
284
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.14.3
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.16,>=0.15.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.
@@ -293,8 +277,27 @@ command that produces no media file — nothing is generated:
293
277
  tr, vi, id` (`pt_br` is Brazilian Portuguese and `es_419` Latin American
294
278
  Spanish; plain `pt` and `es` stay unqualified, as does `ar`).
295
279
  - Source videos may be at most 300 seconds long.
280
+ - `--no-lipsync` leaves the picture completely untouched. By default the speaker's mouth is
281
+ re-rendered to match the dubbed speech; with this flag the video comes back at its original
282
+ resolution and frame rate and only the audio is replaced, so the mouths keep moving to the
283
+ original language. Use it for footage with no on-camera speaker, or when preserving the exact
284
+ original picture matters more than matching lip movement.
296
285
  - `--output` is a filename template, not a single destination: a dubbing task returns one video
297
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.
298
301
  - Billing is per language, and dubbing has **no free trial runs** — see [Free trial](#free-trial)
299
302
  below.
300
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.14.3"
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.15.0,<0.16"]
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,10 +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),
690
+ # Only sent when the flag is present, so the server keeps owning the
691
+ # default (lip sync on).
692
+ lipsync=False if args.no_lipsync else None,
693
+ subtitles=subtitles,
694
+ export_srt=True if args.export_srt else None,
638
695
  timeout=args.timeout,
639
696
  )
640
697
  if not result.outputs:
@@ -642,6 +699,17 @@ def cmd_dubbing(client: Sonilo, args: argparse.Namespace) -> None:
642
699
  for language in sorted(result.outputs):
643
700
  path = result.save(language, _language_path(out, language))
644
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, {})))
645
713
 
646
714
 
647
715
  def _identity(body: Any) -> Any:
@@ -1010,6 +1078,39 @@ def build_parser() -> argparse.ArgumentParser:
1010
1078
  "es_419 Latin American Spanish; plain pt and es stay "
1011
1079
  "unqualified, as does ar.",
1012
1080
  )
1081
+ p_dub.add_argument(
1082
+ "--no-lipsync", dest="no_lipsync", action="store_true",
1083
+ help="Leave the picture completely untouched. By default the speaker's "
1084
+ "mouth is re-rendered to match the dubbed speech; with this the "
1085
+ "video comes back at its original resolution and frame rate and "
1086
+ "only the audio is replaced, so the mouths keep moving to the "
1087
+ "original language. Use it for footage with no on-camera speaker, "
1088
+ "or when preserving the exact original picture matters more than "
1089
+ "matching lip movement.",
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
+ )
1013
1114
  p_dub.add_argument(
1014
1115
  "--output", default=None,
1015
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