aisrt 0.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.
- aisrt-0.1.0/LICENSE +21 -0
- aisrt-0.1.0/PKG-INFO +152 -0
- aisrt-0.1.0/README.md +127 -0
- aisrt-0.1.0/pyproject.toml +53 -0
- aisrt-0.1.0/src/aisrt/__init__.py +3 -0
- aisrt-0.1.0/src/aisrt/assembly.py +192 -0
- aisrt-0.1.0/src/aisrt/cli.py +177 -0
- aisrt-0.1.0/src/aisrt/config.py +80 -0
- aisrt-0.1.0/src/aisrt/discovery.py +195 -0
- aisrt-0.1.0/src/aisrt/extractor.py +156 -0
- aisrt-0.1.0/src/aisrt/hardware.py +134 -0
- aisrt-0.1.0/src/aisrt/pipeline.py +209 -0
- aisrt-0.1.0/src/aisrt/state.py +208 -0
- aisrt-0.1.0/src/aisrt/stt.py +79 -0
aisrt-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arvarik
|
|
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.
|
aisrt-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aisrt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hardware-aware, concurrent pipeline for subtitle generation.
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Author: Arvind
|
|
7
|
+
Author-email: arvind@example.com
|
|
8
|
+
Requires-Python: >=3.11,<4.0
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Dist: aiosqlite (>=0.19.0,<0.20.0)
|
|
15
|
+
Requires-Dist: faster-whisper (>=1.0.0,<2.0.0)
|
|
16
|
+
Requires-Dist: loguru (>=0.7.2,<0.8.0)
|
|
17
|
+
Requires-Dist: psutil (>=5.9.8,<6.0.0)
|
|
18
|
+
Requires-Dist: pydantic (>=2.5.3,<3.0.0)
|
|
19
|
+
Requires-Dist: pydantic-settings (>=2.1.0,<3.0.0)
|
|
20
|
+
Requires-Dist: pynvml (>=11.5.0,<12.0.0)
|
|
21
|
+
Requires-Dist: rich (>=13.7.0,<14.0.0)
|
|
22
|
+
Requires-Dist: typer[all] (>=0.9.0,<0.10.0)
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
<div align="center">
|
|
26
|
+
<h1>🎬 Ultimate SRT Generator</h1>
|
|
27
|
+
<p><strong>Hardware-aware, zero-disk, highly concurrent AI pipeline for mass-generating broadcast-quality subtitles.</strong></p>
|
|
28
|
+
|
|
29
|
+
[](https://www.python.org/downloads/)
|
|
30
|
+
[](https://github.com/astral-sh/ruff)
|
|
31
|
+
[](https://mypy-lang.org/)
|
|
32
|
+
[](https://opensource.org/licenses/MIT)
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 🌟 Overview
|
|
38
|
+
|
|
39
|
+
The **Ultimate SRT Generator** is a production-grade daemon built for power users, NAS hoarders, and sysadmins. It autonomously crawls massive network-attached media libraries, detects videos missing English subtitles, performs **zero-disk** audio extraction directly into RAM, and infers broadcast-quality `.srt` files using state-of-the-art [faster-whisper](https://github.com/SYSTRAN/faster-whisper) AI models.
|
|
40
|
+
|
|
41
|
+
### 🛠️ Key Architectural Features
|
|
42
|
+
|
|
43
|
+
* **Zero-Disk Audio Extraction:** Stops SSD wear-and-tear by bypassing `/tmp/` files completely. Audio streams are asynchronously ripped via FFmpeg and piped directly into RAM (NumPy arrays) for AI ingestion.
|
|
44
|
+
* **Bounded Asynchronous Concurrency:** Eliminates Python GIL starvation via an `asyncio.TaskGroup` producer-consumer pipeline. It extracts audio streams exactly as fast as the GPU can transcribe them, strictly capping memory usage.
|
|
45
|
+
* **Intelligent Hardware Routing Matrix:** Auto-detects NVIDIA GPUs (VRAM), Apple Silicon, or pure CPU environments to intelligently route to the most optimal `large-v3-turbo`, `small`, or quantized `int8` model.
|
|
46
|
+
* **NAS-Safe & Deduplicating:** Backed by an asynchronous local SQLite database (WAL mode) tracking `inode` and `size`. It avoids parsing active downloads, seamlessly skips duplicate hardlinks, and performs strict POSIX Atomic `os.replace` operations with original MKV metadata inheritance.
|
|
47
|
+
* **Broadcast Formatting:** Implements a strict chunking algorithm on top of Whisper's word-level timestamps. No more "walls of text"—subtitles are limited to ~42 chars and 2 lines, naturally breaking on terminal punctuation.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 🚀 Installation & Deployment
|
|
52
|
+
|
|
53
|
+
### 🐳 Docker (Recommended for TrueNAS / Unraid)
|
|
54
|
+
|
|
55
|
+
For maximum stability and ease-of-use with NVIDIA hardware, use the provided Docker stack.
|
|
56
|
+
|
|
57
|
+
1. Clone the repository:
|
|
58
|
+
```bash
|
|
59
|
+
git clone https://github.com/arvarik/srt-generator.git
|
|
60
|
+
cd srt-generator
|
|
61
|
+
```
|
|
62
|
+
2. Review and modify the `docker-compose.yml` to point to your media directory:
|
|
63
|
+
```yaml
|
|
64
|
+
volumes:
|
|
65
|
+
- /mnt/user/media/movies:/media:rw
|
|
66
|
+
- ./aisrt_data:/root/.config/aisrt:rw
|
|
67
|
+
```
|
|
68
|
+
3. Deploy:
|
|
69
|
+
```bash
|
|
70
|
+
docker compose up --build -d
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 💻 Native Python (Ubuntu Desktop / Server / macOS)
|
|
74
|
+
|
|
75
|
+
**Prerequisites:** Python 3.11+ and `ffmpeg` must be installed on your system.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# 1. Clone repository
|
|
79
|
+
git clone https://github.com/arvarik/srt-generator.git
|
|
80
|
+
cd srt-generator
|
|
81
|
+
|
|
82
|
+
# 2. Create virtual environment
|
|
83
|
+
python3 -m venv .venv
|
|
84
|
+
source .venv/bin/activate
|
|
85
|
+
|
|
86
|
+
# 3. Install the application
|
|
87
|
+
pip install -e .
|
|
88
|
+
|
|
89
|
+
# 4. (Optional) Install development dependencies
|
|
90
|
+
pip install -e ".[dev]"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 🎮 Usage
|
|
96
|
+
|
|
97
|
+
The application features a beautifully formatted CLI built on `Typer` and `Rich`.
|
|
98
|
+
|
|
99
|
+
### Dry-Run (Scan)
|
|
100
|
+
Safely scan a directory to see exactly what hardware will be loaded and what files will be processed, without actually running the AI model.
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
aisrt scan /path/to/movies --min-age-mins 60 --verbose
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Live Run
|
|
107
|
+
Execute the extraction and inference pipeline.
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
aisrt run /path/to/movies
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### CLI Overrides & Environment Variables
|
|
114
|
+
You can manually override the hardware auto-detector and execution options.
|
|
115
|
+
|
|
116
|
+
**Via CLI:**
|
|
117
|
+
```bash
|
|
118
|
+
aisrt run /path/to/movies --force-device cuda --force-model large-v3-turbo --translate --watch --watch-interval 60
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Via Docker Environment Variables:**
|
|
122
|
+
Since `AppConfig` utilizes `pydantic-settings`, you can configure the daemon entirely through your `docker-compose.yml`:
|
|
123
|
+
* `AISRT_TRANSLATE=True` (Auto-dub foreign audio into English)
|
|
124
|
+
* `AISRT_WATCH=True` (Run 24/7 as a daemon)
|
|
125
|
+
* `AISRT_WATCH_INTERVAL_MINS=60` (Time between library scans)
|
|
126
|
+
* `AISRT_FILTERS__MIN_AGE_MINS=30` (Skip active torrent/usenet downloads)
|
|
127
|
+
* `AISRT_HARDWARE__FORCE_MODEL=large-v3-turbo`
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## 🏗️ Open Source Development
|
|
132
|
+
|
|
133
|
+
We welcome contributions! The codebase strictly adheres to enterprise-level typing and styling.
|
|
134
|
+
|
|
135
|
+
**Development Setup:**
|
|
136
|
+
```bash
|
|
137
|
+
poetry install # Or pip install -e ".[dev]"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**Running Tests & Linters:**
|
|
141
|
+
```bash
|
|
142
|
+
ruff check . # Linter
|
|
143
|
+
ruff format . # Formatter
|
|
144
|
+
mypy src/aisrt tests # Strict Type Checking
|
|
145
|
+
pytest tests # Asynchronous Unit Tests
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## 📜 License
|
|
151
|
+
Distributed under the MIT License. See `LICENSE` for more information.
|
|
152
|
+
|
aisrt-0.1.0/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>🎬 Ultimate SRT Generator</h1>
|
|
3
|
+
<p><strong>Hardware-aware, zero-disk, highly concurrent AI pipeline for mass-generating broadcast-quality subtitles.</strong></p>
|
|
4
|
+
|
|
5
|
+
[](https://www.python.org/downloads/)
|
|
6
|
+
[](https://github.com/astral-sh/ruff)
|
|
7
|
+
[](https://mypy-lang.org/)
|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 🌟 Overview
|
|
14
|
+
|
|
15
|
+
The **Ultimate SRT Generator** is a production-grade daemon built for power users, NAS hoarders, and sysadmins. It autonomously crawls massive network-attached media libraries, detects videos missing English subtitles, performs **zero-disk** audio extraction directly into RAM, and infers broadcast-quality `.srt` files using state-of-the-art [faster-whisper](https://github.com/SYSTRAN/faster-whisper) AI models.
|
|
16
|
+
|
|
17
|
+
### 🛠️ Key Architectural Features
|
|
18
|
+
|
|
19
|
+
* **Zero-Disk Audio Extraction:** Stops SSD wear-and-tear by bypassing `/tmp/` files completely. Audio streams are asynchronously ripped via FFmpeg and piped directly into RAM (NumPy arrays) for AI ingestion.
|
|
20
|
+
* **Bounded Asynchronous Concurrency:** Eliminates Python GIL starvation via an `asyncio.TaskGroup` producer-consumer pipeline. It extracts audio streams exactly as fast as the GPU can transcribe them, strictly capping memory usage.
|
|
21
|
+
* **Intelligent Hardware Routing Matrix:** Auto-detects NVIDIA GPUs (VRAM), Apple Silicon, or pure CPU environments to intelligently route to the most optimal `large-v3-turbo`, `small`, or quantized `int8` model.
|
|
22
|
+
* **NAS-Safe & Deduplicating:** Backed by an asynchronous local SQLite database (WAL mode) tracking `inode` and `size`. It avoids parsing active downloads, seamlessly skips duplicate hardlinks, and performs strict POSIX Atomic `os.replace` operations with original MKV metadata inheritance.
|
|
23
|
+
* **Broadcast Formatting:** Implements a strict chunking algorithm on top of Whisper's word-level timestamps. No more "walls of text"—subtitles are limited to ~42 chars and 2 lines, naturally breaking on terminal punctuation.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 🚀 Installation & Deployment
|
|
28
|
+
|
|
29
|
+
### 🐳 Docker (Recommended for TrueNAS / Unraid)
|
|
30
|
+
|
|
31
|
+
For maximum stability and ease-of-use with NVIDIA hardware, use the provided Docker stack.
|
|
32
|
+
|
|
33
|
+
1. Clone the repository:
|
|
34
|
+
```bash
|
|
35
|
+
git clone https://github.com/arvarik/srt-generator.git
|
|
36
|
+
cd srt-generator
|
|
37
|
+
```
|
|
38
|
+
2. Review and modify the `docker-compose.yml` to point to your media directory:
|
|
39
|
+
```yaml
|
|
40
|
+
volumes:
|
|
41
|
+
- /mnt/user/media/movies:/media:rw
|
|
42
|
+
- ./aisrt_data:/root/.config/aisrt:rw
|
|
43
|
+
```
|
|
44
|
+
3. Deploy:
|
|
45
|
+
```bash
|
|
46
|
+
docker compose up --build -d
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 💻 Native Python (Ubuntu Desktop / Server / macOS)
|
|
50
|
+
|
|
51
|
+
**Prerequisites:** Python 3.11+ and `ffmpeg` must be installed on your system.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# 1. Clone repository
|
|
55
|
+
git clone https://github.com/arvarik/srt-generator.git
|
|
56
|
+
cd srt-generator
|
|
57
|
+
|
|
58
|
+
# 2. Create virtual environment
|
|
59
|
+
python3 -m venv .venv
|
|
60
|
+
source .venv/bin/activate
|
|
61
|
+
|
|
62
|
+
# 3. Install the application
|
|
63
|
+
pip install -e .
|
|
64
|
+
|
|
65
|
+
# 4. (Optional) Install development dependencies
|
|
66
|
+
pip install -e ".[dev]"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## 🎮 Usage
|
|
72
|
+
|
|
73
|
+
The application features a beautifully formatted CLI built on `Typer` and `Rich`.
|
|
74
|
+
|
|
75
|
+
### Dry-Run (Scan)
|
|
76
|
+
Safely scan a directory to see exactly what hardware will be loaded and what files will be processed, without actually running the AI model.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
aisrt scan /path/to/movies --min-age-mins 60 --verbose
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Live Run
|
|
83
|
+
Execute the extraction and inference pipeline.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
aisrt run /path/to/movies
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### CLI Overrides & Environment Variables
|
|
90
|
+
You can manually override the hardware auto-detector and execution options.
|
|
91
|
+
|
|
92
|
+
**Via CLI:**
|
|
93
|
+
```bash
|
|
94
|
+
aisrt run /path/to/movies --force-device cuda --force-model large-v3-turbo --translate --watch --watch-interval 60
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Via Docker Environment Variables:**
|
|
98
|
+
Since `AppConfig` utilizes `pydantic-settings`, you can configure the daemon entirely through your `docker-compose.yml`:
|
|
99
|
+
* `AISRT_TRANSLATE=True` (Auto-dub foreign audio into English)
|
|
100
|
+
* `AISRT_WATCH=True` (Run 24/7 as a daemon)
|
|
101
|
+
* `AISRT_WATCH_INTERVAL_MINS=60` (Time between library scans)
|
|
102
|
+
* `AISRT_FILTERS__MIN_AGE_MINS=30` (Skip active torrent/usenet downloads)
|
|
103
|
+
* `AISRT_HARDWARE__FORCE_MODEL=large-v3-turbo`
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## 🏗️ Open Source Development
|
|
108
|
+
|
|
109
|
+
We welcome contributions! The codebase strictly adheres to enterprise-level typing and styling.
|
|
110
|
+
|
|
111
|
+
**Development Setup:**
|
|
112
|
+
```bash
|
|
113
|
+
poetry install # Or pip install -e ".[dev]"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Running Tests & Linters:**
|
|
117
|
+
```bash
|
|
118
|
+
ruff check . # Linter
|
|
119
|
+
ruff format . # Formatter
|
|
120
|
+
mypy src/aisrt tests # Strict Type Checking
|
|
121
|
+
pytest tests # Asynchronous Unit Tests
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## 📜 License
|
|
127
|
+
Distributed under the MIT License. See `LICENSE` for more information.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["poetry-core>=1.0.0"]
|
|
3
|
+
build-backend = "poetry.core.masonry.api"
|
|
4
|
+
|
|
5
|
+
[tool.poetry]
|
|
6
|
+
name = "aisrt"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Hardware-aware, concurrent pipeline for subtitle generation."
|
|
9
|
+
authors = ["Arvind <arvind@example.com>"]
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
packages = [{include = "aisrt", from = "src"}]
|
|
12
|
+
|
|
13
|
+
[tool.poetry.scripts]
|
|
14
|
+
aisrt = "aisrt.cli:app"
|
|
15
|
+
|
|
16
|
+
[tool.poetry.dependencies]
|
|
17
|
+
python = "^3.11"
|
|
18
|
+
typer = {extras = ["all"], version = "^0.9.0"}
|
|
19
|
+
rich = "^13.7.0"
|
|
20
|
+
loguru = "^0.7.2"
|
|
21
|
+
pydantic = "^2.5.3"
|
|
22
|
+
pydantic-settings = "^2.1.0"
|
|
23
|
+
aiosqlite = "^0.19.0"
|
|
24
|
+
psutil = "^5.9.8"
|
|
25
|
+
pynvml = "^11.5.0"
|
|
26
|
+
faster-whisper = "^1.0.0"
|
|
27
|
+
|
|
28
|
+
[tool.poetry.group.dev.dependencies]
|
|
29
|
+
pytest = "^8.0.0"
|
|
30
|
+
pytest-asyncio = "^0.23.5"
|
|
31
|
+
ruff = "^0.2.0"
|
|
32
|
+
mypy = "^1.8.0"
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
line-length = 100
|
|
36
|
+
target-version = "py311"
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = [
|
|
40
|
+
"E", # pycodestyle errors
|
|
41
|
+
"W", # pycodestyle warnings
|
|
42
|
+
"F", # pyflakes
|
|
43
|
+
"I", # isort
|
|
44
|
+
"C", # flake8-comprehensions
|
|
45
|
+
"B", # flake8-bugbear
|
|
46
|
+
"UP", # pyupgrade
|
|
47
|
+
]
|
|
48
|
+
ignore = []
|
|
49
|
+
|
|
50
|
+
[tool.mypy]
|
|
51
|
+
python_version = "3.11"
|
|
52
|
+
strict = true
|
|
53
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Broadcast-quality SubRip (SRT) formatting and Atomic File I/O."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _format_timestamp(seconds: float) -> str:
|
|
11
|
+
"""Format a timestamp (in float seconds) to SRT standard: HH:MM:SS,mmm."""
|
|
12
|
+
hours = int(seconds // 3600)
|
|
13
|
+
minutes = int((seconds % 3600) // 60)
|
|
14
|
+
secs = int(seconds % 60)
|
|
15
|
+
millis = int(round((seconds - int(seconds)) * 1000))
|
|
16
|
+
|
|
17
|
+
if millis == 1000:
|
|
18
|
+
secs += 1
|
|
19
|
+
millis = 0
|
|
20
|
+
if secs == 60:
|
|
21
|
+
secs = 0
|
|
22
|
+
minutes += 1
|
|
23
|
+
if minutes == 60:
|
|
24
|
+
minutes = 0
|
|
25
|
+
hours += 1
|
|
26
|
+
|
|
27
|
+
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SRTFormatter:
|
|
31
|
+
"""Chunks Whisper words into broadcast-standard SRT format."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, max_chars_per_line: int = 42, max_lines: int = 2) -> None:
|
|
34
|
+
"""Initialize the SRT chunker.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
max_chars_per_line: Maximum characters before wrapping a line.
|
|
38
|
+
max_lines: Maximum lines per subtitle block.
|
|
39
|
+
"""
|
|
40
|
+
self.max_chars_per_line = max_chars_per_line
|
|
41
|
+
self.max_lines = max_lines
|
|
42
|
+
self.terminal_punctuation = {".", "?", "!", "。", "?", "!"}
|
|
43
|
+
|
|
44
|
+
def format_segments(self, segments: Any) -> str:
|
|
45
|
+
"""Iterate over faster-whisper Segment/Word objects and yield SRT blocks.
|
|
46
|
+
|
|
47
|
+
Requires word_timestamps=True in the Whisper model transcribe() call.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
segments: A generator of faster-whisper Segment objects.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The complete SRT file content as a string.
|
|
54
|
+
"""
|
|
55
|
+
self._srt_blocks: list[str] = []
|
|
56
|
+
self._block_idx = 1
|
|
57
|
+
|
|
58
|
+
for segment in segments:
|
|
59
|
+
if not getattr(segment, "words", None):
|
|
60
|
+
self._format_raw_segment(segment)
|
|
61
|
+
else:
|
|
62
|
+
self._format_word_segment(segment)
|
|
63
|
+
|
|
64
|
+
return "\n".join(self._srt_blocks)
|
|
65
|
+
|
|
66
|
+
def _format_raw_segment(self, segment: Any) -> None:
|
|
67
|
+
"""Fallback formatter for segments without word timestamps."""
|
|
68
|
+
text = segment.text.strip()
|
|
69
|
+
if text:
|
|
70
|
+
start = _format_timestamp(segment.start)
|
|
71
|
+
end = _format_timestamp(segment.end)
|
|
72
|
+
self._srt_blocks.append(f"{self._block_idx}\n{start} --> {end}\n{text}\n")
|
|
73
|
+
self._block_idx += 1
|
|
74
|
+
|
|
75
|
+
def _format_word_segment(self, segment: Any) -> None:
|
|
76
|
+
"""Advanced formatter that chunks based on character count and punctuation."""
|
|
77
|
+
current_words: list[str] = []
|
|
78
|
+
current_start: float | None = None
|
|
79
|
+
current_end: float = 0.0
|
|
80
|
+
char_count = 0
|
|
81
|
+
line_count = 1
|
|
82
|
+
|
|
83
|
+
for word_obj in segment.words:
|
|
84
|
+
word = word_obj.word.strip()
|
|
85
|
+
if not word:
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# Temporal gap check: flush if silence > 1.5s
|
|
89
|
+
if current_end > 0.0 and (word_obj.start - current_end) > 1.5:
|
|
90
|
+
if current_words and current_start is not None:
|
|
91
|
+
self._flush_words(current_words, current_start, current_end)
|
|
92
|
+
current_words = []
|
|
93
|
+
current_start = None
|
|
94
|
+
char_count = 0
|
|
95
|
+
line_count = 1
|
|
96
|
+
|
|
97
|
+
if current_start is None:
|
|
98
|
+
current_start = word_obj.start
|
|
99
|
+
|
|
100
|
+
current_words.append(word_obj.word)
|
|
101
|
+
current_end = word_obj.end
|
|
102
|
+
char_count += len(word)
|
|
103
|
+
|
|
104
|
+
is_terminal = any(word.endswith(p) for p in self.terminal_punctuation)
|
|
105
|
+
|
|
106
|
+
# If appending this word exceeds the line length, wrap BEFORE adding it
|
|
107
|
+
if char_count > self.max_chars_per_line and line_count < self.max_lines:
|
|
108
|
+
# Insert newline before the current word
|
|
109
|
+
current_words.pop() # Remove the word we just added
|
|
110
|
+
current_words.append("\n")
|
|
111
|
+
current_words.append(word_obj.word.lstrip())
|
|
112
|
+
char_count = len(word)
|
|
113
|
+
line_count += 1
|
|
114
|
+
|
|
115
|
+
is_too_long = char_count >= self.max_chars_per_line
|
|
116
|
+
|
|
117
|
+
if is_terminal or (is_too_long and line_count >= self.max_lines):
|
|
118
|
+
self._flush_words(current_words, current_start, current_end)
|
|
119
|
+
current_words = []
|
|
120
|
+
current_start = None
|
|
121
|
+
char_count = 0
|
|
122
|
+
line_count = 1
|
|
123
|
+
|
|
124
|
+
if current_words and current_start is not None:
|
|
125
|
+
self._flush_words(current_words, current_start, current_end)
|
|
126
|
+
|
|
127
|
+
def _flush_words(self, words: list[str], start_time: float, end_time: float) -> None:
|
|
128
|
+
"""Write the aggregated words to the block list."""
|
|
129
|
+
text = "".join(words).strip()
|
|
130
|
+
if text:
|
|
131
|
+
start_str = _format_timestamp(start_time)
|
|
132
|
+
end_str = _format_timestamp(end_time)
|
|
133
|
+
self._srt_blocks.append(f"{self._block_idx}\n{start_str} --> {end_str}\n{text}\n")
|
|
134
|
+
self._block_idx += 1
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class AtomicWriter:
|
|
138
|
+
"""Handles cross-device POSIX atomic file writing and metadata inheritance."""
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def write_srt(source_video: Path, srt_content: str, language_code: str = "en") -> Path:
|
|
142
|
+
"""Write the SRT securely, inheriting the permissions of the source video.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
source_video: The original MKV/MP4 file.
|
|
146
|
+
srt_content: The fully formatted SRT text block.
|
|
147
|
+
language_code: The locale suffix for the subtitle (e.g., 'en', 'eng').
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The Path to the finalized, atomically committed SRT file.
|
|
151
|
+
"""
|
|
152
|
+
final_srt_path = source_video.with_suffix(f".{language_code}.srt")
|
|
153
|
+
temp_srt_path = source_video.with_name(f".{source_video.stem}.srt.tmp")
|
|
154
|
+
|
|
155
|
+
logger.debug(f"Assembling atomic SRT chunks in {temp_srt_path}")
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
# 1. Write to hidden temp file in the same directory
|
|
159
|
+
temp_srt_path.write_text(srt_content, encoding="utf-8")
|
|
160
|
+
|
|
161
|
+
# 2. Inherit metadata from the source video
|
|
162
|
+
stat = source_video.stat()
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
os.chown(temp_srt_path, stat.st_uid, stat.st_gid)
|
|
166
|
+
except PermissionError:
|
|
167
|
+
# Running as non-root over SMB/NFS might restrict chown
|
|
168
|
+
logger.debug(
|
|
169
|
+
f"Insufficient permissions to chown {temp_srt_path} to "
|
|
170
|
+
f"UID:{stat.st_uid}/GID:{stat.st_gid}. Proceeding anyway."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
os.chmod(temp_srt_path, stat.st_mode)
|
|
175
|
+
except PermissionError:
|
|
176
|
+
logger.debug(f"Insufficient permissions to chmod {temp_srt_path}")
|
|
177
|
+
|
|
178
|
+
# 3. Cross-device safe Atomic Rename
|
|
179
|
+
# os.replace is atomic on POSIX if both files are on the same filesystem.
|
|
180
|
+
# We write the temp file in the same folder to guarantee this and prevent EXDEV errors.
|
|
181
|
+
os.replace(temp_srt_path, final_srt_path)
|
|
182
|
+
logger.info(f"Successfully generated and committed {final_srt_path.name}")
|
|
183
|
+
return final_srt_path
|
|
184
|
+
|
|
185
|
+
except Exception as e:
|
|
186
|
+
# Clean up the temp file if the atomic commit fails
|
|
187
|
+
if temp_srt_path.exists():
|
|
188
|
+
try:
|
|
189
|
+
temp_srt_path.unlink()
|
|
190
|
+
except OSError:
|
|
191
|
+
pass
|
|
192
|
+
raise RuntimeError(f"Atomic subtitle write failed for {source_video.name}: {e}") from e
|