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.
- {srtforge-0.2.0/src/srtforge.egg-info → srtforge-0.2.1}/PKG-INFO +7 -4
- {srtforge-0.2.0 → srtforge-0.2.1}/README.md +6 -3
- {srtforge-0.2.0 → srtforge-0.2.1}/pyproject.toml +1 -1
- {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/__init__.py +1 -1
- {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/cli.py +50 -29
- {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/segment.py +70 -39
- srtforge-0.2.1/src/srtforge/srt.py +110 -0
- srtforge-0.2.1/src/srtforge/translation_completion.py +84 -0
- {srtforge-0.2.0 → srtforge-0.2.1/src/srtforge.egg-info}/PKG-INFO +7 -4
- {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge.egg-info/SOURCES.txt +3 -1
- {srtforge-0.2.0 → srtforge-0.2.1}/tests/test_cli.py +173 -6
- srtforge-0.2.1/tests/test_segment.py +176 -0
- srtforge-0.2.1/tests/test_srt.py +212 -0
- srtforge-0.2.1/tests/test_translation_completion.py +157 -0
- srtforge-0.2.0/src/srtforge/srt.py +0 -48
- srtforge-0.2.0/tests/test_segment.py +0 -74
- srtforge-0.2.0/tests/test_srt.py +0 -50
- {srtforge-0.2.0 → srtforge-0.2.1}/LICENSE +0 -0
- {srtforge-0.2.0 → srtforge-0.2.1}/setup.cfg +0 -0
- {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/_types.py +0 -0
- {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/audio.py +0 -0
- {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/merge.py +0 -0
- {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/transcribe.py +0 -0
- {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/transcript.py +0 -0
- {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge/translate.py +0 -0
- {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge.egg-info/dependency_links.txt +0 -0
- {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge.egg-info/entry_points.txt +0 -0
- {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge.egg-info/requires.txt +0 -0
- {srtforge-0.2.0 → srtforge-0.2.1}/src/srtforge.egg-info/top_level.txt +0 -0
- {srtforge-0.2.0 → srtforge-0.2.1}/tests/test_merge.py +0 -0
- {srtforge-0.2.0 → srtforge-0.2.1}/tests/test_transcript.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
@@ -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,
|
|
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
|
|
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 =
|
|
52
|
-
|
|
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
|
-
|
|
165
|
-
_ensure_srt_output(
|
|
178
|
+
translation_output = _translated_path(output, args.translate)
|
|
179
|
+
_ensure_srt_output(translation_output)
|
|
166
180
|
_ensure_can_write_output(
|
|
167
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
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:
|
|
196
211
|
_log("Warning: no speech detected; wrote an empty SRT file.")
|
|
197
212
|
else:
|
|
198
|
-
_log(
|
|
213
|
+
_log(" Subtitle file written.")
|
|
199
214
|
|
|
200
|
-
if
|
|
201
|
-
if
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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 (
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
276
|
-
boundaries, with
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
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
|