subtitlemaker 0.1.0__py3-none-any.whl
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.
- subtitlemaker/__init__.py +3 -0
- subtitlemaker/__main__.py +5 -0
- subtitlemaker/_cuda.py +91 -0
- subtitlemaker/cli.py +771 -0
- subtitlemaker/config.py +158 -0
- subtitlemaker/formats/__init__.py +80 -0
- subtitlemaker/formats/_timestamp.py +18 -0
- subtitlemaker/formats/srt.py +27 -0
- subtitlemaker/formats/txt.py +10 -0
- subtitlemaker/formats/vtt.py +33 -0
- subtitlemaker/log.py +88 -0
- subtitlemaker/media.py +55 -0
- subtitlemaker/progress.py +97 -0
- subtitlemaker/segmenter.py +304 -0
- subtitlemaker/transcribe.py +253 -0
- subtitlemaker-0.1.0.dist-info/METADATA +207 -0
- subtitlemaker-0.1.0.dist-info/RECORD +20 -0
- subtitlemaker-0.1.0.dist-info/WHEEL +4 -0
- subtitlemaker-0.1.0.dist-info/entry_points.txt +2 -0
- subtitlemaker-0.1.0.dist-info/licenses/LICENSE +21 -0
subtitlemaker/cli.py
ADDED
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import dataclasses
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .config import DEVICES, ConfigError, load_config
|
|
11
|
+
from .formats import DEFAULT_FORMAT, FORMATS, output_path, parse_formats
|
|
12
|
+
from .log import LOGGER_NAME, setup_logging
|
|
13
|
+
from .media import find_media, get_duration
|
|
14
|
+
from .progress import TranscriptionProgress, format_time
|
|
15
|
+
from .segmenter import build_subtitles
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(LOGGER_NAME)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclasses.dataclass(frozen=True)
|
|
21
|
+
class _Options:
|
|
22
|
+
"""Everything that stays the same for every file in a run."""
|
|
23
|
+
|
|
24
|
+
style: object
|
|
25
|
+
model_config: object
|
|
26
|
+
task: str
|
|
27
|
+
languages: list
|
|
28
|
+
language_hint: object
|
|
29
|
+
multilingual: bool
|
|
30
|
+
condition_on_previous_text: bool
|
|
31
|
+
detect_segments: int
|
|
32
|
+
detect_threshold: float
|
|
33
|
+
quiet: bool
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _expand_inputs(inputs, recursive):
|
|
37
|
+
"""
|
|
38
|
+
Turn the command line inputs into a list of media files.
|
|
39
|
+
|
|
40
|
+
Folders contribute the media files in them (and in sub folders with
|
|
41
|
+
`recursive`). Returns `(files, missing)`; duplicates are dropped.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
files = []
|
|
45
|
+
missing = []
|
|
46
|
+
seen = set()
|
|
47
|
+
|
|
48
|
+
for raw in inputs:
|
|
49
|
+
|
|
50
|
+
path = Path(raw)
|
|
51
|
+
|
|
52
|
+
if path.is_dir():
|
|
53
|
+
found = find_media(path, recursive)
|
|
54
|
+
|
|
55
|
+
if not found:
|
|
56
|
+
log.warning("no media files found in %s", path)
|
|
57
|
+
|
|
58
|
+
elif path.exists():
|
|
59
|
+
found = [path]
|
|
60
|
+
|
|
61
|
+
else:
|
|
62
|
+
missing.append(path)
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
for file in found:
|
|
66
|
+
|
|
67
|
+
key = file.resolve()
|
|
68
|
+
|
|
69
|
+
if key not in seen:
|
|
70
|
+
seen.add(key)
|
|
71
|
+
files.append(file)
|
|
72
|
+
|
|
73
|
+
return files, missing
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _plan_outputs(input_file, args, formats):
|
|
77
|
+
"""The `{format: path}` files to write for one input."""
|
|
78
|
+
|
|
79
|
+
if args.output:
|
|
80
|
+
|
|
81
|
+
base = Path(args.output)
|
|
82
|
+
|
|
83
|
+
# One format: the name is used exactly as given.
|
|
84
|
+
if len(formats) == 1:
|
|
85
|
+
return {formats[0]: base}
|
|
86
|
+
|
|
87
|
+
return {name: output_path(base, name) for name in formats}
|
|
88
|
+
|
|
89
|
+
folder = Path(args.output_dir) if args.output_dir else input_file.parent
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
name: folder / f"{input_file.stem}.{FORMATS[name].extension}"
|
|
93
|
+
for name in formats
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _process_file(model, input_file, outputs, options, console):
|
|
98
|
+
"""Transcribe one file and write its outputs. Returns True on success."""
|
|
99
|
+
|
|
100
|
+
if options.multilingual:
|
|
101
|
+
|
|
102
|
+
if options.languages:
|
|
103
|
+
language_display = (
|
|
104
|
+
f"{', '.join(options.languages)} "
|
|
105
|
+
f"(re-detected per segment)"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
else:
|
|
109
|
+
language_display = "auto-detect per segment"
|
|
110
|
+
|
|
111
|
+
else:
|
|
112
|
+
|
|
113
|
+
language_display = (
|
|
114
|
+
options.languages[0] if options.languages else "auto-detect"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
log.info("Input: %s", input_file)
|
|
118
|
+
log.info("Output: %s", ", ".join(str(p) for p in outputs.values()))
|
|
119
|
+
log.info("Language: %s", language_display)
|
|
120
|
+
log.info("Task: %s", options.task)
|
|
121
|
+
log.info(
|
|
122
|
+
"Prompt history: %s",
|
|
123
|
+
"on" if options.condition_on_previous_text else "off",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
log.debug("Reading media information...")
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
duration = get_duration(input_file)
|
|
130
|
+
|
|
131
|
+
except Exception as e:
|
|
132
|
+
log.warning("couldn't determine duration: %s", e)
|
|
133
|
+
duration = 0.0
|
|
134
|
+
|
|
135
|
+
if duration:
|
|
136
|
+
log.info("Duration: %s", format_time(duration))
|
|
137
|
+
|
|
138
|
+
log.info("Transcribing...")
|
|
139
|
+
|
|
140
|
+
transcription_start = time.monotonic()
|
|
141
|
+
|
|
142
|
+
segments, info = model.transcribe(
|
|
143
|
+
str(input_file),
|
|
144
|
+
language=options.language_hint,
|
|
145
|
+
task=options.task,
|
|
146
|
+
vad_filter=options.model_config.vad_filter,
|
|
147
|
+
word_timestamps=options.model_config.word_timestamps,
|
|
148
|
+
multilingual=options.multilingual,
|
|
149
|
+
condition_on_previous_text=options.condition_on_previous_text,
|
|
150
|
+
language_detection_segments=options.detect_segments,
|
|
151
|
+
language_detection_threshold=options.detect_threshold,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
processed_segments = []
|
|
155
|
+
|
|
156
|
+
with TranscriptionProgress(
|
|
157
|
+
duration,
|
|
158
|
+
console,
|
|
159
|
+
enabled=not options.quiet,
|
|
160
|
+
) as progress:
|
|
161
|
+
|
|
162
|
+
for segment in segments:
|
|
163
|
+
|
|
164
|
+
processed_segments.append(segment)
|
|
165
|
+
|
|
166
|
+
progress.update(float(segment.end))
|
|
167
|
+
|
|
168
|
+
# Final progress
|
|
169
|
+
progress.update(duration)
|
|
170
|
+
|
|
171
|
+
elapsed = time.monotonic() - transcription_start
|
|
172
|
+
|
|
173
|
+
log.info("Transcription complete in %s.", format_time(elapsed))
|
|
174
|
+
|
|
175
|
+
if duration and elapsed > 0:
|
|
176
|
+
log.info("Average speed: %.2fx realtime", duration / elapsed)
|
|
177
|
+
|
|
178
|
+
if options.multilingual:
|
|
179
|
+
|
|
180
|
+
log.info(
|
|
181
|
+
"Initial language: %s (%.1f%%)",
|
|
182
|
+
info.language,
|
|
183
|
+
info.language_probability * 100,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
log.info(
|
|
187
|
+
"Languages were re-detected for every segment; "
|
|
188
|
+
"the value above is only the first guess."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
else:
|
|
192
|
+
|
|
193
|
+
log.info(
|
|
194
|
+
"Detected language: %s (%.1f%%)",
|
|
195
|
+
info.language,
|
|
196
|
+
info.language_probability * 100,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
log.info("Formatting subtitles...")
|
|
200
|
+
|
|
201
|
+
subtitles = build_subtitles(processed_segments, options.style)
|
|
202
|
+
|
|
203
|
+
log.info("Generated %d subtitle cues.", len(subtitles))
|
|
204
|
+
|
|
205
|
+
for name, path in outputs.items():
|
|
206
|
+
|
|
207
|
+
log.info("Writing: %s", path)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
FORMATS[name].write(subtitles, path)
|
|
211
|
+
|
|
212
|
+
except OSError as e:
|
|
213
|
+
log.error("couldn't write %s: %s", path, e)
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main():
|
|
220
|
+
|
|
221
|
+
# Imported here so --help and argument errors don't wait for the
|
|
222
|
+
# heavy machine learning libraries to load.
|
|
223
|
+
from .transcribe import (
|
|
224
|
+
ModelError,
|
|
225
|
+
load_model,
|
|
226
|
+
parse_language_argument,
|
|
227
|
+
resolve_model_config,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
parser = argparse.ArgumentParser(
|
|
231
|
+
prog="subtitlemaker",
|
|
232
|
+
description=(
|
|
233
|
+
"Generate subtitles (SRT, WebVTT or plain text) "
|
|
234
|
+
"from audio and video files using Faster-Whisper."
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
parser.add_argument(
|
|
239
|
+
"--version",
|
|
240
|
+
action="version",
|
|
241
|
+
version=f"%(prog)s {__version__}",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
parser.add_argument(
|
|
245
|
+
"inputs",
|
|
246
|
+
nargs="+",
|
|
247
|
+
metavar="INPUT",
|
|
248
|
+
help=(
|
|
249
|
+
"Video/audio files, or folders to scan for them "
|
|
250
|
+
"(see -r)"
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
parser.add_argument(
|
|
255
|
+
"-f",
|
|
256
|
+
"--format",
|
|
257
|
+
metavar="FORMATS",
|
|
258
|
+
help=(
|
|
259
|
+
"Output format(s), comma separated: "
|
|
260
|
+
f"{', '.join(FORMATS)} (default: {DEFAULT_FORMAT}). "
|
|
261
|
+
"Each is written next to the input"
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
parser.add_argument(
|
|
266
|
+
"-o",
|
|
267
|
+
"--output",
|
|
268
|
+
metavar="FILE",
|
|
269
|
+
help=(
|
|
270
|
+
"Output file, for a single input. With several "
|
|
271
|
+
"formats it is a base name and the extension is "
|
|
272
|
+
"replaced for each one"
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
parser.add_argument(
|
|
277
|
+
"-d",
|
|
278
|
+
"--output-dir",
|
|
279
|
+
metavar="DIR",
|
|
280
|
+
help="Write outputs here instead of next to each input",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
parser.add_argument(
|
|
284
|
+
"-r",
|
|
285
|
+
"--recursive",
|
|
286
|
+
action="store_true",
|
|
287
|
+
help="Also look in sub folders of folders given as input",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
parser.add_argument(
|
|
291
|
+
"--skip-existing",
|
|
292
|
+
action="store_true",
|
|
293
|
+
help=(
|
|
294
|
+
"Skip inputs whose output files all exist already "
|
|
295
|
+
"(by default they are overwritten)"
|
|
296
|
+
),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
parser.add_argument(
|
|
300
|
+
"-l",
|
|
301
|
+
"--language",
|
|
302
|
+
help=(
|
|
303
|
+
"Source language. Several "
|
|
304
|
+
"comma-separated codes are "
|
|
305
|
+
"allowed for mixed-language "
|
|
306
|
+
"audio (e.g. en, sv, he,en)"
|
|
307
|
+
),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
parser.add_argument(
|
|
311
|
+
"-m",
|
|
312
|
+
"--multilingual",
|
|
313
|
+
action="store_true",
|
|
314
|
+
help=(
|
|
315
|
+
"Audio contains more than one "
|
|
316
|
+
"language: re-detect the language "
|
|
317
|
+
"before every segment instead of "
|
|
318
|
+
"using one language for the whole "
|
|
319
|
+
"file"
|
|
320
|
+
),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
parser.add_argument(
|
|
324
|
+
"-t",
|
|
325
|
+
"--task",
|
|
326
|
+
choices=[
|
|
327
|
+
"transcribe",
|
|
328
|
+
"translate",
|
|
329
|
+
],
|
|
330
|
+
default="transcribe",
|
|
331
|
+
help=(
|
|
332
|
+
"transcribe = subtitles in the spoken language; "
|
|
333
|
+
"translate = translate to English (default: transcribe)"
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
parser.add_argument(
|
|
338
|
+
"--detect-segments",
|
|
339
|
+
type=int,
|
|
340
|
+
default=1,
|
|
341
|
+
metavar="N",
|
|
342
|
+
help=(
|
|
343
|
+
"Segments sampled for the initial "
|
|
344
|
+
"language detection (default: 1)"
|
|
345
|
+
),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
parser.add_argument(
|
|
349
|
+
"--detect-threshold",
|
|
350
|
+
type=float,
|
|
351
|
+
default=0.5,
|
|
352
|
+
metavar="P",
|
|
353
|
+
help=(
|
|
354
|
+
"Confidence needed to accept the "
|
|
355
|
+
"initial language detection "
|
|
356
|
+
"(default: 0.5)"
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
parser.add_argument(
|
|
361
|
+
"--condition-on-previous-text",
|
|
362
|
+
action=argparse.BooleanOptionalAction,
|
|
363
|
+
default=None,
|
|
364
|
+
help=(
|
|
365
|
+
"Use previous text as a prompt for "
|
|
366
|
+
"the next segment "
|
|
367
|
+
"(default: off for multilingual, "
|
|
368
|
+
"on otherwise)"
|
|
369
|
+
),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
parser.add_argument(
|
|
373
|
+
"--config",
|
|
374
|
+
metavar="FILE",
|
|
375
|
+
help=(
|
|
376
|
+
"TOML config file (default: "
|
|
377
|
+
"~/.config/subtitlemaker/config.toml "
|
|
378
|
+
"if it exists)"
|
|
379
|
+
),
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
verbosity = parser.add_mutually_exclusive_group()
|
|
383
|
+
|
|
384
|
+
verbosity.add_argument(
|
|
385
|
+
"-q",
|
|
386
|
+
"--quiet",
|
|
387
|
+
action="store_true",
|
|
388
|
+
help="Only show warnings and errors (no progress bar)",
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
verbosity.add_argument(
|
|
392
|
+
"-v",
|
|
393
|
+
"--verbose",
|
|
394
|
+
action="store_true",
|
|
395
|
+
help="Show debug output",
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
model_group = parser.add_argument_group(
|
|
399
|
+
"model",
|
|
400
|
+
"Override the defaults and any config file values",
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
model_group.add_argument(
|
|
404
|
+
"--model",
|
|
405
|
+
metavar="NAME",
|
|
406
|
+
help=(
|
|
407
|
+
"Whisper model: tiny, base, small, medium, large-v3, "
|
|
408
|
+
"turbo, ..., a Hugging Face repo id, or a local path "
|
|
409
|
+
"(default: large-v3 on GPU, small on CPU). Models are "
|
|
410
|
+
"downloaded on first use."
|
|
411
|
+
),
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
model_group.add_argument(
|
|
415
|
+
"--device",
|
|
416
|
+
choices=DEVICES,
|
|
417
|
+
help=(
|
|
418
|
+
"Where to run: auto uses the GPU if one is found and "
|
|
419
|
+
"falls back to the CPU (default: auto)"
|
|
420
|
+
),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
model_group.add_argument(
|
|
424
|
+
"--compute-type",
|
|
425
|
+
metavar="TYPE",
|
|
426
|
+
help=(
|
|
427
|
+
"Numeric precision, e.g. float16, int8_float16, int8, "
|
|
428
|
+
"float32 (default: float16 on GPU, int8 on CPU)"
|
|
429
|
+
),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
model_group.add_argument(
|
|
433
|
+
"--word-timestamps",
|
|
434
|
+
action=argparse.BooleanOptionalAction,
|
|
435
|
+
default=None,
|
|
436
|
+
help=(
|
|
437
|
+
"Time cues from individual words, which splits long "
|
|
438
|
+
"sentences accurately (default: on; --no-word-timestamps "
|
|
439
|
+
"is faster but less precise)"
|
|
440
|
+
),
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
style_group = parser.add_argument_group(
|
|
444
|
+
"subtitle style",
|
|
445
|
+
"Override the defaults and any config file values",
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
style_group.add_argument(
|
|
449
|
+
"--max-line-length",
|
|
450
|
+
type=int,
|
|
451
|
+
metavar="N",
|
|
452
|
+
help="Maximum characters per line (default: 42)",
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
style_group.add_argument(
|
|
456
|
+
"--max-lines",
|
|
457
|
+
type=int,
|
|
458
|
+
metavar="N",
|
|
459
|
+
help="Maximum lines per subtitle (default: 2)",
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
style_group.add_argument(
|
|
463
|
+
"--max-cps",
|
|
464
|
+
type=float,
|
|
465
|
+
metavar="N",
|
|
466
|
+
help="Maximum characters per second (default: 20)",
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
style_group.add_argument(
|
|
470
|
+
"--min-duration",
|
|
471
|
+
type=float,
|
|
472
|
+
metavar="SEC",
|
|
473
|
+
help="Minimum subtitle duration (default: 1.0)",
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
style_group.add_argument(
|
|
477
|
+
"--max-duration",
|
|
478
|
+
type=float,
|
|
479
|
+
metavar="SEC",
|
|
480
|
+
help="Maximum subtitle duration (default: 7.0)",
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
style_group.add_argument(
|
|
484
|
+
"--min-gap",
|
|
485
|
+
type=float,
|
|
486
|
+
metavar="SEC",
|
|
487
|
+
help="Minimum gap between subtitles (default: 0.05)",
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
args = parser.parse_args()
|
|
491
|
+
|
|
492
|
+
console = setup_logging(
|
|
493
|
+
quiet=args.quiet,
|
|
494
|
+
verbose=args.verbose,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# --------------------------------------------------------
|
|
498
|
+
# Settings: defaults < config file < command line
|
|
499
|
+
# --------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
style, model_config = load_config(args.config)
|
|
503
|
+
|
|
504
|
+
overrides = {
|
|
505
|
+
field.name: getattr(args, field.name)
|
|
506
|
+
for field in dataclasses.fields(style)
|
|
507
|
+
if getattr(args, field.name) is not None
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
style = dataclasses.replace(style, **overrides)
|
|
511
|
+
|
|
512
|
+
# Command line option -> ModelConfig field
|
|
513
|
+
model_overrides = {
|
|
514
|
+
field: getattr(args, option)
|
|
515
|
+
for option, field in (
|
|
516
|
+
("model", "name"),
|
|
517
|
+
("device", "device"),
|
|
518
|
+
("compute_type", "compute_type"),
|
|
519
|
+
("word_timestamps", "word_timestamps"),
|
|
520
|
+
)
|
|
521
|
+
if getattr(args, option) is not None
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
model_config = dataclasses.replace(model_config, **model_overrides)
|
|
525
|
+
|
|
526
|
+
except ConfigError as e:
|
|
527
|
+
parser.error(str(e))
|
|
528
|
+
|
|
529
|
+
# --------------------------------------------------------
|
|
530
|
+
# Output options
|
|
531
|
+
# --------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
formats = parse_formats(args.format)
|
|
535
|
+
|
|
536
|
+
except ValueError as e:
|
|
537
|
+
parser.error(str(e))
|
|
538
|
+
|
|
539
|
+
if args.output and args.output_dir:
|
|
540
|
+
parser.error("-o/--output and -d/--output-dir cannot be combined")
|
|
541
|
+
|
|
542
|
+
# --------------------------------------------------------
|
|
543
|
+
# Language options
|
|
544
|
+
# --------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
try:
|
|
547
|
+
languages, listed_multilingual = (
|
|
548
|
+
parse_language_argument(
|
|
549
|
+
args.language
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
except ValueError as e:
|
|
554
|
+
parser.error(str(e))
|
|
555
|
+
|
|
556
|
+
if args.detect_segments < 1:
|
|
557
|
+
parser.error(
|
|
558
|
+
"--detect-segments must be at least 1"
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
if not 0.0 < args.detect_threshold <= 1.0:
|
|
562
|
+
parser.error(
|
|
563
|
+
"--detect-threshold must be "
|
|
564
|
+
"greater than 0 and at most 1"
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
# More than one language code, or -m/--multilingual,
|
|
568
|
+
# means the language has to be decided again for every
|
|
569
|
+
# segment instead of once for the whole file.
|
|
570
|
+
multilingual = (
|
|
571
|
+
args.multilingual or listed_multilingual
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
# Whisper holds a single language token per decode, so a
|
|
575
|
+
# hint is only used when the language is fixed for the
|
|
576
|
+
# whole file. Leaving it unset also keeps the initial
|
|
577
|
+
# detection available for the report.
|
|
578
|
+
language_hint = (
|
|
579
|
+
None
|
|
580
|
+
if multilingual
|
|
581
|
+
else (languages[0] if languages else None)
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
if args.condition_on_previous_text is not None:
|
|
585
|
+
|
|
586
|
+
condition_on_previous_text = (
|
|
587
|
+
args.condition_on_previous_text
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
else:
|
|
591
|
+
|
|
592
|
+
# Text from the previous segment pulls the next one
|
|
593
|
+
# back towards the language it was written in, which
|
|
594
|
+
# fights per-segment language detection.
|
|
595
|
+
condition_on_previous_text = (
|
|
596
|
+
model_config.condition_on_previous_text
|
|
597
|
+
and not multilingual
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
# --------------------------------------------------------
|
|
601
|
+
# Inputs and where their outputs go
|
|
602
|
+
# --------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
files, missing = _expand_inputs(args.inputs, args.recursive)
|
|
605
|
+
|
|
606
|
+
for path in missing:
|
|
607
|
+
log.error("input file does not exist: %s", path)
|
|
608
|
+
|
|
609
|
+
if not files:
|
|
610
|
+
|
|
611
|
+
if not missing:
|
|
612
|
+
log.error("no media files to process")
|
|
613
|
+
|
|
614
|
+
return 1
|
|
615
|
+
|
|
616
|
+
if args.output and len(files) > 1:
|
|
617
|
+
parser.error(
|
|
618
|
+
"-o/--output can only be used with a single input file; "
|
|
619
|
+
"use -d/--output-dir for several"
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
plan = {file: _plan_outputs(file, args, formats) for file in files}
|
|
623
|
+
|
|
624
|
+
owners = {}
|
|
625
|
+
|
|
626
|
+
for file, outputs in plan.items():
|
|
627
|
+
|
|
628
|
+
for path in outputs.values():
|
|
629
|
+
|
|
630
|
+
if path in owners:
|
|
631
|
+
parser.error(
|
|
632
|
+
f"{owners[path]} and {file} would both write {path}; "
|
|
633
|
+
f"use separate output folders"
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
owners[path] = file
|
|
637
|
+
|
|
638
|
+
failed = len(missing)
|
|
639
|
+
skipped = 0
|
|
640
|
+
todo = []
|
|
641
|
+
|
|
642
|
+
for file in files:
|
|
643
|
+
|
|
644
|
+
if args.skip_existing and all(p.exists() for p in plan[file].values()):
|
|
645
|
+
log.info("Skipping %s (output exists)", file)
|
|
646
|
+
skipped += 1
|
|
647
|
+
|
|
648
|
+
else:
|
|
649
|
+
todo.append(file)
|
|
650
|
+
|
|
651
|
+
# --------------------------------------------------------
|
|
652
|
+
# Model: resolved and loaded once, only if there is work
|
|
653
|
+
# --------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
done = 0
|
|
656
|
+
|
|
657
|
+
if todo:
|
|
658
|
+
|
|
659
|
+
if args.output_dir:
|
|
660
|
+
|
|
661
|
+
try:
|
|
662
|
+
Path(args.output_dir).mkdir(parents=True, exist_ok=True)
|
|
663
|
+
|
|
664
|
+
except OSError as e:
|
|
665
|
+
log.error("couldn't create output folder %s: %s", args.output_dir, e)
|
|
666
|
+
return 1
|
|
667
|
+
|
|
668
|
+
# A GPU that turns out to be unusable only falls back to the CPU when
|
|
669
|
+
# the device was left on auto.
|
|
670
|
+
allow_cpu_fallback = model_config.device == "auto"
|
|
671
|
+
|
|
672
|
+
try:
|
|
673
|
+
model_config = resolve_model_config(model_config)
|
|
674
|
+
|
|
675
|
+
except ModelError as e:
|
|
676
|
+
log.error("%s", e)
|
|
677
|
+
return 1
|
|
678
|
+
|
|
679
|
+
log.info("Model: %s", model_config.name)
|
|
680
|
+
log.info("Device: %s", model_config.device)
|
|
681
|
+
log.info("Compute type: %s", model_config.compute_type)
|
|
682
|
+
|
|
683
|
+
if multilingual and args.task == "translate":
|
|
684
|
+
log.warning(
|
|
685
|
+
"task=translate turns every segment into English."
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
log.debug("Subtitle configuration:")
|
|
689
|
+
log.debug(" Max line length: %s", style.max_line_length)
|
|
690
|
+
log.debug(" Max lines: %s", style.max_lines)
|
|
691
|
+
log.debug(" Max CPS: %g", style.max_cps)
|
|
692
|
+
log.debug(" Min duration: %ss", style.min_duration)
|
|
693
|
+
log.debug(" Max duration: %ss", style.max_duration)
|
|
694
|
+
log.debug(" Minimum gap: %ss", style.min_gap)
|
|
695
|
+
|
|
696
|
+
log.info("Loading Whisper model...")
|
|
697
|
+
|
|
698
|
+
try:
|
|
699
|
+
model, model_config = load_model(
|
|
700
|
+
model_config,
|
|
701
|
+
allow_cpu_fallback=allow_cpu_fallback,
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
except ModelError as e:
|
|
705
|
+
log.error("%s", e)
|
|
706
|
+
return 1
|
|
707
|
+
|
|
708
|
+
options = _Options(
|
|
709
|
+
style=style,
|
|
710
|
+
model_config=model_config,
|
|
711
|
+
task=args.task,
|
|
712
|
+
languages=languages,
|
|
713
|
+
language_hint=language_hint,
|
|
714
|
+
multilingual=multilingual,
|
|
715
|
+
condition_on_previous_text=condition_on_previous_text,
|
|
716
|
+
detect_segments=args.detect_segments,
|
|
717
|
+
detect_threshold=args.detect_threshold,
|
|
718
|
+
quiet=args.quiet,
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
for index, file in enumerate(todo, start=1):
|
|
722
|
+
|
|
723
|
+
if len(todo) > 1:
|
|
724
|
+
log.info("[%d/%d] %s", index, len(todo), file.name)
|
|
725
|
+
|
|
726
|
+
try:
|
|
727
|
+
ok = _process_file(model, file, plan[file], options, console)
|
|
728
|
+
|
|
729
|
+
except Exception as e:
|
|
730
|
+
# One bad file must not stop the rest of a batch.
|
|
731
|
+
log.error("failed to process %s: %s", file, e)
|
|
732
|
+
log.debug("details:", exc_info=True)
|
|
733
|
+
ok = False
|
|
734
|
+
|
|
735
|
+
if ok:
|
|
736
|
+
done += 1
|
|
737
|
+
|
|
738
|
+
else:
|
|
739
|
+
failed += 1
|
|
740
|
+
|
|
741
|
+
# --------------------------------------------------------
|
|
742
|
+
# Summary
|
|
743
|
+
# --------------------------------------------------------
|
|
744
|
+
|
|
745
|
+
if failed:
|
|
746
|
+
|
|
747
|
+
log.warning(
|
|
748
|
+
"%d done, %d skipped, %d failed",
|
|
749
|
+
done,
|
|
750
|
+
skipped,
|
|
751
|
+
failed,
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
return 1
|
|
755
|
+
|
|
756
|
+
if len(files) == 1 and done == 1:
|
|
757
|
+
|
|
758
|
+
log.info("Done! 🎬")
|
|
759
|
+
log.info(
|
|
760
|
+
"Subtitle file: %s",
|
|
761
|
+
", ".join(str(p) for p in plan[files[0]].values()),
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
else:
|
|
765
|
+
log.info("Done! 🎬 %d done, %d skipped", done, skipped)
|
|
766
|
+
|
|
767
|
+
return 0
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
if __name__ == "__main__":
|
|
771
|
+
raise SystemExit(main())
|