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.
- sonilo_cli-0.15.0/README.md → sonilo_cli-0.16.0/PKG-INFO +30 -0
- sonilo_cli-0.15.0/PKG-INFO → sonilo_cli-0.16.0/README.md +14 -16
- {sonilo_cli-0.15.0 → sonilo_cli-0.16.0}/pyproject.toml +2 -2
- {sonilo_cli-0.15.0 → sonilo_cli-0.16.0}/src/sonilo_cli/__init__.py +1 -1
- {sonilo_cli-0.15.0 → sonilo_cli-0.16.0}/src/sonilo_cli/__main__.py +88 -0
- {sonilo_cli-0.15.0 → sonilo_cli-0.16.0}/tests/test_cli.py +243 -0
- {sonilo_cli-0.15.0 → sonilo_cli-0.16.0}/.gitignore +0 -0
- {sonilo_cli-0.15.0 → sonilo_cli-0.16.0}/LICENSE +0 -0
- {sonilo_cli-0.15.0 → sonilo_cli-0.16.0}/src/sonilo_cli/credentials.py +0 -0
- {sonilo_cli-0.15.0 → sonilo_cli-0.16.0}/src/sonilo_cli/login.py +0 -0
- {sonilo_cli-0.15.0 → sonilo_cli-0.16.0}/tests/__init__.py +0 -0
- {sonilo_cli-0.15.0 → sonilo_cli-0.16.0}/tests/conftest.py +0 -0
- {sonilo_cli-0.15.0 → sonilo_cli-0.16.0}/tests/test_context7.py +0 -0
- {sonilo_cli-0.15.0 → sonilo_cli-0.16.0}/tests/test_credentials.py +0 -0
- {sonilo_cli-0.15.0 → sonilo_cli-0.16.0}/tests/test_login.py +0 -0
- {sonilo_cli-0.15.0 → sonilo_cli-0.16.0}/tests/test_smoke.py +0 -0
|
@@ -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.
|
|
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.
|
|
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]
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|