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.
Files changed (34) hide show
  1. {srtforge-0.1.2/src/srtforge.egg-info → srtforge-0.2.1}/PKG-INFO +30 -5
  2. {srtforge-0.1.2 → srtforge-0.2.1}/README.md +27 -3
  3. {srtforge-0.1.2 → srtforge-0.2.1}/pyproject.toml +5 -1
  4. {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge/__init__.py +1 -1
  5. srtforge-0.2.1/src/srtforge/_types.py +24 -0
  6. {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge/cli.py +106 -27
  7. {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge/segment.py +107 -61
  8. srtforge-0.2.1/src/srtforge/srt.py +110 -0
  9. {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge/transcribe.py +3 -2
  10. {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge/transcript.py +24 -4
  11. {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge/translate.py +6 -3
  12. srtforge-0.2.1/src/srtforge/translation_completion.py +84 -0
  13. {srtforge-0.1.2 → srtforge-0.2.1/src/srtforge.egg-info}/PKG-INFO +30 -5
  14. {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge.egg-info/SOURCES.txt +5 -1
  15. {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge.egg-info/requires.txt +2 -0
  16. srtforge-0.2.1/tests/test_cli.py +359 -0
  17. srtforge-0.2.1/tests/test_merge.py +39 -0
  18. srtforge-0.2.1/tests/test_segment.py +176 -0
  19. srtforge-0.2.1/tests/test_srt.py +212 -0
  20. {srtforge-0.1.2 → srtforge-0.2.1}/tests/test_transcript.py +28 -0
  21. srtforge-0.2.1/tests/test_translate.py +116 -0
  22. srtforge-0.2.1/tests/test_translation_completion.py +157 -0
  23. srtforge-0.1.2/src/srtforge/srt.py +0 -31
  24. srtforge-0.1.2/tests/test_cli.py +0 -64
  25. srtforge-0.1.2/tests/test_merge.py +0 -16
  26. srtforge-0.1.2/tests/test_segment.py +0 -33
  27. srtforge-0.1.2/tests/test_srt.py +0 -19
  28. {srtforge-0.1.2 → srtforge-0.2.1}/LICENSE +0 -0
  29. {srtforge-0.1.2 → srtforge-0.2.1}/setup.cfg +0 -0
  30. {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge/audio.py +0 -0
  31. {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge/merge.py +1 -1
  32. {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge.egg-info/dependency_links.txt +0 -0
  33. {srtforge-0.1.2 → srtforge-0.2.1}/src/srtforge.egg-info/entry_points.txt +0 -0
  34. {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.2
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
- Requires-Dist: mlx-lm>=0.20
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 are re-fitted after translation because translated text can
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 are re-fitted after translation because translated text can
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.2"
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
 
@@ -1,3 +1,3 @@
1
1
  """srtforge: generate SRT subtitles from video using local Whisper (MLX)."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.2.1"
@@ -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, fit_cues, resegment
14
- from .srt import segments_to_srt
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, translate_segments
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 = re.sub(r"[^\w.-]+", "-", language.strip().lower()).strip(".-_")
48
- tag = tag or "translated"
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
- srt_text = segments_to_srt(segments)
146
- output.write_text(srt_text, encoding="utf-8")
147
- if not srt_text:
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(f" {srt_text.count('-->')} subtitle entries written.")
213
+ _log(" Subtitle file written.")
151
214
 
152
- if translating:
153
- if not srt_text:
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
- t_segments = translate_segments(
162
- segments,
163
- target_language=args.translate,
164
- model=args.translate_model,
165
- )
166
- if args.resegment:
167
- # Translated text length differs; fit/split to line limits.
168
- t_segments = fit_cues(t_segments, opts)
169
- t_out.write_text(segments_to_srt(t_segments), encoding="utf-8")
170
- _log(f" Translated subtitles written to {t_out}")
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 (FFmpegError, FileNotFoundError, TranslationError) as e:
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: