srtforge 0.1.2__tar.gz → 0.2.1__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.
- {srtforge-0.1.2/src/srtforge.egg-info → srtforge-0.2.1}/PKG-INFO +30 -5
- {srtforge-0.1.2 → srtforge-0.2.1}/README.md +27 -3
- {srtforge-0.1.2 → srtforge-0.2.1}/pyproject.toml +5 -1
- {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge/__init__.py +1 -1
- srtforge-0.2.1/src/srtforge/_types.py +24 -0
- {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge/cli.py +106 -27
- {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge/segment.py +107 -61
- srtforge-0.2.1/src/srtforge/srt.py +110 -0
- {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge/transcribe.py +3 -2
- {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge/transcript.py +24 -4
- {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge/translate.py +6 -3
- srtforge-0.2.1/src/srtforge/translation_completion.py +84 -0
- {srtforge-0.1.2 → srtforge-0.2.1/src/srtforge.egg-info}/PKG-INFO +30 -5
- {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge.egg-info/SOURCES.txt +5 -1
- {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge.egg-info/requires.txt +2 -0
- srtforge-0.2.1/tests/test_cli.py +359 -0
- srtforge-0.2.1/tests/test_merge.py +39 -0
- srtforge-0.2.1/tests/test_segment.py +176 -0
- srtforge-0.2.1/tests/test_srt.py +212 -0
- {srtforge-0.1.2 → srtforge-0.2.1}/tests/test_transcript.py +28 -0
- srtforge-0.2.1/tests/test_translate.py +116 -0
- srtforge-0.2.1/tests/test_translation_completion.py +157 -0
- srtforge-0.1.2/src/srtforge/srt.py +0 -31
- srtforge-0.1.2/tests/test_cli.py +0 -64
- srtforge-0.1.2/tests/test_merge.py +0 -16
- srtforge-0.1.2/tests/test_segment.py +0 -33
- srtforge-0.1.2/tests/test_srt.py +0 -19
- {srtforge-0.1.2 → srtforge-0.2.1}/LICENSE +0 -0
- {srtforge-0.1.2 → srtforge-0.2.1}/setup.cfg +0 -0
- {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge/audio.py +0 -0
- {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge/merge.py +1 -1
- {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge.egg-info/dependency_links.txt +0 -0
- {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge.egg-info/entry_points.txt +0 -0
- {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: srtforge
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Generate, translate, and burn SRT subtitles locally with MLX Whisper.
|
|
5
5
|
Author: rromanv
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,7 +22,8 @@ Requires-Python: <3.13,>=3.10
|
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Requires-Dist: mlx-whisper>=0.4
|
|
25
|
-
|
|
25
|
+
Provides-Extra: translate
|
|
26
|
+
Requires-Dist: mlx-lm>=0.20; extra == "translate"
|
|
26
27
|
Dynamic: license-file
|
|
27
28
|
|
|
28
29
|
# srtforge
|
|
@@ -76,6 +77,12 @@ From PyPI:
|
|
|
76
77
|
pipx install --python python3.11 srtforge
|
|
77
78
|
```
|
|
78
79
|
|
|
80
|
+
If you want local LLM translation support, install the translation extra:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pipx install --python python3.11 'srtforge[translate]'
|
|
84
|
+
```
|
|
85
|
+
|
|
79
86
|
`srtforge` currently supports Python 3.10-3.12. If your default Python is newer
|
|
80
87
|
than that, such as Python 3.14, pass a supported interpreter explicitly with
|
|
81
88
|
`--python`.
|
|
@@ -86,6 +93,12 @@ From GitHub:
|
|
|
86
93
|
pipx install --python python3.11 git+https://github.com/rromanv/srtforge.git
|
|
87
94
|
```
|
|
88
95
|
|
|
96
|
+
With translation support from GitHub:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pipx install --python python3.11 'srtforge[translate] @ git+https://github.com/rromanv/srtforge.git'
|
|
100
|
+
```
|
|
101
|
+
|
|
89
102
|
For local development:
|
|
90
103
|
|
|
91
104
|
```bash
|
|
@@ -94,6 +107,7 @@ cd srtforge
|
|
|
94
107
|
python3.11 -m venv .venv
|
|
95
108
|
source .venv/bin/activate
|
|
96
109
|
pip install -e .
|
|
110
|
+
pip install -e '.[translate]' # optional, for `srtforge -t ...`
|
|
97
111
|
```
|
|
98
112
|
|
|
99
113
|
Or into a virtual environment:
|
|
@@ -102,6 +116,7 @@ Or into a virtual environment:
|
|
|
102
116
|
python3.11 -m venv .venv
|
|
103
117
|
source .venv/bin/activate
|
|
104
118
|
pip install srtforge
|
|
119
|
+
pip install 'srtforge[translate]' # optional, for `srtforge -t ...`
|
|
105
120
|
```
|
|
106
121
|
|
|
107
122
|
## Troubleshooting
|
|
@@ -177,6 +192,8 @@ srtforge video.mp4 -t "Brazilian Portuguese"
|
|
|
177
192
|
srtforge video.mp4 -t ja --translate-model mlx-community/Qwen3.5-9B-OptiQ-4bit
|
|
178
193
|
```
|
|
179
194
|
|
|
195
|
+
Translation requires installing `srtforge[translate]`.
|
|
196
|
+
|
|
180
197
|
Burn subtitles into a video:
|
|
181
198
|
|
|
182
199
|
```bash
|
|
@@ -184,6 +201,11 @@ srtforge merge video.mp4 video.srt -o final.mp4
|
|
|
184
201
|
srtforge merge video.mp4 video.es.srt --crf 16
|
|
185
202
|
```
|
|
186
203
|
|
|
204
|
+
Existing outputs are not overwritten by default. Pass `--force` when you intend
|
|
205
|
+
to replace an existing `.srt` or merged video output.
|
|
206
|
+
Subtitle files are published atomically, so a failed write preserves any
|
|
207
|
+
existing destination instead of leaving a partial file.
|
|
208
|
+
|
|
187
209
|
Run:
|
|
188
210
|
|
|
189
211
|
```bash
|
|
@@ -202,8 +224,9 @@ so they are easier to read:
|
|
|
202
224
|
- cues are paced around 17 characters per second
|
|
203
225
|
- cues are adjusted to avoid overlap
|
|
204
226
|
|
|
205
|
-
Translated subtitles
|
|
206
|
-
be longer or shorter than the source.
|
|
227
|
+
Translated subtitles receive Cue Fit after normal Cue Planning because translated
|
|
228
|
+
text can be longer or shorter than the source. With `--no-resegment`, translated
|
|
229
|
+
subtitles retain the raw Whisper Cue shape.
|
|
207
230
|
|
|
208
231
|
## ffmpeg Notes
|
|
209
232
|
|
|
@@ -225,8 +248,9 @@ src/srtforge/
|
|
|
225
248
|
segment.py # sentence-aware re-cueing, wrapping, pacing
|
|
226
249
|
translate.py # context-aware local-LLM translation
|
|
227
250
|
transcript.py # reference transcript reading + correction
|
|
251
|
+
_types.py # shared typed subtitle data shapes
|
|
228
252
|
merge.py # burn subtitles into video with ffmpeg/libass
|
|
229
|
-
srt.py # SRT rendering
|
|
253
|
+
srt.py # SRT rendering and safe publication
|
|
230
254
|
cli.py # argparse CLI
|
|
231
255
|
tests/
|
|
232
256
|
test_cli.py
|
|
@@ -234,6 +258,7 @@ tests/
|
|
|
234
258
|
test_segment.py
|
|
235
259
|
test_srt.py
|
|
236
260
|
test_transcript.py
|
|
261
|
+
test_translate.py
|
|
237
262
|
```
|
|
238
263
|
|
|
239
264
|
## Development
|
|
@@ -49,6 +49,12 @@ From PyPI:
|
|
|
49
49
|
pipx install --python python3.11 srtforge
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
If you want local LLM translation support, install the translation extra:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pipx install --python python3.11 'srtforge[translate]'
|
|
56
|
+
```
|
|
57
|
+
|
|
52
58
|
`srtforge` currently supports Python 3.10-3.12. If your default Python is newer
|
|
53
59
|
than that, such as Python 3.14, pass a supported interpreter explicitly with
|
|
54
60
|
`--python`.
|
|
@@ -59,6 +65,12 @@ From GitHub:
|
|
|
59
65
|
pipx install --python python3.11 git+https://github.com/rromanv/srtforge.git
|
|
60
66
|
```
|
|
61
67
|
|
|
68
|
+
With translation support from GitHub:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pipx install --python python3.11 'srtforge[translate] @ git+https://github.com/rromanv/srtforge.git'
|
|
72
|
+
```
|
|
73
|
+
|
|
62
74
|
For local development:
|
|
63
75
|
|
|
64
76
|
```bash
|
|
@@ -67,6 +79,7 @@ cd srtforge
|
|
|
67
79
|
python3.11 -m venv .venv
|
|
68
80
|
source .venv/bin/activate
|
|
69
81
|
pip install -e .
|
|
82
|
+
pip install -e '.[translate]' # optional, for `srtforge -t ...`
|
|
70
83
|
```
|
|
71
84
|
|
|
72
85
|
Or into a virtual environment:
|
|
@@ -75,6 +88,7 @@ Or into a virtual environment:
|
|
|
75
88
|
python3.11 -m venv .venv
|
|
76
89
|
source .venv/bin/activate
|
|
77
90
|
pip install srtforge
|
|
91
|
+
pip install 'srtforge[translate]' # optional, for `srtforge -t ...`
|
|
78
92
|
```
|
|
79
93
|
|
|
80
94
|
## Troubleshooting
|
|
@@ -150,6 +164,8 @@ srtforge video.mp4 -t "Brazilian Portuguese"
|
|
|
150
164
|
srtforge video.mp4 -t ja --translate-model mlx-community/Qwen3.5-9B-OptiQ-4bit
|
|
151
165
|
```
|
|
152
166
|
|
|
167
|
+
Translation requires installing `srtforge[translate]`.
|
|
168
|
+
|
|
153
169
|
Burn subtitles into a video:
|
|
154
170
|
|
|
155
171
|
```bash
|
|
@@ -157,6 +173,11 @@ srtforge merge video.mp4 video.srt -o final.mp4
|
|
|
157
173
|
srtforge merge video.mp4 video.es.srt --crf 16
|
|
158
174
|
```
|
|
159
175
|
|
|
176
|
+
Existing outputs are not overwritten by default. Pass `--force` when you intend
|
|
177
|
+
to replace an existing `.srt` or merged video output.
|
|
178
|
+
Subtitle files are published atomically, so a failed write preserves any
|
|
179
|
+
existing destination instead of leaving a partial file.
|
|
180
|
+
|
|
160
181
|
Run:
|
|
161
182
|
|
|
162
183
|
```bash
|
|
@@ -175,8 +196,9 @@ so they are easier to read:
|
|
|
175
196
|
- cues are paced around 17 characters per second
|
|
176
197
|
- cues are adjusted to avoid overlap
|
|
177
198
|
|
|
178
|
-
Translated subtitles
|
|
179
|
-
be longer or shorter than the source.
|
|
199
|
+
Translated subtitles receive Cue Fit after normal Cue Planning because translated
|
|
200
|
+
text can be longer or shorter than the source. With `--no-resegment`, translated
|
|
201
|
+
subtitles retain the raw Whisper Cue shape.
|
|
180
202
|
|
|
181
203
|
## ffmpeg Notes
|
|
182
204
|
|
|
@@ -198,8 +220,9 @@ src/srtforge/
|
|
|
198
220
|
segment.py # sentence-aware re-cueing, wrapping, pacing
|
|
199
221
|
translate.py # context-aware local-LLM translation
|
|
200
222
|
transcript.py # reference transcript reading + correction
|
|
223
|
+
_types.py # shared typed subtitle data shapes
|
|
201
224
|
merge.py # burn subtitles into video with ffmpeg/libass
|
|
202
|
-
srt.py # SRT rendering
|
|
225
|
+
srt.py # SRT rendering and safe publication
|
|
203
226
|
cli.py # argparse CLI
|
|
204
227
|
tests/
|
|
205
228
|
test_cli.py
|
|
@@ -207,6 +230,7 @@ tests/
|
|
|
207
230
|
test_segment.py
|
|
208
231
|
test_srt.py
|
|
209
232
|
test_transcript.py
|
|
233
|
+
test_translate.py
|
|
210
234
|
```
|
|
211
235
|
|
|
212
236
|
## Development
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "srtforge"
|
|
3
|
-
version = "0.1
|
|
3
|
+
version = "0.2.1"
|
|
4
4
|
description = "Generate, translate, and burn SRT subtitles locally with MLX Whisper."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10,<3.13"
|
|
@@ -23,6 +23,10 @@ classifiers = [
|
|
|
23
23
|
]
|
|
24
24
|
dependencies = [
|
|
25
25
|
"mlx-whisper>=0.4",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
translate = [
|
|
26
30
|
"mlx-lm>=0.20",
|
|
27
31
|
]
|
|
28
32
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Shared subtitle data shapes used across the pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TypedDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WhisperWord(TypedDict, total=False):
|
|
9
|
+
word: str
|
|
10
|
+
start: float
|
|
11
|
+
end: float
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Segment(TypedDict, total=False):
|
|
15
|
+
start: float
|
|
16
|
+
end: float
|
|
17
|
+
text: str
|
|
18
|
+
words: list[WhisperWord]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Cue(TypedDict, total=False):
|
|
22
|
+
start: float
|
|
23
|
+
end: float
|
|
24
|
+
text: str
|
|
@@ -10,11 +10,16 @@ from pathlib import Path
|
|
|
10
10
|
from . import __version__
|
|
11
11
|
from .audio import FFmpegError, extract_audio
|
|
12
12
|
from .merge import burn_subtitles
|
|
13
|
-
from .segment import Opts,
|
|
14
|
-
from .srt import
|
|
13
|
+
from .segment import Opts, resegment
|
|
14
|
+
from .srt import SubtitlePublicationError, publish_srt
|
|
15
15
|
from .transcribe import DEFAULT_MODEL, transcribe
|
|
16
16
|
from .transcript import correct_segments, initial_prompt_from_transcript, read_transcript
|
|
17
|
-
from .translate import DEFAULT_TRANSLATE_MODEL, TranslationError
|
|
17
|
+
from .translate import DEFAULT_TRANSLATE_MODEL, TranslationError
|
|
18
|
+
from .translation_completion import TranslationSkipped, complete_translation
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CliError(RuntimeError):
|
|
22
|
+
"""Raised for invalid CLI arguments that argparse cannot express alone."""
|
|
18
23
|
|
|
19
24
|
|
|
20
25
|
def _log(msg: str) -> None:
|
|
@@ -35,6 +40,17 @@ def _positive_float(value: str) -> float:
|
|
|
35
40
|
return parsed
|
|
36
41
|
|
|
37
42
|
|
|
43
|
+
def _translation_tag(language: str) -> str:
|
|
44
|
+
return re.sub(r"[^\w.-]+", "-", language.strip().lower()).strip(".-_")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _translation_language(value: str) -> str:
|
|
48
|
+
tag = _translation_tag(value)
|
|
49
|
+
if not tag:
|
|
50
|
+
raise argparse.ArgumentTypeError("translation language must not be blank")
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
38
54
|
def _crf(value: str) -> int:
|
|
39
55
|
parsed = int(value)
|
|
40
56
|
if not 0 <= parsed <= 51:
|
|
@@ -44,11 +60,35 @@ def _crf(value: str) -> int:
|
|
|
44
60
|
|
|
45
61
|
def _translated_path(output: Path, language: str) -> Path:
|
|
46
62
|
"""Insert a language tag before the suffix: video.srt -> video.<lang>.srt."""
|
|
47
|
-
tag =
|
|
48
|
-
|
|
63
|
+
tag = _translation_tag(language)
|
|
64
|
+
if not tag:
|
|
65
|
+
raise CliError("Translation language must not be blank.")
|
|
49
66
|
return output.with_suffix(f".{tag}{output.suffix}")
|
|
50
67
|
|
|
51
68
|
|
|
69
|
+
def _same_path(a: Path, b: Path) -> bool:
|
|
70
|
+
return a.resolve(strict=False) == b.resolve(strict=False)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _ensure_srt_output(path: Path) -> None:
|
|
74
|
+
if path.suffix.lower() != ".srt":
|
|
75
|
+
raise CliError(f"Subtitle output must end in .srt: {path}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _ensure_can_write_output(
|
|
79
|
+
output: Path,
|
|
80
|
+
*,
|
|
81
|
+
force: bool,
|
|
82
|
+
protected: list[Path],
|
|
83
|
+
kind: str,
|
|
84
|
+
) -> None:
|
|
85
|
+
for path in protected:
|
|
86
|
+
if _same_path(output, path):
|
|
87
|
+
raise CliError(f"Refusing to overwrite input {kind}: {output}")
|
|
88
|
+
if output.exists() and not force:
|
|
89
|
+
raise CliError(f"Output already exists: {output}. Pass --force to overwrite.")
|
|
90
|
+
|
|
91
|
+
|
|
52
92
|
def build_parser() -> argparse.ArgumentParser:
|
|
53
93
|
p = argparse.ArgumentParser(
|
|
54
94
|
prog="srtforge",
|
|
@@ -61,6 +101,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
61
101
|
"-o", "--output", type=Path, default=None,
|
|
62
102
|
help="Output .srt path (default: alongside the video).",
|
|
63
103
|
)
|
|
104
|
+
p.add_argument(
|
|
105
|
+
"--force", action="store_true",
|
|
106
|
+
help="Overwrite existing output files.",
|
|
107
|
+
)
|
|
64
108
|
p.add_argument(
|
|
65
109
|
"-m", "--model", default=DEFAULT_MODEL,
|
|
66
110
|
help=f"Whisper model / HF repo (default: {DEFAULT_MODEL}).",
|
|
@@ -95,7 +139,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
95
139
|
help=f"Reading speed in characters/second (default: {Opts.reading_cps}).",
|
|
96
140
|
)
|
|
97
141
|
p.add_argument(
|
|
98
|
-
"-t", "--translate", default=None, metavar="LANG",
|
|
142
|
+
"-t", "--translate", type=_translation_language, default=None, metavar="LANG",
|
|
99
143
|
help="Also translate subtitles into LANG (e.g. es, Spanish, Japanese) "
|
|
100
144
|
"using a local MLX LLM. Writes a second .<lang>.srt file.",
|
|
101
145
|
)
|
|
@@ -121,7 +165,25 @@ def _gen_main(argv: list[str] | None = None) -> int:
|
|
|
121
165
|
# Sentence-aware re-cueing needs word-level timestamps.
|
|
122
166
|
need_words = args.word_timestamps or args.resegment
|
|
123
167
|
wav_path = None
|
|
168
|
+
translation_output: Path | None = None
|
|
124
169
|
try:
|
|
170
|
+
_ensure_srt_output(output)
|
|
171
|
+
_ensure_can_write_output(
|
|
172
|
+
output,
|
|
173
|
+
force=args.force,
|
|
174
|
+
protected=[args.video],
|
|
175
|
+
kind="video",
|
|
176
|
+
)
|
|
177
|
+
if translating:
|
|
178
|
+
translation_output = _translated_path(output, args.translate)
|
|
179
|
+
_ensure_srt_output(translation_output)
|
|
180
|
+
_ensure_can_write_output(
|
|
181
|
+
translation_output,
|
|
182
|
+
force=args.force,
|
|
183
|
+
protected=[args.video, output],
|
|
184
|
+
kind="video or subtitle output",
|
|
185
|
+
)
|
|
186
|
+
|
|
125
187
|
transcript_text = read_transcript(args.transcript) if args.transcript else ""
|
|
126
188
|
_log(f"[1/{total}] Extracting audio from {args.video} ...")
|
|
127
189
|
wav_path = extract_audio(args.video)
|
|
@@ -140,39 +202,46 @@ def _gen_main(argv: list[str] | None = None) -> int:
|
|
|
140
202
|
|
|
141
203
|
if args.resegment:
|
|
142
204
|
segments = resegment(segments, opts)
|
|
205
|
+
cues = segments
|
|
143
206
|
|
|
144
207
|
_log(f"[3/{total}] Writing {output} ...")
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if not
|
|
208
|
+
has_cues = any((cue.get("text") or "").strip() for cue in cues)
|
|
209
|
+
publish_srt(cues, output, replace_existing=args.force)
|
|
210
|
+
if not has_cues:
|
|
148
211
|
_log("Warning: no speech detected; wrote an empty SRT file.")
|
|
149
212
|
else:
|
|
150
|
-
_log(
|
|
213
|
+
_log(" Subtitle file written.")
|
|
151
214
|
|
|
152
|
-
if
|
|
153
|
-
if
|
|
154
|
-
_log("Skipping translation: no speech to translate.")
|
|
155
|
-
else:
|
|
156
|
-
t_out = _translated_path(output, args.translate)
|
|
215
|
+
if translation_output is not None:
|
|
216
|
+
if has_cues:
|
|
157
217
|
_log(
|
|
158
218
|
f"[4/{total}] Translating to {args.translate} with "
|
|
159
219
|
f"{args.translate_model} (downloads model on first run) ..."
|
|
160
220
|
)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
221
|
+
completion = complete_translation(
|
|
222
|
+
cues,
|
|
223
|
+
target_language=args.translate,
|
|
224
|
+
model=args.translate_model,
|
|
225
|
+
output=translation_output,
|
|
226
|
+
apply_cue_fit=args.resegment,
|
|
227
|
+
replace_existing=args.force,
|
|
228
|
+
opts=opts,
|
|
229
|
+
)
|
|
230
|
+
if isinstance(completion, TranslationSkipped):
|
|
231
|
+
_log("Skipping translation: no speech to translate.")
|
|
232
|
+
else:
|
|
233
|
+
_log(f" Translated subtitles written to {completion.output}")
|
|
171
234
|
|
|
172
235
|
_log("Done.")
|
|
173
236
|
return 0
|
|
174
237
|
|
|
175
|
-
except (
|
|
238
|
+
except (
|
|
239
|
+
CliError,
|
|
240
|
+
FFmpegError,
|
|
241
|
+
FileNotFoundError,
|
|
242
|
+
SubtitlePublicationError,
|
|
243
|
+
TranslationError,
|
|
244
|
+
) as e:
|
|
176
245
|
_log(f"Error: {e}")
|
|
177
246
|
return 1
|
|
178
247
|
except KeyboardInterrupt:
|
|
@@ -202,6 +271,10 @@ def build_merge_parser() -> argparse.ArgumentParser:
|
|
|
202
271
|
"--preset", default="slow",
|
|
203
272
|
help="x264 speed/efficiency preset (default: slow).",
|
|
204
273
|
)
|
|
274
|
+
p.add_argument(
|
|
275
|
+
"--force", action="store_true",
|
|
276
|
+
help="Overwrite the output video if it already exists.",
|
|
277
|
+
)
|
|
205
278
|
return p
|
|
206
279
|
|
|
207
280
|
|
|
@@ -209,6 +282,12 @@ def _merge_main(argv: list[str]) -> int:
|
|
|
209
282
|
args = build_merge_parser().parse_args(argv)
|
|
210
283
|
output = args.output or args.video.with_suffix(f".subbed{args.video.suffix}")
|
|
211
284
|
try:
|
|
285
|
+
_ensure_can_write_output(
|
|
286
|
+
output,
|
|
287
|
+
force=args.force,
|
|
288
|
+
protected=[args.video, args.subtitles],
|
|
289
|
+
kind="video or subtitle file",
|
|
290
|
+
)
|
|
212
291
|
_log(f"Burning {args.subtitles} into {args.video} (crf={args.crf}, preset={args.preset}) ...")
|
|
213
292
|
burn_subtitles(
|
|
214
293
|
args.video,
|
|
@@ -219,7 +298,7 @@ def _merge_main(argv: list[str]) -> int:
|
|
|
219
298
|
)
|
|
220
299
|
_log(f"Done. Wrote {output}")
|
|
221
300
|
return 0
|
|
222
|
-
except (FFmpegError, FileNotFoundError) as e:
|
|
301
|
+
except (CliError, FFmpegError, FileNotFoundError) as e:
|
|
223
302
|
_log(f"Error: {e}")
|
|
224
303
|
return 1
|
|
225
304
|
except KeyboardInterrupt:
|