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.
@@ -0,0 +1,158 @@
1
+ """Configuration: defaults, config file loading and validation.
2
+
3
+ Precedence, lowest to highest: built-in defaults, config file, command line.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ from dataclasses import dataclass, fields, replace
9
+ from pathlib import Path
10
+
11
+ if sys.version_info >= (3, 11):
12
+ import tomllib
13
+ else: # pragma: no cover
14
+ import tomli as tomllib
15
+
16
+
17
+ class ConfigError(ValueError):
18
+ """Raised for an invalid config file or setting."""
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class SubtitleStyle:
23
+ """How subtitle cues are laid out and timed."""
24
+
25
+ max_line_length: int = 42 # Maximum characters per line
26
+ max_lines: int = 2 # Maximum number of lines
27
+ max_cps: float = 20.0 # Maximum characters per second
28
+ min_duration: float = 1.0 # Minimum subtitle duration (seconds)
29
+ max_duration: float = 7.0 # Maximum subtitle duration (seconds)
30
+ min_gap: float = 0.05 # Minimum gap between subtitles (seconds)
31
+
32
+ def __post_init__(self):
33
+ if not isinstance(self.max_line_length, int) or self.max_line_length < 1:
34
+ raise ConfigError("max_line_length must be an integer of at least 1")
35
+
36
+ if self.max_lines not in (1, 2):
37
+ raise ConfigError("max_lines must be 1 or 2")
38
+
39
+ for name in ("max_cps", "min_duration", "max_duration"):
40
+ if getattr(self, name) <= 0:
41
+ raise ConfigError(f"{name} must be greater than 0")
42
+
43
+ if self.min_gap < 0:
44
+ raise ConfigError("min_gap must not be negative")
45
+
46
+ if self.min_duration > self.max_duration:
47
+ raise ConfigError("min_duration must not be greater than max_duration")
48
+
49
+
50
+ DEVICES = ("auto", "cuda", "cpu")
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ModelConfig:
55
+ """
56
+ Which Whisper model to run, and how.
57
+
58
+ `name`, `device` and `compute_type` may be "auto", which is resolved
59
+ at run time from the hardware (see `transcribe.resolve_model_config`).
60
+ """
61
+
62
+ name: str = "auto" # model size, Hugging Face repo id, or local path
63
+ device: str = "auto" # auto, cuda or cpu
64
+ compute_type: str = "auto" # e.g. float16, int8, int8_float16
65
+ vad_filter: bool = True
66
+ condition_on_previous_text: bool = True
67
+ word_timestamps: bool = True # time cues from words, not whole segments
68
+
69
+ def __post_init__(self):
70
+ if self.device not in DEVICES:
71
+ raise ConfigError(f"device must be one of: {', '.join(DEVICES)}")
72
+
73
+ for name in ("name", "compute_type"):
74
+ if not getattr(self, name).strip():
75
+ raise ConfigError(f"{name} must not be empty")
76
+
77
+
78
+ # Names accepted in each section of the config file.
79
+ _SECTIONS = {
80
+ "style": SubtitleStyle,
81
+ "model": ModelConfig,
82
+ }
83
+
84
+
85
+ def default_config_path():
86
+ """Location of the user config file (respects XDG_CONFIG_HOME)."""
87
+
88
+ base = os.environ.get("XDG_CONFIG_HOME")
89
+ root = Path(base) if base else Path.home() / ".config"
90
+
91
+ return root / "subtitlemaker" / "config.toml"
92
+
93
+
94
+ def load_config(path=None):
95
+ """
96
+ Load the config file and return `(SubtitleStyle, ModelConfig)`.
97
+
98
+ With `path=None` the default location is used and a missing file is
99
+ fine. An explicit `path` that does not exist is an error.
100
+ """
101
+
102
+ explicit = path is not None
103
+ path = Path(path) if explicit else default_config_path()
104
+
105
+ if not path.is_file():
106
+ if explicit:
107
+ raise ConfigError(f"config file not found: {path}")
108
+
109
+ return SubtitleStyle(), ModelConfig()
110
+
111
+ try:
112
+ with open(path, "rb") as f:
113
+ data = tomllib.load(f)
114
+
115
+ except tomllib.TOMLDecodeError as e:
116
+ raise ConfigError(f"{path}: invalid TOML: {e}") from e
117
+
118
+ unknown_sections = sorted(set(data) - set(_SECTIONS))
119
+
120
+ if unknown_sections:
121
+ raise ConfigError(f"{path}: unknown section(s): {', '.join(unknown_sections)}")
122
+
123
+ built = {}
124
+
125
+ for section, cls in _SECTIONS.items():
126
+ values = data.get(section, {})
127
+ allowed = {f.name: f for f in fields(cls)}
128
+ unknown = sorted(set(values) - set(allowed))
129
+
130
+ if unknown:
131
+ raise ConfigError(
132
+ f"{path}: unknown key(s) in [{section}]: {', '.join(unknown)}"
133
+ )
134
+
135
+ defaults = cls()
136
+
137
+ for key, value in values.items():
138
+ expected = type(getattr(defaults, key))
139
+
140
+ # TOML integers are fine where a float is expected.
141
+ if expected is float and isinstance(value, int) and not isinstance(value, bool):
142
+ values[key] = float(value)
143
+
144
+ elif not isinstance(value, expected) or (
145
+ expected is int and isinstance(value, bool)
146
+ ):
147
+ raise ConfigError(
148
+ f"{path}: [{section}] {key} must be {expected.__name__}, "
149
+ f"got {type(value).__name__}"
150
+ )
151
+
152
+ try:
153
+ built[section] = replace(defaults, **values)
154
+
155
+ except ConfigError as e:
156
+ raise ConfigError(f"{path}: [{section}] {e}") from e
157
+
158
+ return built["style"], built["model"]
@@ -0,0 +1,80 @@
1
+ """Subtitle output formats.
2
+
3
+ A format is a name, a file extension and a function
4
+ `write(cues, path)`, where each cue is a dict with `start` and `end` in
5
+ seconds and `text` (lines separated by newlines).
6
+
7
+ To add a format: write a module like `vtt.py` and register it below.
8
+ """
9
+
10
+ import re
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from .srt import write_srt
16
+ from .txt import write_txt
17
+ from .vtt import write_vtt
18
+
19
+ DEFAULT_FORMAT = "srt"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class OutputFormat:
24
+ name: str
25
+ extension: str
26
+ write: Callable
27
+
28
+
29
+ FORMATS = {
30
+ f.name: f
31
+ for f in (
32
+ OutputFormat("srt", "srt", write_srt),
33
+ OutputFormat("vtt", "vtt", write_vtt),
34
+ OutputFormat("txt", "txt", write_txt),
35
+ )
36
+ }
37
+
38
+
39
+ def parse_formats(value):
40
+ """
41
+ Parse a comma or space separated list of format names.
42
+
43
+ Returns the names in order without duplicates; an empty value gives the
44
+ default format. Raises ValueError for unknown names.
45
+ """
46
+
47
+ names = []
48
+
49
+ for name in re.split(r"[,\s]+", (value or "").strip().lower()):
50
+
51
+ if name and name not in names:
52
+ names.append(name)
53
+
54
+ unknown = [name for name in names if name not in FORMATS]
55
+
56
+ if unknown:
57
+ raise ValueError(
58
+ f"unknown output format(s): {', '.join(unknown)} "
59
+ f"(choose from: {', '.join(FORMATS)})"
60
+ )
61
+
62
+ return names or [DEFAULT_FORMAT]
63
+
64
+
65
+ def output_path(base, name):
66
+ """
67
+ The file to write format `name` to, derived from `base`.
68
+
69
+ A known format extension on `base` is replaced; anything else (such as
70
+ the dots in `show.s01e01`) is kept and the extension is appended.
71
+ """
72
+
73
+ base = Path(base)
74
+ known = {"." + f.extension for f in FORMATS.values()}
75
+ extension = "." + FORMATS[name].extension
76
+
77
+ if base.suffix.lower() in known:
78
+ return base.with_suffix(extension)
79
+
80
+ return base.with_name(base.name + extension)
@@ -0,0 +1,18 @@
1
+ """Timestamp formatting shared by the subtitle formats."""
2
+
3
+
4
+ def format_timestamp(seconds, decimal):
5
+ """
6
+ Format seconds as `HH:MM:SS<decimal>mmm`.
7
+
8
+ SubRip uses a comma as the decimal separator and WebVTT a dot. Hours
9
+ are not capped at 23.
10
+ """
11
+
12
+ milliseconds = int(round(seconds * 1000))
13
+
14
+ hours, milliseconds = divmod(milliseconds, 3_600_000)
15
+ minutes, milliseconds = divmod(milliseconds, 60_000)
16
+ secs, milliseconds = divmod(milliseconds, 1000)
17
+
18
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}{decimal}{milliseconds:03d}"
@@ -0,0 +1,27 @@
1
+ """SubRip (.srt) output."""
2
+
3
+ from ._timestamp import format_timestamp
4
+
5
+
6
+ def format_srt_time(seconds):
7
+ """Convert seconds to an SRT timestamp (`HH:MM:SS,mmm`)."""
8
+
9
+ return format_timestamp(seconds, ",")
10
+
11
+
12
+ def write_srt(subtitles, path):
13
+ """Write subtitle cues to `path` as SRT."""
14
+
15
+ with open(path, "w", encoding="utf-8") as f:
16
+
17
+ for index, subtitle in enumerate(subtitles, start=1):
18
+
19
+ f.write(f"{index}\n")
20
+
21
+ f.write(
22
+ f"{format_srt_time(subtitle['start'])} "
23
+ f"--> "
24
+ f"{format_srt_time(subtitle['end'])}\n"
25
+ )
26
+
27
+ f.write(f"{subtitle['text']}\n\n")
@@ -0,0 +1,10 @@
1
+ """Plain text transcript output: one cue per line, no timing."""
2
+
3
+
4
+ def write_txt(subtitles, path):
5
+ """Write the text of each cue to `path`, one cue per line."""
6
+
7
+ with open(path, "w", encoding="utf-8") as f:
8
+
9
+ for subtitle in subtitles:
10
+ f.write(subtitle["text"].replace("\n", " ") + "\n")
@@ -0,0 +1,33 @@
1
+ """WebVTT (.vtt) output."""
2
+
3
+ from ._timestamp import format_timestamp
4
+
5
+
6
+ def format_vtt_time(seconds):
7
+ """Convert seconds to a WebVTT timestamp (`HH:MM:SS.mmm`)."""
8
+
9
+ return format_timestamp(seconds, ".")
10
+
11
+
12
+ def _escape(text):
13
+ # In WebVTT cue text, & < > are markup. Escaping > also keeps a literal
14
+ # "-->" from being read as the start of a timing line.
15
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
16
+
17
+
18
+ def write_vtt(subtitles, path):
19
+ """Write subtitle cues to `path` as WebVTT."""
20
+
21
+ with open(path, "w", encoding="utf-8") as f:
22
+
23
+ f.write("WEBVTT\n\n")
24
+
25
+ for subtitle in subtitles:
26
+
27
+ f.write(
28
+ f"{format_vtt_time(subtitle['start'])} "
29
+ f"--> "
30
+ f"{format_vtt_time(subtitle['end'])}\n"
31
+ )
32
+
33
+ f.write(f"{_escape(subtitle['text'])}\n\n")
subtitlemaker/log.py ADDED
@@ -0,0 +1,88 @@
1
+ """Logging setup: messages and progress share one stderr console."""
2
+
3
+ import logging
4
+
5
+ from rich.console import Console
6
+
7
+ LOGGER_NAME = "subtitlemaker"
8
+
9
+
10
+ class _Formatter(logging.Formatter):
11
+ """Plain messages, with a prefix for warnings and errors."""
12
+
13
+ def format(self, record):
14
+ message = record.getMessage()
15
+
16
+ # Other libraries often write their own "Warning:" prefix.
17
+ if message.lower().startswith(("warning:", "error:")):
18
+ return message
19
+
20
+ if record.levelno >= logging.ERROR:
21
+ return f"Error: {message}"
22
+
23
+ if record.levelno >= logging.WARNING:
24
+ return f"Warning: {message}"
25
+
26
+ return message
27
+
28
+
29
+ class _ConsoleHandler(logging.Handler):
30
+ """
31
+ Print records through a `rich` console.
32
+
33
+ Going through the console keeps log lines and the live progress bar
34
+ from overwriting each other. Markup, highlighting and emoji codes are
35
+ off because file names may contain [brackets] or :colons:.
36
+ """
37
+
38
+ def __init__(self, console):
39
+ super().__init__()
40
+ self.console = console
41
+
42
+ def emit(self, record):
43
+ try:
44
+ self.console.print(
45
+ self.format(record),
46
+ markup=False,
47
+ highlight=False,
48
+ emoji=False,
49
+ soft_wrap=True,
50
+ )
51
+
52
+ except Exception:
53
+ self.handleError(record)
54
+
55
+
56
+ def setup_logging(quiet=False, verbose=False):
57
+ """
58
+ Configure logging and return the shared `rich` console.
59
+
60
+ Normal runs show INFO and above, `quiet` shows only warnings and
61
+ errors, `verbose` adds DEBUG (including faster-whisper's own log).
62
+ """
63
+
64
+ console = Console(stderr=True)
65
+
66
+ handler = _ConsoleHandler(console)
67
+
68
+ handler.setFormatter(_Formatter())
69
+
70
+ root = logging.getLogger()
71
+ # Replace only our own handler from an earlier call, never other people's.
72
+ root.handlers[:] = [h for h in root.handlers if not isinstance(h, _ConsoleHandler)]
73
+ root.addHandler(handler)
74
+ root.setLevel(logging.WARNING)
75
+
76
+ if quiet:
77
+ level = logging.WARNING
78
+
79
+ elif verbose:
80
+ level = logging.DEBUG
81
+ logging.getLogger("faster_whisper").setLevel(logging.DEBUG)
82
+
83
+ else:
84
+ level = logging.INFO
85
+
86
+ logging.getLogger(LOGGER_NAME).setLevel(level)
87
+
88
+ return console
subtitlemaker/media.py ADDED
@@ -0,0 +1,55 @@
1
+ """Media file inspection."""
2
+
3
+ import av
4
+
5
+
6
+ def get_duration(filename):
7
+ """Get media duration using PyAV."""
8
+
9
+ container = av.open(filename)
10
+
11
+ try:
12
+
13
+ if container.duration is not None:
14
+ return container.duration / av.time_base
15
+
16
+ # Fallback
17
+ for stream in container.streams:
18
+
19
+ if (
20
+ stream.duration is not None
21
+ and stream.time_base is not None
22
+ ):
23
+ return float(
24
+ stream.duration
25
+ * stream.time_base
26
+ )
27
+
28
+ return 0.0
29
+
30
+ finally:
31
+ container.close()
32
+
33
+
34
+ # Containers and codecs people commonly want subtitles for. Anything else
35
+ # can still be given explicitly as a file; this only limits what is picked
36
+ # up when a folder is scanned.
37
+ MEDIA_EXTENSIONS = frozenset(
38
+ {
39
+ ".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v", ".ts", ".flv", ".wmv",
40
+ ".mpg", ".mpeg", ".3gp", ".mp3", ".m4a", ".aac", ".wav", ".flac", ".ogg",
41
+ ".oga", ".opus", ".wma", ".mka",
42
+ }
43
+ ) # fmt: skip
44
+
45
+
46
+ def find_media(directory, recursive=False):
47
+ """The media files in `directory`, sorted by path."""
48
+
49
+ candidates = directory.rglob("*") if recursive else directory.iterdir()
50
+
51
+ return sorted(
52
+ path
53
+ for path in candidates
54
+ if path.suffix.lower() in MEDIA_EXTENSIONS and path.is_file()
55
+ )
@@ -0,0 +1,97 @@
1
+ """Time formatting and the live transcription progress display."""
2
+
3
+ from rich.progress import BarColumn, Progress, ProgressColumn, TaskProgressColumn, TextColumn
4
+ from rich.text import Text
5
+
6
+
7
+ def format_time(seconds):
8
+ """Format seconds as HH:MM:SS."""
9
+
10
+ seconds = max(0, int(seconds))
11
+
12
+ hours = seconds // 3600
13
+ minutes = (seconds % 3600) // 60
14
+ secs = seconds % 60
15
+
16
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}"
17
+
18
+
19
+ class _PositionColumn(ProgressColumn):
20
+ """Media position, e.g. `00:00:06 / 00:01:00`."""
21
+
22
+ def render(self, task):
23
+ return Text(f"{format_time(task.completed)} / {format_time(task.total)}")
24
+
25
+
26
+ class _SpeedColumn(ProgressColumn):
27
+ """Media seconds processed per real second, e.g. ` 7.4x`."""
28
+
29
+ def render(self, task):
30
+ elapsed = task.elapsed or 0
31
+
32
+ speed = task.completed / elapsed if elapsed > 0 else 0.0
33
+
34
+ return Text(f"{speed:4.1f}x")
35
+
36
+
37
+ class _EtaColumn(ProgressColumn):
38
+ """Estimated real time remaining, based on the average speed so far."""
39
+
40
+ def render(self, task):
41
+ elapsed = task.elapsed or 0
42
+
43
+ speed = task.completed / elapsed if elapsed > 0 else 0.0
44
+
45
+ if speed > 0 and task.total > task.completed:
46
+ eta = (task.total - task.completed) / speed
47
+
48
+ else:
49
+ eta = 0
50
+
51
+ return Text(f"ETA {format_time(eta)}")
52
+
53
+
54
+ class TranscriptionProgress:
55
+ """
56
+ Context manager showing transcription progress.
57
+
58
+ with TranscriptionProgress(duration, console) as progress:
59
+ progress.update(seconds_processed)
60
+
61
+ With `enabled=False` (or an unknown duration) it does nothing.
62
+ """
63
+
64
+ def __init__(self, total, console, enabled=True):
65
+ self._total = float(total)
66
+ self._enabled = bool(enabled and total)
67
+
68
+ self._progress = Progress(
69
+ TextColumn("Transcribing"),
70
+ BarColumn(bar_width=30),
71
+ TaskProgressColumn(),
72
+ _PositionColumn(),
73
+ _SpeedColumn(),
74
+ _EtaColumn(),
75
+ console=console,
76
+ disable=not self._enabled,
77
+ )
78
+
79
+ self._task = None
80
+
81
+ def __enter__(self):
82
+ self._progress.start()
83
+ self._task = self._progress.add_task("", total=self._total)
84
+
85
+ return self
86
+
87
+ def __exit__(self, exc_type, exc, tb):
88
+ self._progress.stop()
89
+
90
+ def update(self, processed):
91
+ """Report that `processed` seconds of media are done."""
92
+
93
+ if self._enabled:
94
+ self._progress.update(
95
+ self._task,
96
+ completed=min(float(processed), self._total),
97
+ )