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