subthis 1.1.0__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.
- subthis-1.1.0/LICENSE +21 -0
- subthis-1.1.0/PKG-INFO +77 -0
- subthis-1.1.0/README.md +64 -0
- subthis-1.1.0/pyproject.toml +23 -0
- subthis-1.1.0/setup.cfg +4 -0
- subthis-1.1.0/subthis.egg-info/PKG-INFO +77 -0
- subthis-1.1.0/subthis.egg-info/SOURCES.txt +10 -0
- subthis-1.1.0/subthis.egg-info/dependency_links.txt +1 -0
- subthis-1.1.0/subthis.egg-info/entry_points.txt +2 -0
- subthis-1.1.0/subthis.egg-info/top_level.txt +1 -0
- subthis-1.1.0/subthis.py +726 -0
- subthis-1.1.0/tests/test_subthis.py +174 -0
subthis-1.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bram
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
subthis-1.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: subthis
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Create short, accurately worded SRT captions from a video
|
|
5
|
+
Author: Bram
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/ItayCohen-Prog/subthis
|
|
8
|
+
Keywords: subtitles,srt,captions,transcription,whisper
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# subthis
|
|
15
|
+
|
|
16
|
+
Turns a video (or audio file) into an SRT subtitle file with short, accurately worded captions, at most three words per cue. Built for Hebrew with English tech terms mixed in: it transcribes twice through the OpenAI API (`gpt-transcribe` for wording, `whisper-1` for word timing), aligns the two, and canonicalizes product names like OpenAI, Claude and ChatGPT.
|
|
17
|
+
|
|
18
|
+
Works on Linux, macOS and Windows. No Python dependencies outside the standard library.
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
- [ffmpeg](https://ffmpeg.org/) (`ffmpeg` and `ffprobe` on PATH). `subthis setup` tells you how to install it if it's missing.
|
|
23
|
+
- An OpenAI API key.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
With [uv](https://docs.astral.sh/uv/) (installs Python too if needed):
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
uv tool install /path/to/subthis
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or with pipx:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pipx install /path/to/subthis
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Both put a `subthis` command on your PATH on any OS. To install from a git remote instead: `uv tool install git+<repo-url>`.
|
|
40
|
+
|
|
41
|
+
## Setup
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
subthis setup
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Checks for ffmpeg, asks for your OpenAI API key (input hidden), verifies it against the API, and stores it in the config directory:
|
|
48
|
+
|
|
49
|
+
- Linux/macOS: `~/.config/subthis/`
|
|
50
|
+
- Windows: `%APPDATA%\subthis\`
|
|
51
|
+
|
|
52
|
+
It also creates `terms.txt` there, where you can add professional terms and spelling corrections:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
MyCompany
|
|
56
|
+
DaVinci Resolve = דה וינצ'י ריזולב | Davinci Resolve
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`OPENAI_API_KEY` in the environment overrides the stored key.
|
|
60
|
+
|
|
61
|
+
## Use
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
subthis lecture.mp4 # writes lecture.srt next to the input
|
|
65
|
+
subthis talk.mp4 -o subs/talk.srt # explicit output path
|
|
66
|
+
subthis clip.mp4 --max-words 2 # shorter cues
|
|
67
|
+
subthis clip.mp4 --term "Omarchy" # one-off extra term
|
|
68
|
+
subthis clip.mp4 --language he --language en
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Long videos are chunked and merged automatically; uploads stay under OpenAI's 25 MB limit.
|
|
72
|
+
|
|
73
|
+
## Tests
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
python -m unittest discover -s tests
|
|
77
|
+
```
|
subthis-1.1.0/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# subthis
|
|
2
|
+
|
|
3
|
+
Turns a video (or audio file) into an SRT subtitle file with short, accurately worded captions, at most three words per cue. Built for Hebrew with English tech terms mixed in: it transcribes twice through the OpenAI API (`gpt-transcribe` for wording, `whisper-1` for word timing), aligns the two, and canonicalizes product names like OpenAI, Claude and ChatGPT.
|
|
4
|
+
|
|
5
|
+
Works on Linux, macOS and Windows. No Python dependencies outside the standard library.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- [ffmpeg](https://ffmpeg.org/) (`ffmpeg` and `ffprobe` on PATH). `subthis setup` tells you how to install it if it's missing.
|
|
10
|
+
- An OpenAI API key.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
With [uv](https://docs.astral.sh/uv/) (installs Python too if needed):
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
uv tool install /path/to/subthis
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or with pipx:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pipx install /path/to/subthis
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Both put a `subthis` command on your PATH on any OS. To install from a git remote instead: `uv tool install git+<repo-url>`.
|
|
27
|
+
|
|
28
|
+
## Setup
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
subthis setup
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Checks for ffmpeg, asks for your OpenAI API key (input hidden), verifies it against the API, and stores it in the config directory:
|
|
35
|
+
|
|
36
|
+
- Linux/macOS: `~/.config/subthis/`
|
|
37
|
+
- Windows: `%APPDATA%\subthis\`
|
|
38
|
+
|
|
39
|
+
It also creates `terms.txt` there, where you can add professional terms and spelling corrections:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
MyCompany
|
|
43
|
+
DaVinci Resolve = דה וינצ'י ריזולב | Davinci Resolve
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`OPENAI_API_KEY` in the environment overrides the stored key.
|
|
47
|
+
|
|
48
|
+
## Use
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
subthis lecture.mp4 # writes lecture.srt next to the input
|
|
52
|
+
subthis talk.mp4 -o subs/talk.srt # explicit output path
|
|
53
|
+
subthis clip.mp4 --max-words 2 # shorter cues
|
|
54
|
+
subthis clip.mp4 --term "Omarchy" # one-off extra term
|
|
55
|
+
subthis clip.mp4 --language he --language en
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Long videos are chunked and merged automatically; uploads stay under OpenAI's 25 MB limit.
|
|
59
|
+
|
|
60
|
+
## Tests
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
python -m unittest discover -s tests
|
|
64
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "subthis"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "Create short, accurately worded SRT captions from a video"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "Bram" }]
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
keywords = ["subtitles", "srt", "captions", "transcription", "whisper"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Repository = "https://github.com/ItayCohen-Prog/subthis"
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
subthis = "subthis:main"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools]
|
|
23
|
+
py-modules = ["subthis"]
|
subthis-1.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: subthis
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Create short, accurately worded SRT captions from a video
|
|
5
|
+
Author: Bram
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/ItayCohen-Prog/subthis
|
|
8
|
+
Keywords: subtitles,srt,captions,transcription,whisper
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# subthis
|
|
15
|
+
|
|
16
|
+
Turns a video (or audio file) into an SRT subtitle file with short, accurately worded captions, at most three words per cue. Built for Hebrew with English tech terms mixed in: it transcribes twice through the OpenAI API (`gpt-transcribe` for wording, `whisper-1` for word timing), aligns the two, and canonicalizes product names like OpenAI, Claude and ChatGPT.
|
|
17
|
+
|
|
18
|
+
Works on Linux, macOS and Windows. No Python dependencies outside the standard library.
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
- [ffmpeg](https://ffmpeg.org/) (`ffmpeg` and `ffprobe` on PATH). `subthis setup` tells you how to install it if it's missing.
|
|
23
|
+
- An OpenAI API key.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
With [uv](https://docs.astral.sh/uv/) (installs Python too if needed):
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
uv tool install /path/to/subthis
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or with pipx:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pipx install /path/to/subthis
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Both put a `subthis` command on your PATH on any OS. To install from a git remote instead: `uv tool install git+<repo-url>`.
|
|
40
|
+
|
|
41
|
+
## Setup
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
subthis setup
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Checks for ffmpeg, asks for your OpenAI API key (input hidden), verifies it against the API, and stores it in the config directory:
|
|
48
|
+
|
|
49
|
+
- Linux/macOS: `~/.config/subthis/`
|
|
50
|
+
- Windows: `%APPDATA%\subthis\`
|
|
51
|
+
|
|
52
|
+
It also creates `terms.txt` there, where you can add professional terms and spelling corrections:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
MyCompany
|
|
56
|
+
DaVinci Resolve = דה וינצ'י ריזולב | Davinci Resolve
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`OPENAI_API_KEY` in the environment overrides the stored key.
|
|
60
|
+
|
|
61
|
+
## Use
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
subthis lecture.mp4 # writes lecture.srt next to the input
|
|
65
|
+
subthis talk.mp4 -o subs/talk.srt # explicit output path
|
|
66
|
+
subthis clip.mp4 --max-words 2 # shorter cues
|
|
67
|
+
subthis clip.mp4 --term "Omarchy" # one-off extra term
|
|
68
|
+
subthis clip.mp4 --language he --language en
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Long videos are chunked and merged automatically; uploads stay under OpenAI's 25 MB limit.
|
|
72
|
+
|
|
73
|
+
## Tests
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
python -m unittest discover -s tests
|
|
77
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
subthis
|
subthis-1.1.0/subthis.py
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create short, accurately worded SRT captions from a video."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import contextlib
|
|
8
|
+
import dataclasses
|
|
9
|
+
import difflib
|
|
10
|
+
import getpass
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import unicodedata
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
import uuid
|
|
22
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Iterable, Sequence
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__version__ = "1.1.0"
|
|
28
|
+
|
|
29
|
+
API_URL = "https://api.openai.com/v1/audio/transcriptions"
|
|
30
|
+
KEY_CHECK_URL = "https://api.openai.com/v1/models/whisper-1"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _config_dir() -> Path:
|
|
34
|
+
if sys.platform == "win32":
|
|
35
|
+
appdata = os.environ.get("APPDATA", "").strip()
|
|
36
|
+
if appdata:
|
|
37
|
+
return Path(appdata) / "subthis"
|
|
38
|
+
xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
|
|
39
|
+
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
40
|
+
return base / "subthis"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
CONFIG_DIR = _config_dir()
|
|
44
|
+
ENV_FILE = CONFIG_DIR / ".env"
|
|
45
|
+
TERMS_FILE = CONFIG_DIR / "terms.txt"
|
|
46
|
+
ACCURATE_MODEL = "gpt-transcribe"
|
|
47
|
+
TIMING_MODEL = "whisper-1"
|
|
48
|
+
MAX_UPLOAD_BYTES = 24 * 1024 * 1024
|
|
49
|
+
CHUNK_SECONDS = 60 * 60
|
|
50
|
+
CHUNK_OVERLAP_SECONDS = 1.5
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
DEFAULT_ALIASES: dict[str, list[str]] = {
|
|
54
|
+
"OpenAI": ["OpenAI", "Open AI", "אופן איי איי", "אופן איי-איי", "אופן איי"],
|
|
55
|
+
"Claude": ["Claude", "Clod", "קלוד", "קלאוד"],
|
|
56
|
+
"ChatGPT": ["ChatGPT", "Chat GPT", "צ'אט ג'יפיטי", "צ׳אט ג׳יפיטי", "צ'ט ג'יפיטי"],
|
|
57
|
+
"Codex": ["Codex", "קודקס"],
|
|
58
|
+
"Anthropic": ["Anthropic", "אנתרופיק"],
|
|
59
|
+
"Gemini": ["Gemini", "ג'מיני", "ג׳מיני"],
|
|
60
|
+
"Cursor": ["Cursor", "קרסר", "קורסר"],
|
|
61
|
+
"GitHub": ["GitHub", "Git Hub", "גיטהאב", "גיט האב"],
|
|
62
|
+
"WordPress": ["WordPress", "Word Press", "וורדפרס"],
|
|
63
|
+
"JavaScript": ["JavaScript", "Java Script", "ג'אווה סקריפט", "ג׳אווה סקריפט"],
|
|
64
|
+
"TypeScript": ["TypeScript", "Type Script", "טייפסקריפט"],
|
|
65
|
+
"Python": ["Python", "פייתון", "פייטון"],
|
|
66
|
+
"Linux": ["Linux", "לינוקס"],
|
|
67
|
+
"API": ["API", "A P I", "איי פי איי"],
|
|
68
|
+
"CLI": ["CLI", "C L I", "סי אל איי"],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
DEFAULT_KEYWORDS = [
|
|
72
|
+
"OpenAI",
|
|
73
|
+
"Claude",
|
|
74
|
+
"ChatGPT",
|
|
75
|
+
"Codex",
|
|
76
|
+
"Anthropic",
|
|
77
|
+
"Gemini",
|
|
78
|
+
"Cursor",
|
|
79
|
+
"GitHub",
|
|
80
|
+
"API",
|
|
81
|
+
"CLI",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SubthisError(RuntimeError):
|
|
86
|
+
"""A safe, user-facing command error."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclasses.dataclass(frozen=True)
|
|
90
|
+
class TimedWord:
|
|
91
|
+
text: str
|
|
92
|
+
start: float
|
|
93
|
+
end: float
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclasses.dataclass(frozen=True)
|
|
97
|
+
class Cue:
|
|
98
|
+
start: float
|
|
99
|
+
end: float
|
|
100
|
+
text: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclasses.dataclass(frozen=True)
|
|
104
|
+
class AudioChunk:
|
|
105
|
+
path: Path
|
|
106
|
+
offset: float
|
|
107
|
+
duration: float
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclasses.dataclass(frozen=True)
|
|
111
|
+
class Config:
|
|
112
|
+
api_key: str
|
|
113
|
+
aliases: dict[str, list[str]]
|
|
114
|
+
terms: list[str]
|
|
115
|
+
languages: list[str]
|
|
116
|
+
max_words: int
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _normalized_token(text: str) -> str:
|
|
120
|
+
normalized = unicodedata.normalize("NFKD", text).casefold()
|
|
121
|
+
return "".join(
|
|
122
|
+
char
|
|
123
|
+
for char in normalized
|
|
124
|
+
if char.isalnum() and unicodedata.category(char) != "Mn"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def canonicalize_terms(text: str, aliases: dict[str, list[str]]) -> str:
|
|
129
|
+
replacements: list[tuple[str, str]] = []
|
|
130
|
+
for canonical, spellings in aliases.items():
|
|
131
|
+
for spelling in set([canonical, *spellings]):
|
|
132
|
+
cleaned = spelling.strip()
|
|
133
|
+
if cleaned:
|
|
134
|
+
replacements.append((cleaned, canonical))
|
|
135
|
+
replacements.sort(key=lambda item: len(item[0]), reverse=True)
|
|
136
|
+
|
|
137
|
+
result = text
|
|
138
|
+
for spelling, canonical in replacements:
|
|
139
|
+
pattern = rf"(?<!\w){re.escape(spelling)}(?!\w)"
|
|
140
|
+
result = re.sub(pattern, lambda _match: canonical, result, flags=re.IGNORECASE)
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def strip_caption_punctuation(text: str) -> str:
|
|
145
|
+
without_punctuation = "".join(
|
|
146
|
+
char for char in text if not unicodedata.category(char).startswith("P")
|
|
147
|
+
)
|
|
148
|
+
return " ".join(without_punctuation.split())
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _distribute_words(tokens: Sequence[str], start: float, end: float) -> list[TimedWord]:
|
|
152
|
+
if not tokens:
|
|
153
|
+
return []
|
|
154
|
+
start = max(0.0, start)
|
|
155
|
+
end = max(start, end)
|
|
156
|
+
width = (end - start) / len(tokens)
|
|
157
|
+
return [
|
|
158
|
+
TimedWord(token, start + index * width, start + (index + 1) * width)
|
|
159
|
+
for index, token in enumerate(tokens)
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def align_accurate_words(text: str, timed_words: Sequence[TimedWord]) -> list[TimedWord]:
|
|
164
|
+
accurate_tokens = text.split()
|
|
165
|
+
if not accurate_tokens:
|
|
166
|
+
return []
|
|
167
|
+
if not timed_words:
|
|
168
|
+
raise SubthisError("The timing pass returned no words for a non-empty transcript.")
|
|
169
|
+
|
|
170
|
+
accurate_normalized = [_normalized_token(token) for token in accurate_tokens]
|
|
171
|
+
timing_normalized = [_normalized_token(word.text) for word in timed_words]
|
|
172
|
+
matcher = difflib.SequenceMatcher(
|
|
173
|
+
None,
|
|
174
|
+
accurate_normalized,
|
|
175
|
+
timing_normalized,
|
|
176
|
+
autojunk=False,
|
|
177
|
+
)
|
|
178
|
+
aligned: list[TimedWord] = []
|
|
179
|
+
|
|
180
|
+
for tag, a_start, a_end, w_start, w_end in matcher.get_opcodes():
|
|
181
|
+
tokens = accurate_tokens[a_start:a_end]
|
|
182
|
+
if tag == "equal":
|
|
183
|
+
aligned.extend(
|
|
184
|
+
TimedWord(token, source.start, source.end)
|
|
185
|
+
for token, source in zip(tokens, timed_words[w_start:w_end])
|
|
186
|
+
)
|
|
187
|
+
continue
|
|
188
|
+
if tag == "delete":
|
|
189
|
+
continue
|
|
190
|
+
if w_start < w_end:
|
|
191
|
+
interval_start = timed_words[w_start].start
|
|
192
|
+
interval_end = timed_words[w_end - 1].end
|
|
193
|
+
else:
|
|
194
|
+
interval_start = aligned[-1].end if aligned else timed_words[0].start
|
|
195
|
+
interval_end = (
|
|
196
|
+
timed_words[w_start].start
|
|
197
|
+
if w_start < len(timed_words)
|
|
198
|
+
else max(interval_start, timed_words[-1].end)
|
|
199
|
+
)
|
|
200
|
+
aligned.extend(_distribute_words(tokens, interval_start, interval_end))
|
|
201
|
+
|
|
202
|
+
monotonic: list[TimedWord] = []
|
|
203
|
+
previous_end = 0.0
|
|
204
|
+
for word in aligned:
|
|
205
|
+
start = max(previous_end, word.start)
|
|
206
|
+
end = max(start, word.end)
|
|
207
|
+
monotonic.append(TimedWord(word.text, start, end))
|
|
208
|
+
previous_end = end
|
|
209
|
+
return monotonic
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def make_cues(words: Sequence[TimedWord], media_end: float, max_words: int = 3) -> list[Cue]:
|
|
213
|
+
if not 1 <= max_words <= 3:
|
|
214
|
+
raise ValueError("max_words must be between 1 and 3")
|
|
215
|
+
clean_words = [
|
|
216
|
+
TimedWord(cleaned, word.start, word.end)
|
|
217
|
+
for word in words
|
|
218
|
+
if (cleaned := strip_caption_punctuation(word.text))
|
|
219
|
+
]
|
|
220
|
+
if not clean_words:
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
groups = [
|
|
224
|
+
list(clean_words[index : index + max_words])
|
|
225
|
+
for index in range(0, len(clean_words), max_words)
|
|
226
|
+
]
|
|
227
|
+
cues: list[Cue] = []
|
|
228
|
+
for index, group in enumerate(groups):
|
|
229
|
+
start = group[0].start
|
|
230
|
+
if index + 1 < len(groups):
|
|
231
|
+
end = groups[index + 1][0].start
|
|
232
|
+
else:
|
|
233
|
+
end = min(media_end, group[-1].end + 0.5)
|
|
234
|
+
end = max(start + 0.001, end)
|
|
235
|
+
cues.append(Cue(start, end, " ".join(word.text for word in group)))
|
|
236
|
+
return cues
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _srt_time(seconds: float) -> str:
|
|
240
|
+
total_ms = max(0, int(seconds * 1000 + 0.5))
|
|
241
|
+
hours, remainder = divmod(total_ms, 3_600_000)
|
|
242
|
+
minutes, remainder = divmod(remainder, 60_000)
|
|
243
|
+
secs, milliseconds = divmod(remainder, 1_000)
|
|
244
|
+
return f"{hours:02d}:{minutes:02d}:{secs:02d},{milliseconds:03d}"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def render_srt(cues: Sequence[Cue]) -> str:
|
|
248
|
+
blocks = [
|
|
249
|
+
f"{index}\n{_srt_time(cue.start)} --> {_srt_time(cue.end)}\n"
|
|
250
|
+
f"{strip_caption_punctuation(cue.text)}"
|
|
251
|
+
for index, cue in enumerate(cues, start=1)
|
|
252
|
+
]
|
|
253
|
+
return "\n\n".join(blocks) + ("\n" if blocks else "")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def merge_chunk_words(chunks: Sequence[Sequence[TimedWord]]) -> list[TimedWord]:
|
|
257
|
+
merged: list[TimedWord] = []
|
|
258
|
+
for chunk in chunks:
|
|
259
|
+
current = list(chunk)
|
|
260
|
+
if not current:
|
|
261
|
+
continue
|
|
262
|
+
overlap = 0
|
|
263
|
+
maximum = min(30, len(merged), len(current))
|
|
264
|
+
for size in range(maximum, 0, -1):
|
|
265
|
+
left = [_normalized_token(word.text) for word in merged[-size:]]
|
|
266
|
+
right = [_normalized_token(word.text) for word in current[:size]]
|
|
267
|
+
if left == right:
|
|
268
|
+
overlap = size
|
|
269
|
+
break
|
|
270
|
+
merged.extend(current[overlap:])
|
|
271
|
+
return merged
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _run(command: Sequence[str]) -> subprocess.CompletedProcess[str]:
|
|
275
|
+
try:
|
|
276
|
+
return subprocess.run(
|
|
277
|
+
command,
|
|
278
|
+
check=True,
|
|
279
|
+
stdout=subprocess.PIPE,
|
|
280
|
+
stderr=subprocess.PIPE,
|
|
281
|
+
text=True,
|
|
282
|
+
)
|
|
283
|
+
except FileNotFoundError as error:
|
|
284
|
+
raise SubthisError(f"Required command not found: {command[0]}") from error
|
|
285
|
+
except subprocess.CalledProcessError as error:
|
|
286
|
+
detail = error.stderr.strip().splitlines()
|
|
287
|
+
message = detail[-1] if detail else "unknown media error"
|
|
288
|
+
raise SubthisError(f"{command[0]} failed: {message}") from error
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def probe_duration(path: Path) -> float:
|
|
292
|
+
result = _run(
|
|
293
|
+
[
|
|
294
|
+
"ffprobe",
|
|
295
|
+
"-v",
|
|
296
|
+
"error",
|
|
297
|
+
"-show_entries",
|
|
298
|
+
"format=duration",
|
|
299
|
+
"-of",
|
|
300
|
+
"json",
|
|
301
|
+
str(path),
|
|
302
|
+
]
|
|
303
|
+
)
|
|
304
|
+
try:
|
|
305
|
+
duration = float(json.loads(result.stdout)["format"]["duration"])
|
|
306
|
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
|
|
307
|
+
raise SubthisError("Could not determine the video duration.") from error
|
|
308
|
+
if duration <= 0:
|
|
309
|
+
raise SubthisError("The video duration is zero.")
|
|
310
|
+
return duration
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def extract_chunks(video: Path, directory: Path) -> tuple[list[AudioChunk], float]:
|
|
314
|
+
duration = probe_duration(video)
|
|
315
|
+
chunks: list[AudioChunk] = []
|
|
316
|
+
offset = 0.0
|
|
317
|
+
index = 0
|
|
318
|
+
while offset < duration:
|
|
319
|
+
chunk_duration = min(CHUNK_SECONDS + CHUNK_OVERLAP_SECONDS, duration - offset)
|
|
320
|
+
output = directory / f"chunk-{index:04d}.ogg"
|
|
321
|
+
command = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y"]
|
|
322
|
+
if offset:
|
|
323
|
+
command.extend(["-ss", f"{offset:.3f}"])
|
|
324
|
+
command.extend(
|
|
325
|
+
[
|
|
326
|
+
"-i",
|
|
327
|
+
str(video),
|
|
328
|
+
"-t",
|
|
329
|
+
f"{chunk_duration:.3f}",
|
|
330
|
+
"-vn",
|
|
331
|
+
"-ac",
|
|
332
|
+
"1",
|
|
333
|
+
"-ar",
|
|
334
|
+
"16000",
|
|
335
|
+
"-c:a",
|
|
336
|
+
"libopus",
|
|
337
|
+
"-b:a",
|
|
338
|
+
"32k",
|
|
339
|
+
"-application",
|
|
340
|
+
"voip",
|
|
341
|
+
str(output),
|
|
342
|
+
]
|
|
343
|
+
)
|
|
344
|
+
_run(command)
|
|
345
|
+
if not output.exists() or output.stat().st_size == 0:
|
|
346
|
+
raise SubthisError("FFmpeg produced an empty audio file. The video may have no audio track.")
|
|
347
|
+
if output.stat().st_size > MAX_UPLOAD_BYTES:
|
|
348
|
+
raise SubthisError("An extracted audio chunk exceeds OpenAI's 25 MB upload limit.")
|
|
349
|
+
chunks.append(AudioChunk(output, offset, chunk_duration))
|
|
350
|
+
if offset + chunk_duration >= duration:
|
|
351
|
+
break
|
|
352
|
+
offset += CHUNK_SECONDS
|
|
353
|
+
index += 1
|
|
354
|
+
return chunks, duration
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _multipart(fields: Sequence[tuple[str, str]], file_path: Path) -> tuple[bytes, str]:
|
|
358
|
+
boundary = f"subthis-{uuid.uuid4().hex}"
|
|
359
|
+
body = bytearray()
|
|
360
|
+
for name, value in fields:
|
|
361
|
+
body.extend(f"--{boundary}\r\n".encode())
|
|
362
|
+
body.extend(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
|
|
363
|
+
body.extend(value.encode("utf-8"))
|
|
364
|
+
body.extend(b"\r\n")
|
|
365
|
+
body.extend(f"--{boundary}\r\n".encode())
|
|
366
|
+
body.extend(
|
|
367
|
+
(
|
|
368
|
+
f'Content-Disposition: form-data; name="file"; filename="{file_path.name}"\r\n'
|
|
369
|
+
"Content-Type: audio/ogg\r\n\r\n"
|
|
370
|
+
).encode()
|
|
371
|
+
)
|
|
372
|
+
body.extend(file_path.read_bytes())
|
|
373
|
+
body.extend(b"\r\n")
|
|
374
|
+
body.extend(f"--{boundary}--\r\n".encode())
|
|
375
|
+
return bytes(body), f"multipart/form-data; boundary={boundary}"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _api_error_message(payload: bytes, status: int | None = None) -> str:
|
|
379
|
+
prefix = f"OpenAI API error{f' {status}' if status else ''}"
|
|
380
|
+
try:
|
|
381
|
+
decoded = json.loads(payload.decode("utf-8", errors="replace"))
|
|
382
|
+
message = decoded.get("error", {}).get("message")
|
|
383
|
+
if isinstance(message, str) and message.strip():
|
|
384
|
+
return f"{prefix}: {message.strip()}"
|
|
385
|
+
except (json.JSONDecodeError, AttributeError):
|
|
386
|
+
pass
|
|
387
|
+
return prefix
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def request_transcription(
|
|
391
|
+
api_key: str,
|
|
392
|
+
file_path: Path,
|
|
393
|
+
fields: Sequence[tuple[str, str]],
|
|
394
|
+
) -> dict[str, object]:
|
|
395
|
+
body, content_type = _multipart(fields, file_path)
|
|
396
|
+
request = urllib.request.Request(
|
|
397
|
+
API_URL,
|
|
398
|
+
data=body,
|
|
399
|
+
headers={
|
|
400
|
+
"Authorization": f"Bearer {api_key}",
|
|
401
|
+
"Content-Type": content_type,
|
|
402
|
+
"User-Agent": "subthis/1.0",
|
|
403
|
+
},
|
|
404
|
+
method="POST",
|
|
405
|
+
)
|
|
406
|
+
try:
|
|
407
|
+
with urllib.request.urlopen(request, timeout=1800) as response:
|
|
408
|
+
payload = response.read()
|
|
409
|
+
except urllib.error.HTTPError as error:
|
|
410
|
+
raise SubthisError(_api_error_message(error.read(), error.code)) from error
|
|
411
|
+
except urllib.error.URLError as error:
|
|
412
|
+
reason = getattr(error, "reason", "connection failed")
|
|
413
|
+
raise SubthisError(f"Could not reach the OpenAI API: {reason}") from error
|
|
414
|
+
try:
|
|
415
|
+
decoded = json.loads(payload)
|
|
416
|
+
except json.JSONDecodeError as error:
|
|
417
|
+
raise SubthisError("OpenAI returned an invalid transcription response.") from error
|
|
418
|
+
if not isinstance(decoded, dict):
|
|
419
|
+
raise SubthisError("OpenAI returned an unexpected transcription response.")
|
|
420
|
+
return decoded
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def transcribe_accurate(chunk: AudioChunk, config: Config) -> str:
|
|
424
|
+
prompt = (
|
|
425
|
+
"הקלטה בעברית עם מונחים מקצועיים באנגלית. יש לכתוב שמות חברות, "
|
|
426
|
+
"מוצרים וכלים באיות המקורי באנגלית כאשר כך הם נאמרים."
|
|
427
|
+
)
|
|
428
|
+
fields: list[tuple[str, str]] = [
|
|
429
|
+
("model", ACCURATE_MODEL),
|
|
430
|
+
("response_format", "json"),
|
|
431
|
+
("prompt", prompt),
|
|
432
|
+
]
|
|
433
|
+
fields.extend(("languages[]", language) for language in config.languages)
|
|
434
|
+
fields.extend(("keywords[]", term) for term in config.terms)
|
|
435
|
+
response = request_transcription(config.api_key, chunk.path, fields)
|
|
436
|
+
text = response.get("text")
|
|
437
|
+
if not isinstance(text, str):
|
|
438
|
+
raise SubthisError("The accurate transcription response did not contain text.")
|
|
439
|
+
canonical = canonicalize_terms(text.strip(), config.aliases)
|
|
440
|
+
return strip_caption_punctuation(canonical)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def transcribe_timing(chunk: AudioChunk, config: Config) -> list[TimedWord]:
|
|
444
|
+
timing_prompt = ", ".join(config.terms[:30])
|
|
445
|
+
fields: list[tuple[str, str]] = [
|
|
446
|
+
("model", TIMING_MODEL),
|
|
447
|
+
("response_format", "verbose_json"),
|
|
448
|
+
("timestamp_granularities[]", "word"),
|
|
449
|
+
("language", config.languages[0] if config.languages else "he"),
|
|
450
|
+
]
|
|
451
|
+
if timing_prompt:
|
|
452
|
+
fields.append(("prompt", timing_prompt))
|
|
453
|
+
response = request_transcription(config.api_key, chunk.path, fields)
|
|
454
|
+
raw_words = response.get("words")
|
|
455
|
+
if not isinstance(raw_words, list):
|
|
456
|
+
raise SubthisError("The timing transcription response did not contain word timestamps.")
|
|
457
|
+
words: list[TimedWord] = []
|
|
458
|
+
for item in raw_words:
|
|
459
|
+
if not isinstance(item, dict):
|
|
460
|
+
continue
|
|
461
|
+
text = item.get("word")
|
|
462
|
+
start = item.get("start")
|
|
463
|
+
end = item.get("end")
|
|
464
|
+
if isinstance(text, str) and isinstance(start, (int, float)) and isinstance(end, (int, float)):
|
|
465
|
+
words.append(TimedWord(text.strip(), float(start), float(end)))
|
|
466
|
+
return words
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _load_api_key() -> str:
|
|
470
|
+
environment_key = os.environ.get("OPENAI_API_KEY", "").strip()
|
|
471
|
+
if environment_key:
|
|
472
|
+
return environment_key
|
|
473
|
+
if ENV_FILE.is_file():
|
|
474
|
+
with ENV_FILE.open(encoding="utf-8") as handle:
|
|
475
|
+
for line in handle:
|
|
476
|
+
match = re.match(r"\s*OPENAI_API_KEY\s*=\s*(.*?)\s*$", line)
|
|
477
|
+
if match:
|
|
478
|
+
value = match.group(1).strip().strip("\"'")
|
|
479
|
+
if value:
|
|
480
|
+
return value
|
|
481
|
+
raise SubthisError(
|
|
482
|
+
f"No OPENAI_API_KEY found in the environment or {ENV_FILE}. Run: subthis setup"
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
TERMS_TEMPLATE = """\
|
|
487
|
+
# Add one professional term per line so subthis sends it as a literal hint.
|
|
488
|
+
# Add spelling corrections with: Canonical name = alias one | alias two
|
|
489
|
+
#
|
|
490
|
+
# Examples:
|
|
491
|
+
# MyCompany
|
|
492
|
+
# DaVinci Resolve = דה וינצ'י ריזולב | Davinci Resolve
|
|
493
|
+
"""
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _ffmpeg_install_hint() -> str:
|
|
497
|
+
if sys.platform == "win32":
|
|
498
|
+
return "winget install ffmpeg (or: choco install ffmpeg)"
|
|
499
|
+
if sys.platform == "darwin":
|
|
500
|
+
return "brew install ffmpeg"
|
|
501
|
+
return "install it with your package manager, e.g. sudo pacman -S ffmpeg / sudo apt install ffmpeg"
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _verify_api_key(api_key: str) -> str | None:
|
|
505
|
+
"""Return a warning message when verification was inconclusive; raise on a bad key."""
|
|
506
|
+
request = urllib.request.Request(
|
|
507
|
+
KEY_CHECK_URL,
|
|
508
|
+
headers={"Authorization": f"Bearer {api_key}", "User-Agent": "subthis/1.1"},
|
|
509
|
+
)
|
|
510
|
+
try:
|
|
511
|
+
with urllib.request.urlopen(request, timeout=30):
|
|
512
|
+
return None
|
|
513
|
+
except urllib.error.HTTPError as error:
|
|
514
|
+
if error.code in (401, 403):
|
|
515
|
+
raise SubthisError(
|
|
516
|
+
"OpenAI rejected this API key. Check it at https://platform.openai.com/api-keys"
|
|
517
|
+
) from error
|
|
518
|
+
return f"could not verify the key (HTTP {error.code}); saved it anyway"
|
|
519
|
+
except urllib.error.URLError as error:
|
|
520
|
+
reason = getattr(error, "reason", "connection failed")
|
|
521
|
+
return f"could not reach OpenAI to verify the key ({reason}); saved it anyway"
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _write_env_file(api_key: str) -> None:
|
|
525
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
526
|
+
descriptor = os.open(
|
|
527
|
+
ENV_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600
|
|
528
|
+
)
|
|
529
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
530
|
+
handle.write(f"OPENAI_API_KEY={api_key}\n")
|
|
531
|
+
with contextlib.suppress(OSError):
|
|
532
|
+
os.chmod(ENV_FILE, 0o600)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def run_setup() -> int:
|
|
536
|
+
print(f"subthis {__version__} setup\n")
|
|
537
|
+
|
|
538
|
+
if shutil.which("ffmpeg") and shutil.which("ffprobe"):
|
|
539
|
+
print("ffmpeg: found")
|
|
540
|
+
else:
|
|
541
|
+
print("ffmpeg: NOT FOUND — subthis needs ffmpeg and ffprobe to read video.")
|
|
542
|
+
print(f" To install: {_ffmpeg_install_hint()}")
|
|
543
|
+
|
|
544
|
+
existing_key = ""
|
|
545
|
+
with contextlib.suppress(SubthisError):
|
|
546
|
+
existing_key = _load_api_key()
|
|
547
|
+
prompt = (
|
|
548
|
+
"OpenAI API key (Enter keeps the saved key): "
|
|
549
|
+
if existing_key
|
|
550
|
+
else "OpenAI API key: "
|
|
551
|
+
)
|
|
552
|
+
try:
|
|
553
|
+
if sys.stdin is None:
|
|
554
|
+
entered = ""
|
|
555
|
+
elif not sys.stdin.isatty():
|
|
556
|
+
# Piped input: Windows getpass reads the console device and would
|
|
557
|
+
# hang forever, so read stdin directly on every platform.
|
|
558
|
+
print(prompt, end="", flush=True)
|
|
559
|
+
entered = sys.stdin.readline().strip()
|
|
560
|
+
else:
|
|
561
|
+
entered = getpass.getpass(prompt).strip()
|
|
562
|
+
except EOFError:
|
|
563
|
+
entered = ""
|
|
564
|
+
api_key = entered or existing_key
|
|
565
|
+
if not api_key:
|
|
566
|
+
raise SubthisError("No API key entered. Get one at https://platform.openai.com/api-keys")
|
|
567
|
+
|
|
568
|
+
warning = _verify_api_key(api_key)
|
|
569
|
+
if warning:
|
|
570
|
+
print(f"Warning: {warning}", file=sys.stderr)
|
|
571
|
+
else:
|
|
572
|
+
print("API key verified with OpenAI.")
|
|
573
|
+
|
|
574
|
+
_write_env_file(api_key)
|
|
575
|
+
print(f"Saved API key to {ENV_FILE}")
|
|
576
|
+
|
|
577
|
+
if not TERMS_FILE.exists():
|
|
578
|
+
TERMS_FILE.write_text(TERMS_TEMPLATE, encoding="utf-8")
|
|
579
|
+
print(f"Created terms file at {TERMS_FILE}")
|
|
580
|
+
else:
|
|
581
|
+
print(f"Keeping existing terms file at {TERMS_FILE}")
|
|
582
|
+
|
|
583
|
+
print("\nSetup complete. Try: subthis video.mp4")
|
|
584
|
+
return 0
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def load_terms(path: Path, additions: Iterable[str] = ()) -> tuple[dict[str, list[str]], list[str]]:
|
|
588
|
+
aliases = {canonical: list(spellings) for canonical, spellings in DEFAULT_ALIASES.items()}
|
|
589
|
+
keywords = list(DEFAULT_KEYWORDS)
|
|
590
|
+
if path.is_file():
|
|
591
|
+
with path.open(encoding="utf-8") as handle:
|
|
592
|
+
lines = list(handle)
|
|
593
|
+
else:
|
|
594
|
+
lines = []
|
|
595
|
+
lines.extend(additions)
|
|
596
|
+
for raw_line in lines:
|
|
597
|
+
line = raw_line.strip()
|
|
598
|
+
if not line or line.startswith("#"):
|
|
599
|
+
continue
|
|
600
|
+
canonical, separator, raw_aliases = line.partition("=")
|
|
601
|
+
canonical = canonical.strip()
|
|
602
|
+
if not canonical or any(char in canonical for char in "<>\r\n"):
|
|
603
|
+
continue
|
|
604
|
+
extra = [part.strip() for part in raw_aliases.split("|") if part.strip()] if separator else []
|
|
605
|
+
aliases.setdefault(canonical, [])
|
|
606
|
+
aliases[canonical].extend(extra)
|
|
607
|
+
if canonical not in keywords:
|
|
608
|
+
keywords.append(canonical)
|
|
609
|
+
return aliases, keywords
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def transcribe_video(video: Path, config: Config) -> tuple[list[Cue], float]:
|
|
613
|
+
with tempfile.TemporaryDirectory(prefix="subthis-") as temporary:
|
|
614
|
+
chunks, duration = extract_chunks(video, Path(temporary))
|
|
615
|
+
all_words: list[list[TimedWord]] = []
|
|
616
|
+
for index, chunk in enumerate(chunks, start=1):
|
|
617
|
+
print(f"Transcribing chunk {index}/{len(chunks)}...", file=sys.stderr)
|
|
618
|
+
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
619
|
+
accurate_future = pool.submit(transcribe_accurate, chunk, config)
|
|
620
|
+
timing_future = pool.submit(transcribe_timing, chunk, config)
|
|
621
|
+
accurate_text = accurate_future.result()
|
|
622
|
+
timing_words = timing_future.result()
|
|
623
|
+
if not accurate_text and not timing_words:
|
|
624
|
+
continue
|
|
625
|
+
if not accurate_text:
|
|
626
|
+
raise SubthisError("The accurate transcription was empty while speech timing was detected.")
|
|
627
|
+
aligned = align_accurate_words(accurate_text, timing_words)
|
|
628
|
+
all_words.append(
|
|
629
|
+
[
|
|
630
|
+
TimedWord(word.text, word.start + chunk.offset, word.end + chunk.offset)
|
|
631
|
+
for word in aligned
|
|
632
|
+
]
|
|
633
|
+
)
|
|
634
|
+
merged = merge_chunk_words(all_words)
|
|
635
|
+
return make_cues(merged, duration, config.max_words), duration
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
639
|
+
parser = argparse.ArgumentParser(
|
|
640
|
+
prog="subthis",
|
|
641
|
+
description="Create accurate Hebrew/English SRT captions with at most three words per cue.",
|
|
642
|
+
epilog="Run 'subthis setup' once after installing to store your OpenAI API key.",
|
|
643
|
+
)
|
|
644
|
+
parser.add_argument("--version", action="version", version=f"subthis {__version__}")
|
|
645
|
+
parser.add_argument("video", type=Path, help="video or audio file to transcribe")
|
|
646
|
+
parser.add_argument("-o", "--output", type=Path, help="output SRT path; defaults beside the input")
|
|
647
|
+
parser.add_argument(
|
|
648
|
+
"--max-words",
|
|
649
|
+
type=int,
|
|
650
|
+
choices=(1, 2, 3),
|
|
651
|
+
default=3,
|
|
652
|
+
help="maximum words per subtitle (default: 3)",
|
|
653
|
+
)
|
|
654
|
+
parser.add_argument("--term", action="append", default=[], help="extra canonical term; repeat as needed")
|
|
655
|
+
parser.add_argument(
|
|
656
|
+
"--terms-file",
|
|
657
|
+
type=Path,
|
|
658
|
+
default=TERMS_FILE,
|
|
659
|
+
help=f"term list (default: {TERMS_FILE})",
|
|
660
|
+
)
|
|
661
|
+
parser.add_argument(
|
|
662
|
+
"--language",
|
|
663
|
+
action="append",
|
|
664
|
+
dest="languages",
|
|
665
|
+
help="expected ISO-639-1 language; repeat for code-switching (default: he, en)",
|
|
666
|
+
)
|
|
667
|
+
parser.add_argument("--force", action="store_true", help="overwrite an existing subtitle file")
|
|
668
|
+
return parser
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def run(argv: Sequence[str] | None = None) -> int:
|
|
672
|
+
arguments = list(sys.argv[1:] if argv is None else argv)
|
|
673
|
+
if arguments and arguments[0] == "setup":
|
|
674
|
+
if len(arguments) > 1:
|
|
675
|
+
raise SubthisError("setup takes no further arguments.")
|
|
676
|
+
return run_setup()
|
|
677
|
+
args = build_parser().parse_args(arguments)
|
|
678
|
+
video = args.video.expanduser().resolve()
|
|
679
|
+
if not video.is_file():
|
|
680
|
+
raise SubthisError(f"Input file not found: {video}")
|
|
681
|
+
if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
|
|
682
|
+
raise SubthisError("subthis requires both ffmpeg and ffprobe.")
|
|
683
|
+
output = (args.output or video.with_suffix(".srt")).expanduser().resolve()
|
|
684
|
+
if output == video:
|
|
685
|
+
raise SubthisError("The output path must differ from the input path.")
|
|
686
|
+
if output.exists() and not args.force:
|
|
687
|
+
raise SubthisError(f"Output already exists: {output}. Use --force to replace it.")
|
|
688
|
+
aliases, terms = load_terms(args.terms_file.expanduser(), args.term)
|
|
689
|
+
config = Config(
|
|
690
|
+
api_key=_load_api_key(),
|
|
691
|
+
aliases=aliases,
|
|
692
|
+
terms=terms,
|
|
693
|
+
languages=args.languages or ["he", "en"],
|
|
694
|
+
max_words=args.max_words,
|
|
695
|
+
)
|
|
696
|
+
cues, _duration = transcribe_video(video, config)
|
|
697
|
+
if not cues:
|
|
698
|
+
raise SubthisError("No speech was detected, so no subtitle file was written.")
|
|
699
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
700
|
+
temporary_output = output.with_name(f".{output.name}.subthis-{os.getpid()}.tmp")
|
|
701
|
+
try:
|
|
702
|
+
temporary_output.write_text(render_srt(cues), encoding="utf-8")
|
|
703
|
+
os.replace(temporary_output, output)
|
|
704
|
+
finally:
|
|
705
|
+
with contextlib.suppress(FileNotFoundError):
|
|
706
|
+
temporary_output.unlink()
|
|
707
|
+
print(f"Wrote {len(cues)} subtitles to {output}")
|
|
708
|
+
return 0
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def main() -> int:
|
|
712
|
+
for stream in (sys.stdout, sys.stderr):
|
|
713
|
+
with contextlib.suppress(Exception):
|
|
714
|
+
stream.reconfigure(errors="replace")
|
|
715
|
+
try:
|
|
716
|
+
return run()
|
|
717
|
+
except SubthisError as error:
|
|
718
|
+
print(f"subthis: {error}", file=sys.stderr)
|
|
719
|
+
return 1
|
|
720
|
+
except KeyboardInterrupt:
|
|
721
|
+
print("subthis: cancelled", file=sys.stderr)
|
|
722
|
+
return 130
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
if __name__ == "__main__":
|
|
726
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
MODULE_PATH = Path(__file__).parents[1] / "subthis.py"
|
|
12
|
+
SPEC = importlib.util.spec_from_file_location("subthis", MODULE_PATH)
|
|
13
|
+
subthis = importlib.util.module_from_spec(SPEC)
|
|
14
|
+
sys.modules["subthis"] = subthis
|
|
15
|
+
assert SPEC.loader is not None
|
|
16
|
+
SPEC.loader.exec_module(subthis)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CanonicalTermsTests(unittest.TestCase):
|
|
20
|
+
def test_replaces_hebrew_and_english_aliases_with_canonical_terms(self) -> None:
|
|
21
|
+
text = "בדקתי אופן איי איי וגם קלוד ואז chat gpt"
|
|
22
|
+
|
|
23
|
+
actual = subthis.canonicalize_terms(text, subthis.DEFAULT_ALIASES)
|
|
24
|
+
|
|
25
|
+
self.assertEqual(actual, "בדקתי OpenAI וגם Claude ואז ChatGPT")
|
|
26
|
+
|
|
27
|
+
def test_does_not_replace_alias_inside_another_word(self) -> None:
|
|
28
|
+
actual = subthis.canonicalize_terms("הענן cloudiness", subthis.DEFAULT_ALIASES)
|
|
29
|
+
|
|
30
|
+
self.assertEqual(actual, "הענן cloudiness")
|
|
31
|
+
|
|
32
|
+
def test_does_not_turn_the_real_word_cloud_into_claude(self) -> None:
|
|
33
|
+
actual = subthis.canonicalize_terms("cloud computing", subthis.DEFAULT_ALIASES)
|
|
34
|
+
|
|
35
|
+
self.assertEqual(actual, "cloud computing")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AlignmentTests(unittest.TestCase):
|
|
39
|
+
def test_transfers_timing_when_accurate_model_merges_phonetic_words(self) -> None:
|
|
40
|
+
timed = [
|
|
41
|
+
subthis.TimedWord("אנחנו", 0.0, 0.3),
|
|
42
|
+
subthis.TimedWord("עובדים", 0.3, 0.7),
|
|
43
|
+
subthis.TimedWord("עם", 0.7, 0.9),
|
|
44
|
+
subthis.TimedWord("אופן", 0.9, 1.1),
|
|
45
|
+
subthis.TimedWord("איי", 1.1, 1.3),
|
|
46
|
+
subthis.TimedWord("איי", 1.3, 1.5),
|
|
47
|
+
subthis.TimedWord("היום", 1.5, 1.9),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
actual = subthis.align_accurate_words("אנחנו עובדים עם OpenAI היום", timed)
|
|
51
|
+
|
|
52
|
+
self.assertEqual([word.text for word in actual], ["אנחנו", "עובדים", "עם", "OpenAI", "היום"])
|
|
53
|
+
self.assertAlmostEqual(actual[3].start, 0.9)
|
|
54
|
+
self.assertAlmostEqual(actual[3].end, 1.5)
|
|
55
|
+
self.assertAlmostEqual(actual[4].start, 1.5)
|
|
56
|
+
|
|
57
|
+
def test_alignment_remains_monotonic_when_no_tokens_match(self) -> None:
|
|
58
|
+
timed = [
|
|
59
|
+
subthis.TimedWord("one", 2.0, 2.4),
|
|
60
|
+
subthis.TimedWord("two", 2.5, 3.0),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
actual = subthis.align_accurate_words("שלום עולם חדש", timed)
|
|
64
|
+
|
|
65
|
+
self.assertEqual(len(actual), 3)
|
|
66
|
+
self.assertEqual(actual[0].start, 2.0)
|
|
67
|
+
self.assertEqual(actual[-1].end, 3.0)
|
|
68
|
+
self.assertTrue(all(a.end <= b.start for a, b in zip(actual, actual[1:])))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CueTests(unittest.TestCase):
|
|
72
|
+
def test_removes_all_unicode_punctuation_from_caption_text(self) -> None:
|
|
73
|
+
actual = subthis.strip_caption_punctuation(
|
|
74
|
+
"שלום, OpenAI! מה נשמע? Next.js — כן. צ׳אט-בוט"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
self.assertEqual(actual, "שלום OpenAI מה נשמע Nextjs כן צאטבוט")
|
|
78
|
+
|
|
79
|
+
def test_groups_at_most_three_words_and_holds_cue_across_pause(self) -> None:
|
|
80
|
+
words = [
|
|
81
|
+
subthis.TimedWord("אחד", 0.0, 0.2),
|
|
82
|
+
subthis.TimedWord("שתיים", 0.25, 0.5),
|
|
83
|
+
subthis.TimedWord("שלוש", 0.55, 0.8),
|
|
84
|
+
subthis.TimedWord("ארבע", 3.0, 3.2),
|
|
85
|
+
subthis.TimedWord("חמש", 3.25, 3.5),
|
|
86
|
+
subthis.TimedWord("שש", 3.55, 3.8),
|
|
87
|
+
subthis.TimedWord("שבע", 4.0, 4.3),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
cues = subthis.make_cues(words, media_end=10.0, max_words=3)
|
|
91
|
+
|
|
92
|
+
self.assertEqual([cue.text for cue in cues], ["אחד שתיים שלוש", "ארבע חמש שש", "שבע"])
|
|
93
|
+
self.assertEqual(cues[0].end, 3.0)
|
|
94
|
+
self.assertEqual(cues[1].end, 4.0)
|
|
95
|
+
self.assertEqual(cues[2].end, 4.8)
|
|
96
|
+
self.assertTrue(all(len(cue.text.split()) <= 3 for cue in cues))
|
|
97
|
+
|
|
98
|
+
def test_make_cues_never_emits_punctuation(self) -> None:
|
|
99
|
+
words = [
|
|
100
|
+
subthis.TimedWord("שלום,", 0.0, 0.3),
|
|
101
|
+
subthis.TimedWord("OpenAI!", 0.4, 0.8),
|
|
102
|
+
subthis.TimedWord("באמת?", 0.9, 1.2),
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
cues = subthis.make_cues(words, media_end=2.0, max_words=3)
|
|
106
|
+
|
|
107
|
+
self.assertEqual(cues[0].text, "שלום OpenAI באמת")
|
|
108
|
+
|
|
109
|
+
def test_srt_rounds_milliseconds_without_overlapping(self) -> None:
|
|
110
|
+
cues = [
|
|
111
|
+
subthis.Cue(0.0, 1.2346, "שלום OpenAI"),
|
|
112
|
+
subthis.Cue(1.2346, 2.0, "מה נשמע"),
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
actual = subthis.render_srt(cues)
|
|
116
|
+
|
|
117
|
+
self.assertEqual(
|
|
118
|
+
actual,
|
|
119
|
+
"1\n00:00:00,000 --> 00:00:01,235\nשלום OpenAI\n\n"
|
|
120
|
+
"2\n00:00:01,235 --> 00:00:02,000\nמה נשמע\n",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ChunkMergeTests(unittest.TestCase):
|
|
125
|
+
def test_discards_duplicate_words_from_overlapping_chunks(self) -> None:
|
|
126
|
+
first = [
|
|
127
|
+
subthis.TimedWord("hello", 0.0, 0.4),
|
|
128
|
+
subthis.TimedWord("OpenAI", 0.5, 1.0),
|
|
129
|
+
]
|
|
130
|
+
second = [
|
|
131
|
+
subthis.TimedWord("OpenAI", 0.5, 1.0),
|
|
132
|
+
subthis.TimedWord("again", 1.1, 1.5),
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
actual = subthis.merge_chunk_words([first, second])
|
|
136
|
+
|
|
137
|
+
self.assertEqual([word.text for word in actual], ["hello", "OpenAI", "again"])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ConfigDirTests(unittest.TestCase):
|
|
141
|
+
def test_uses_xdg_config_home_when_set(self) -> None:
|
|
142
|
+
with mock.patch.object(subthis.sys, "platform", "linux"), mock.patch.dict(
|
|
143
|
+
os.environ, {"XDG_CONFIG_HOME": "/custom/config"}
|
|
144
|
+
):
|
|
145
|
+
self.assertEqual(subthis._config_dir(), Path("/custom/config/subthis"))
|
|
146
|
+
|
|
147
|
+
def test_defaults_to_dot_config_without_xdg(self) -> None:
|
|
148
|
+
with mock.patch.object(subthis.sys, "platform", "linux"), mock.patch.dict(
|
|
149
|
+
os.environ, {"XDG_CONFIG_HOME": ""}
|
|
150
|
+
):
|
|
151
|
+
self.assertEqual(subthis._config_dir(), Path.home() / ".config" / "subthis")
|
|
152
|
+
|
|
153
|
+
def test_uses_appdata_on_windows(self) -> None:
|
|
154
|
+
with mock.patch.object(subthis.sys, "platform", "win32"), mock.patch.dict(
|
|
155
|
+
os.environ, {"APPDATA": r"C:\Users\bram\AppData\Roaming"}
|
|
156
|
+
):
|
|
157
|
+
actual = subthis._config_dir()
|
|
158
|
+
self.assertEqual(actual.name, "subthis")
|
|
159
|
+
self.assertIn("AppData", str(actual))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class SetupDispatchTests(unittest.TestCase):
|
|
163
|
+
def test_setup_rejects_extra_arguments(self) -> None:
|
|
164
|
+
with self.assertRaises(subthis.SubthisError):
|
|
165
|
+
subthis.run(["setup", "extra"])
|
|
166
|
+
|
|
167
|
+
def test_setup_dispatches_to_run_setup(self) -> None:
|
|
168
|
+
with mock.patch.object(subthis, "run_setup", return_value=0) as run_setup:
|
|
169
|
+
self.assertEqual(subthis.run(["setup"]), 0)
|
|
170
|
+
run_setup.assert_called_once_with()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
unittest.main()
|