srtforge 0.2.0__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 (32) hide show
  1. {srtforge-0.2.0/src/srtforge.egg-info → srtforge-0.2.1}/PKG-INFO +7 -4
  2. {srtforge-0.2.0 → srtforge-0.2.1}/README.md +6 -3
  3. {srtforge-0.2.0 → srtforge-0.2.1}/pyproject.toml +1 -1
  4. {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/__init__.py +1 -1
  5. {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/cli.py +50 -29
  6. {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/segment.py +70 -39
  7. srtforge-0.2.1/src/srtforge/srt.py +110 -0
  8. srtforge-0.2.1/src/srtforge/translation_completion.py +84 -0
  9. {srtforge-0.2.0 → srtforge-0.2.1/src/srtforge.egg-info}/PKG-INFO +7 -4
  10. {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge.egg-info/SOURCES.txt +3 -1
  11. {srtforge-0.2.0 → srtforge-0.2.1}/tests/test_cli.py +173 -6
  12. srtforge-0.2.1/tests/test_segment.py +176 -0
  13. srtforge-0.2.1/tests/test_srt.py +212 -0
  14. srtforge-0.2.1/tests/test_translation_completion.py +157 -0
  15. srtforge-0.2.0/src/srtforge/srt.py +0 -48
  16. srtforge-0.2.0/tests/test_segment.py +0 -74
  17. srtforge-0.2.0/tests/test_srt.py +0 -50
  18. {srtforge-0.2.0 → srtforge-0.2.1}/LICENSE +0 -0
  19. {srtforge-0.2.0 → srtforge-0.2.1}/setup.cfg +0 -0
  20. {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/_types.py +0 -0
  21. {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/audio.py +0 -0
  22. {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/merge.py +0 -0
  23. {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/transcribe.py +0 -0
  24. {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/transcript.py +0 -0
  25. {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/translate.py +0 -0
  26. {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge.egg-info/dependency_links.txt +0 -0
  27. {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge.egg-info/entry_points.txt +0 -0
  28. {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge.egg-info/requires.txt +0 -0
  29. {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge.egg-info/top_level.txt +0 -0
  30. {srtforge-0.2.0 → srtforge-0.2.1}/tests/test_merge.py +0 -0
  31. {srtforge-0.2.0 → srtforge-0.2.1}/tests/test_transcript.py +0 -0
  32. {srtforge-0.2.0 → srtforge-0.2.1}/tests/test_translate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: srtforge
3
- Version: 0.2.0
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
@@ -203,6 +203,8 @@ srtforge merge video.mp4 video.es.srt --crf 16
203
203
 
204
204
  Existing outputs are not overwritten by default. Pass `--force` when you intend
205
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.
206
208
 
207
209
  Run:
208
210
 
@@ -222,8 +224,9 @@ so they are easier to read:
222
224
  - cues are paced around 17 characters per second
223
225
  - cues are adjusted to avoid overlap
224
226
 
225
- Translated subtitles are re-fitted after translation because translated text can
226
- 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.
227
230
 
228
231
  ## ffmpeg Notes
229
232
 
@@ -247,7 +250,7 @@ src/srtforge/
247
250
  transcript.py # reference transcript reading + correction
248
251
  _types.py # shared typed subtitle data shapes
249
252
  merge.py # burn subtitles into video with ffmpeg/libass
250
- srt.py # SRT rendering
253
+ srt.py # SRT rendering and safe publication
251
254
  cli.py # argparse CLI
252
255
  tests/
253
256
  test_cli.py
@@ -175,6 +175,8 @@ srtforge merge video.mp4 video.es.srt --crf 16
175
175
 
176
176
  Existing outputs are not overwritten by default. Pass `--force` when you intend
177
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.
178
180
 
179
181
  Run:
180
182
 
@@ -194,8 +196,9 @@ so they are easier to read:
194
196
  - cues are paced around 17 characters per second
195
197
  - cues are adjusted to avoid overlap
196
198
 
197
- Translated subtitles are re-fitted after translation because translated text can
198
- 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.
199
202
 
200
203
  ## ffmpeg Notes
201
204
 
@@ -219,7 +222,7 @@ src/srtforge/
219
222
  transcript.py # reference transcript reading + correction
220
223
  _types.py # shared typed subtitle data shapes
221
224
  merge.py # burn subtitles into video with ffmpeg/libass
222
- srt.py # SRT rendering
225
+ srt.py # SRT rendering and safe publication
223
226
  cli.py # argparse CLI
224
227
  tests/
225
228
  test_cli.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "srtforge"
3
- version = "0.2.0"
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"
@@ -1,3 +1,3 @@
1
1
  """srtforge: generate SRT subtitles from video using local Whisper (MLX)."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.1"
@@ -10,11 +10,12 @@ 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
18
19
 
19
20
 
20
21
  class CliError(RuntimeError):
@@ -39,6 +40,17 @@ def _positive_float(value: str) -> float:
39
40
  return parsed
40
41
 
41
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
+
42
54
  def _crf(value: str) -> int:
43
55
  parsed = int(value)
44
56
  if not 0 <= parsed <= 51:
@@ -48,8 +60,9 @@ def _crf(value: str) -> int:
48
60
 
49
61
  def _translated_path(output: Path, language: str) -> Path:
50
62
  """Insert a language tag before the suffix: video.srt -> video.<lang>.srt."""
51
- tag = re.sub(r"[^\w.-]+", "-", language.strip().lower()).strip(".-_")
52
- tag = tag or "translated"
63
+ tag = _translation_tag(language)
64
+ if not tag:
65
+ raise CliError("Translation language must not be blank.")
53
66
  return output.with_suffix(f".{tag}{output.suffix}")
54
67
 
55
68
 
@@ -126,7 +139,7 @@ def build_parser() -> argparse.ArgumentParser:
126
139
  help=f"Reading speed in characters/second (default: {Opts.reading_cps}).",
127
140
  )
128
141
  p.add_argument(
129
- "-t", "--translate", default=None, metavar="LANG",
142
+ "-t", "--translate", type=_translation_language, default=None, metavar="LANG",
130
143
  help="Also translate subtitles into LANG (e.g. es, Spanish, Japanese) "
131
144
  "using a local MLX LLM. Writes a second .<lang>.srt file.",
132
145
  )
@@ -152,6 +165,7 @@ def _gen_main(argv: list[str] | None = None) -> int:
152
165
  # Sentence-aware re-cueing needs word-level timestamps.
153
166
  need_words = args.word_timestamps or args.resegment
154
167
  wav_path = None
168
+ translation_output: Path | None = None
155
169
  try:
156
170
  _ensure_srt_output(output)
157
171
  _ensure_can_write_output(
@@ -161,10 +175,10 @@ def _gen_main(argv: list[str] | None = None) -> int:
161
175
  kind="video",
162
176
  )
163
177
  if translating:
164
- t_out = _translated_path(output, args.translate)
165
- _ensure_srt_output(t_out)
178
+ translation_output = _translated_path(output, args.translate)
179
+ _ensure_srt_output(translation_output)
166
180
  _ensure_can_write_output(
167
- t_out,
181
+ translation_output,
168
182
  force=args.force,
169
183
  protected=[args.video, output],
170
184
  kind="video or subtitle output",
@@ -188,39 +202,46 @@ def _gen_main(argv: list[str] | None = None) -> int:
188
202
 
189
203
  if args.resegment:
190
204
  segments = resegment(segments, opts)
205
+ cues = segments
191
206
 
192
207
  _log(f"[3/{total}] Writing {output} ...")
193
- srt_text = segments_to_srt(segments)
194
- output.write_text(srt_text, encoding="utf-8")
195
- 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:
196
211
  _log("Warning: no speech detected; wrote an empty SRT file.")
197
212
  else:
198
- _log(f" {srt_text.count('-->')} subtitle entries written.")
213
+ _log(" Subtitle file written.")
199
214
 
200
- if translating:
201
- if not srt_text:
202
- _log("Skipping translation: no speech to translate.")
203
- else:
204
- t_out = _translated_path(output, args.translate)
215
+ if translation_output is not None:
216
+ if has_cues:
205
217
  _log(
206
218
  f"[4/{total}] Translating to {args.translate} with "
207
219
  f"{args.translate_model} (downloads model on first run) ..."
208
220
  )
209
- t_segments = translate_segments(
210
- segments,
211
- target_language=args.translate,
212
- model=args.translate_model,
213
- )
214
- if args.resegment:
215
- # Translated text length differs; fit/split to line limits.
216
- t_segments = fit_cues(t_segments, opts)
217
- t_out.write_text(segments_to_srt(t_segments), encoding="utf-8")
218
- _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}")
219
234
 
220
235
  _log("Done.")
221
236
  return 0
222
237
 
223
- except (CliError, FFmpegError, FileNotFoundError, TranslationError) as e:
238
+ except (
239
+ CliError,
240
+ FFmpegError,
241
+ FileNotFoundError,
242
+ SubtitlePublicationError,
243
+ TranslationError,
244
+ ) as e:
224
245
  _log(f"Error: {e}")
225
246
  return 1
226
247
  except KeyboardInterrupt:
@@ -10,6 +10,7 @@ module rebuilds cues from word-level timestamps so that:
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ from collections import deque
13
14
  import re
14
15
  from dataclasses import dataclass, field
15
16
 
@@ -22,6 +23,7 @@ READING_CPS = 17.0 # characters per second a viewer can comfortably read
22
23
  MIN_DURATION = 0.8 # seconds
23
24
  MAX_DURATION = 7.0 # seconds
24
25
  MIN_GAP = 0.084 # ~2 frames @ 24fps, gap between consecutive cues
26
+ SUBSTANTIAL_SPEECH_GAP = 1.0 # seconds; starts a new cue during planning
25
27
 
26
28
  # Sentence-final punctuation (incl. Spanish/CJK variants).
27
29
  _SENT_END = tuple(".!?…。!?")
@@ -55,6 +57,7 @@ class _Word:
55
57
  @dataclass
56
58
  class _Sentence:
57
59
  words: list[_Word] = field(default_factory=list)
60
+ force_break_after: bool = False
58
61
 
59
62
  @property
60
63
  def text(self) -> str:
@@ -107,6 +110,20 @@ def _split_sentences(words: list[_Word]) -> list[_Sentence]:
107
110
  return sentences
108
111
 
109
112
 
113
+ def _split_speech_gaps(words: list[_Word]) -> list[list[_Word]]:
114
+ """Split timestamped words at substantial gaps in speech."""
115
+ groups: list[list[_Word]] = []
116
+ current: list[_Word] = []
117
+ for word in words:
118
+ if current and word.start - current[-1].end >= SUBSTANTIAL_SPEECH_GAP:
119
+ groups.append(current)
120
+ current = []
121
+ current.append(word)
122
+ if current:
123
+ groups.append(current)
124
+ return groups
125
+
126
+
110
127
  # --------------------------------------------------------------------------- #
111
128
  # Line wrapping
112
129
  # --------------------------------------------------------------------------- #
@@ -171,30 +188,50 @@ def wrap_lines(text: str, max_cpl: int = MAX_CPL, max_lines: int = MAX_LINES) ->
171
188
  # --------------------------------------------------------------------------- #
172
189
  # Cue construction
173
190
  # --------------------------------------------------------------------------- #
191
+ def _split_words_to_fit(words: list[str], opts: Opts) -> list[list[str]]:
192
+ """Plan Cue Fit chunks, preferring clause punctuation when possible."""
193
+ chunks: list[list[str]] = []
194
+ pending = deque(words)
195
+ current: list[str] = []
196
+ last_clause_break = -1
197
+
198
+ while pending:
199
+ word = pending.popleft()
200
+ prospective = current + [word]
201
+ if current and not _fits(" ".join(prospective), opts):
202
+ if 0 <= last_clause_break < len(current) - 1:
203
+ chunks.append(current[: last_clause_break + 1])
204
+ tail = current[last_clause_break + 1 :]
205
+ else:
206
+ chunks.append(current)
207
+ tail = []
208
+ current = []
209
+ last_clause_break = -1
210
+ pending.extendleft(reversed(tail + [word]))
211
+ continue
212
+
213
+ current.append(word)
214
+ if word.endswith(_CLAUSE_PUNCT):
215
+ last_clause_break = len(current) - 1
216
+
217
+ if current:
218
+ chunks.append(current)
219
+ return chunks
220
+
221
+
174
222
  def _split_long_sentence(sent: _Sentence, opts: Opts) -> list[_Sentence]:
175
223
  """Split a sentence that exceeds one block into word-aligned chunks,
176
224
  preferring clause-punctuation break points near the limit."""
177
225
  chunks: list[_Sentence] = []
178
- cur = _Sentence()
179
- last_clause_break = -1 # index within cur.words after a clause punct
180
- for w in sent.words:
181
- prospective = _Sentence(cur.words + [w])
182
- if cur.words and not _fits(prospective.text, opts):
183
- # Prefer to break at the last clause punctuation if we have one.
184
- if 0 <= last_clause_break < len(cur.words) - 1:
185
- head = _Sentence(cur.words[: last_clause_break + 1])
186
- tail_words = cur.words[last_clause_break + 1 :]
187
- chunks.append(head)
188
- cur = _Sentence(list(tail_words))
189
- else:
190
- chunks.append(cur)
191
- cur = _Sentence()
192
- last_clause_break = -1
193
- cur.words.append(w)
194
- if w.text.endswith(_CLAUSE_PUNCT):
195
- last_clause_break = len(cur.words) - 1
196
- if cur.words:
197
- chunks.append(cur)
226
+ offset = 0
227
+ for text_chunk in _split_words_to_fit([word.text for word in sent.words], opts):
228
+ next_offset = offset + len(text_chunk)
229
+ chunks.append(_Sentence(sent.words[offset:next_offset]))
230
+ offset = next_offset
231
+ if chunks:
232
+ for chunk in chunks[:-1]:
233
+ chunk.force_break_after = True
234
+ chunks[-1].force_break_after = sent.force_break_after
198
235
  return chunks
199
236
 
200
237
 
@@ -215,6 +252,8 @@ def _build_cues(sentences: list[_Sentence], opts: Opts) -> list[Cue]:
215
252
  merged_words = list(cur.words)
216
253
  j = i + 1
217
254
  while j < len(units):
255
+ if units[j - 1].force_break_after:
256
+ break
218
257
  nxt = units[j]
219
258
  combined = _Sentence(merged_words + nxt.words).text
220
259
  combined_dur = nxt.end - merged_words[0].start
@@ -272,8 +311,9 @@ def fit_cues(cues: list[Segment], opts: Opts | None = None) -> list[Cue]:
272
311
  """Fit each cue's text within the line limits, splitting over-capacity cues.
273
312
 
274
313
  Used for translated text, whose length differs from the source. A cue whose
275
- text does not fit within the line limits is split into multiple cues at word
276
- boundaries, with the original time span apportioned by character count.
314
+ text does not fit within the line limits is split at clause punctuation
315
+ where possible, otherwise word boundaries, with time apportioned by
316
+ character count.
277
317
  """
278
318
  opts = opts or Opts()
279
319
  out: list[Cue] = []
@@ -282,22 +322,7 @@ def fit_cues(cues: list[Segment], opts: Opts | None = None) -> list[Cue]:
282
322
  if not text:
283
323
  continue
284
324
  start, end = float(c["start"]), float(c["end"])
285
- if _fits(text, opts):
286
- out.append({"start": start, "end": end,
287
- "text": wrap_lines(text, opts.max_cpl, opts.max_lines)})
288
- continue
289
- # Split into chunks that each fit the line limits, at word boundaries.
290
- chunks: list[str] = []
291
- cur = ""
292
- for w in text.split(" "):
293
- cand = (cur + " " + w).strip()
294
- if cur and not _fits(cand, opts):
295
- chunks.append(cur)
296
- cur = w
297
- else:
298
- cur = cand
299
- if cur:
300
- chunks.append(cur)
325
+ chunks = [" ".join(chunk) for chunk in _split_words_to_fit(text.split(), opts)]
301
326
  span = max(end - start, 0.001)
302
327
  total = sum(len(ch) for ch in chunks) or 1
303
328
  t = start
@@ -310,7 +335,13 @@ def fit_cues(cues: list[Segment], opts: Opts | None = None) -> list[Cue]:
310
335
 
311
336
 
312
337
  def _resegment_words(words: list[_Word], opts: Opts) -> list[Cue]:
313
- sentences = _split_sentences(words)
338
+ sentences: list[_Sentence] = []
339
+ groups = _split_speech_gaps(words)
340
+ for i, group in enumerate(groups):
341
+ group_sentences = _split_sentences(group)
342
+ if group_sentences and i + 1 < len(groups):
343
+ group_sentences[-1].force_break_after = True
344
+ sentences.extend(group_sentences)
314
345
  cues = _build_cues(sentences, opts)
315
346
  cues = _apply_pacing(cues, opts)
316
347
  for cue in cues:
@@ -0,0 +1,110 @@
1
+ """Render and publish completed Cues as SRT subtitle documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from tempfile import NamedTemporaryFile
8
+ from typing import Iterable
9
+
10
+ from ._types import Segment
11
+
12
+ MIN_CUE_DURATION = 0.001
13
+
14
+
15
+ class SubtitlePublicationError(RuntimeError):
16
+ """Raised when a complete SRT document cannot be published."""
17
+
18
+
19
+ def _format_timestamp(seconds: float) -> str:
20
+ """Format seconds as SRT timestamp: HH:MM:SS,mmm."""
21
+ if seconds < 0:
22
+ seconds = 0.0
23
+ millis = round(seconds * 1000)
24
+ hours, millis = divmod(millis, 3_600_000)
25
+ minutes, millis = divmod(millis, 60_000)
26
+ secs, millis = divmod(millis, 1000)
27
+ return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
28
+
29
+
30
+ def _normalize_timing(start: float, end: float, previous_end: float) -> tuple[float, float]:
31
+ start = max(0.0, start, previous_end)
32
+ end = max(start + MIN_CUE_DURATION, end)
33
+ return start, end
34
+
35
+
36
+ def _render_srt(cues: Iterable[Segment]) -> str:
37
+ """Render completed Cues to an SRT document string."""
38
+ blocks: list[str] = []
39
+ index = 1
40
+ previous_end = 0.0
41
+ for cue in cues:
42
+ text = (cue.get("text") or "").strip()
43
+ if not text:
44
+ continue
45
+ start_seconds, end_seconds = _normalize_timing(
46
+ float(cue.get("start", 0.0)),
47
+ float(cue.get("end", 0.0)),
48
+ previous_end,
49
+ )
50
+ previous_end = end_seconds
51
+ start = _format_timestamp(start_seconds)
52
+ end = _format_timestamp(end_seconds)
53
+ blocks.append(f"{index}\n{start} --> {end}\n{text}\n")
54
+ index += 1
55
+ return "\n".join(blocks)
56
+
57
+
58
+ def publish_srt(
59
+ cues: Iterable[Segment],
60
+ destination: Path,
61
+ *,
62
+ replace_existing: bool = False,
63
+ ) -> Path:
64
+ """Publish completed Cues as an SRT document."""
65
+ if destination.suffix.lower() != ".srt":
66
+ raise SubtitlePublicationError(
67
+ f"Subtitle destination must end in .srt: {destination}"
68
+ )
69
+ if not destination.parent.exists():
70
+ raise SubtitlePublicationError(
71
+ f"Subtitle parent directory does not exist: {destination.parent}"
72
+ )
73
+ if destination.exists() and not replace_existing:
74
+ raise SubtitlePublicationError(
75
+ f"Subtitle destination already exists: {destination}"
76
+ )
77
+
78
+ temporary: Path | None = None
79
+ try:
80
+ with NamedTemporaryFile(
81
+ mode="w",
82
+ encoding="utf-8",
83
+ dir=destination.parent,
84
+ prefix=f".{destination.stem}.",
85
+ suffix=destination.suffix,
86
+ delete=False,
87
+ ) as stream:
88
+ temporary = Path(stream.name)
89
+ stream.write(_render_srt(cues))
90
+ stream.flush()
91
+ os.fsync(stream.fileno())
92
+ if replace_existing:
93
+ temporary.replace(destination)
94
+ else:
95
+ os.link(temporary, destination)
96
+ return destination
97
+ except FileExistsError as exc:
98
+ raise SubtitlePublicationError(
99
+ f"Subtitle destination already exists: {destination}"
100
+ ) from exc
101
+ except Exception as exc:
102
+ raise SubtitlePublicationError(
103
+ f"Failed to publish subtitles to {destination}: {exc}"
104
+ ) from exc
105
+ finally:
106
+ if temporary is not None:
107
+ try:
108
+ temporary.unlink(missing_ok=True)
109
+ except OSError:
110
+ pass
@@ -0,0 +1,84 @@
1
+ """Publish the outcome of a requested Cue translation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Protocol
8
+
9
+ from ._types import Segment
10
+ from .segment import Opts, fit_cues
11
+ from .srt import publish_srt
12
+ from .translate import DEFAULT_TRANSLATE_MODEL, TranslationError, translate_segments
13
+
14
+
15
+ class TranslationAdapter(Protocol):
16
+ """Translate Cues while keeping their source order and timing."""
17
+
18
+ def __call__(
19
+ self,
20
+ segments: list[Segment],
21
+ *,
22
+ target_language: str,
23
+ model: str,
24
+ ) -> list[Segment]: ...
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class TranslationWritten:
29
+ """A Translation Completion that published a translated subtitle file."""
30
+
31
+ output: Path
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class TranslationSkipped:
36
+ """A Translation Completion with no translatable Cues."""
37
+
38
+
39
+ TranslationCompletion = TranslationWritten | TranslationSkipped
40
+
41
+
42
+ def complete_translation(
43
+ segments: list[Segment],
44
+ *,
45
+ target_language: str,
46
+ output: Path,
47
+ model: str = DEFAULT_TRANSLATE_MODEL,
48
+ apply_cue_fit: bool = False,
49
+ replace_existing: bool = False,
50
+ opts: Opts | None = None,
51
+ translate: TranslationAdapter = translate_segments,
52
+ ) -> TranslationCompletion:
53
+ """Translate Cues and publish the resulting SRT, or skip empty input."""
54
+ if not any((segment.get("text") or "").strip() for segment in segments):
55
+ return TranslationSkipped()
56
+
57
+ try:
58
+ translated = translate(
59
+ segments,
60
+ target_language=target_language,
61
+ model=model,
62
+ )
63
+ except TranslationError:
64
+ raise
65
+ except Exception as exc:
66
+ raise TranslationError(f"Translation failed: {exc}") from exc
67
+
68
+ if len(translated) != len(segments):
69
+ raise TranslationError(
70
+ "Translation failed: Cue count differs from the source Cues."
71
+ )
72
+
73
+ completed = [dict(segment) for segment in translated]
74
+ for source, target in zip(segments, completed):
75
+ if (source.get("text") or "").strip() and not (target.get("text") or "").strip():
76
+ target["text"] = source["text"]
77
+
78
+ completed_cues = fit_cues(completed, opts) if apply_cue_fit else completed
79
+ published = publish_srt(
80
+ completed_cues,
81
+ output,
82
+ replace_existing=replace_existing,
83
+ )
84
+ return TranslationWritten(published)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: srtforge
3
- Version: 0.2.0
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
@@ -203,6 +203,8 @@ srtforge merge video.mp4 video.es.srt --crf 16
203
203
 
204
204
  Existing outputs are not overwritten by default. Pass `--force` when you intend
205
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.
206
208
 
207
209
  Run:
208
210
 
@@ -222,8 +224,9 @@ so they are easier to read:
222
224
  - cues are paced around 17 characters per second
223
225
  - cues are adjusted to avoid overlap
224
226
 
225
- Translated subtitles are re-fitted after translation because translated text can
226
- 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.
227
230
 
228
231
  ## ffmpeg Notes
229
232
 
@@ -247,7 +250,7 @@ src/srtforge/
247
250
  transcript.py # reference transcript reading + correction
248
251
  _types.py # shared typed subtitle data shapes
249
252
  merge.py # burn subtitles into video with ffmpeg/libass
250
- srt.py # SRT rendering
253
+ srt.py # SRT rendering and safe publication
251
254
  cli.py # argparse CLI
252
255
  tests/
253
256
  test_cli.py
@@ -11,6 +11,7 @@ src/srtforge/srt.py
11
11
  src/srtforge/transcribe.py
12
12
  src/srtforge/transcript.py
13
13
  src/srtforge/translate.py
14
+ src/srtforge/translation_completion.py
14
15
  src/srtforge.egg-info/PKG-INFO
15
16
  src/srtforge.egg-info/SOURCES.txt
16
17
  src/srtforge.egg-info/dependency_links.txt
@@ -22,4 +23,5 @@ tests/test_merge.py
22
23
  tests/test_segment.py
23
24
  tests/test_srt.py
24
25
  tests/test_transcript.py
25
- tests/test_translate.py
26
+ tests/test_translate.py
27
+ tests/test_translation_completion.py