BiliSakura commited on
Commit
15f6bf4
·
1 Parent(s): 31a02eb

Update SRT Processing Tool - Convert to Gradio for HF Spaces

Browse files
Files changed (6) hide show
  1. .gitignore +196 -0
  2. README.md +157 -7
  3. app.py +302 -0
  4. requirements.txt +3 -0
  5. tools/__init__.py +1 -0
  6. tools/srt_processor.py +586 -0
.gitignore ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ output
2
+
3
+ # Byte-compiled / optimized / DLL files
4
+ __pycache__/
5
+ *.py[cod]
6
+ *$py.class
7
+
8
+ # C extensions
9
+ *.so
10
+
11
+ # Distribution / packaging
12
+ .Python
13
+ build/
14
+ develop-eggs/
15
+ dist/
16
+ downloads/
17
+ eggs/
18
+ .eggs/
19
+ lib/
20
+ lib64/
21
+ parts/
22
+ sdist/
23
+ var/
24
+ wheels/
25
+ share/python-wheels/
26
+ *.egg-info/
27
+ .installed.cfg
28
+ *.egg
29
+ MANIFEST
30
+
31
+ # PyInstaller
32
+ # Usually these files are written by a python script from a template
33
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
34
+ *.manifest
35
+ *.spec
36
+
37
+ # Installer logs
38
+ pip-log.txt
39
+ pip-delete-this-directory.txt
40
+
41
+ # Unit test / coverage reports
42
+ htmlcov/
43
+ .tox/
44
+ .nox/
45
+ .coverage
46
+ .coverage.*
47
+ .cache
48
+ nosetests.xml
49
+ coverage.xml
50
+ *.cover
51
+ *.py,cover
52
+ .hypothesis/
53
+ .pytest_cache/
54
+ cover/
55
+
56
+ # Translations
57
+ *.mo
58
+ *.pot
59
+
60
+ # Django stuff:
61
+ *.log
62
+ local_settings.py
63
+ db.sqlite3
64
+ db.sqlite3-journal
65
+
66
+ # Flask stuff:
67
+ instance/
68
+ .webassets-cache
69
+
70
+ # Scrapy stuff:
71
+ .scrapy
72
+
73
+ # Sphinx documentation
74
+ docs/_build/
75
+
76
+ # PyBuilder
77
+ .pybuilder/
78
+ target/
79
+
80
+ # Jupyter Notebook
81
+ .ipynb_checkpoints
82
+
83
+ # IPython
84
+ profile_default/
85
+ ipython_config.py
86
+
87
+ # pyenv
88
+ # For a library or package, you might want to ignore these files since the code is
89
+ # intended to run in multiple environments; otherwise, check them in:
90
+ # .python-version
91
+
92
+ # pipenv
93
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
94
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
95
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
96
+ # install all needed dependencies.
97
+ #Pipfile.lock
98
+
99
+ # UV
100
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
101
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
102
+ # commonly ignored for libraries.
103
+ #uv.lock
104
+
105
+ # poetry
106
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
107
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
108
+ # commonly ignored for libraries.
109
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
110
+ #poetry.lock
111
+
112
+ # pdm
113
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
114
+ #pdm.lock
115
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
116
+ # in version control.
117
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
118
+ .pdm.toml
119
+ .pdm-python
120
+ .pdm-build/
121
+
122
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
123
+ __pypackages__/
124
+
125
+ # Celery stuff
126
+ celerybeat-schedule
127
+ celerybeat.pid
128
+
129
+ # SageMath parsed files
130
+ *.sage.py
131
+
132
+ # Environments
133
+ .env
134
+ .venv
135
+ env/
136
+ venv/
137
+ ENV/
138
+ env.bak/
139
+ venv.bak/
140
+
141
+ # Spyder project settings
142
+ .spyderproject
143
+ .spyproject
144
+
145
+ # Rope project settings
146
+ .ropeproject
147
+
148
+ # mkdocs documentation
149
+ /site
150
+
151
+ # mypy
152
+ .mypy_cache/
153
+ .dmypy.json
154
+ dmypy.json
155
+
156
+ # Pyre type checker
157
+ .pyre/
158
+
159
+ # pytype static type analyzer
160
+ .pytype/
161
+
162
+ # Cython debug symbols
163
+ cython_debug/
164
+
165
+ # PyCharm
166
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
167
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
168
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
169
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
170
+ #.idea/
171
+
172
+ # Abstra
173
+ # Abstra is an AI-powered process automation framework.
174
+ # Ignore directories containing user credentials, local state, and settings.
175
+ # Learn more at https://abstra.io/docs
176
+ .abstra/
177
+
178
+ # Visual Studio Code
179
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
180
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
181
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
182
+ # you could uncomment the following to ignore the enitre vscode folder
183
+ # .vscode/
184
+
185
+ # Ruff stuff:
186
+ .ruff_cache/
187
+
188
+ # PyPI configuration file
189
+ .pypirc
190
+
191
+ # Cursor
192
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
193
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
194
+ # refer to https://docs.cursor.com/context/ignore-files
195
+ .cursorignore
196
+ .cursorindexingignore
README.md CHANGED
@@ -1,14 +1,164 @@
1
  ---
2
  title: SRT Processing Tool
3
- emoji:
4
- colorFrom: green
5
- colorTo: gray
6
  sdk: gradio
7
- sdk_version: 6.4.0
8
  app_file: app.py
9
  pinned: false
10
- license: apache-2.0
11
- short_description: A production-ready web application for processing SRT subtit
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: SRT Processing Tool
3
+ emoji: 🎬
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 4.0.0
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
 
11
  ---
12
 
13
+ # 🎬 SRT Processing Tool
14
+
15
+ A production-ready web application for processing SRT subtitle files, powered by Gradio and ready for Hugging Face Spaces.
16
+
17
+ **Resegment and translate your subtitle files easily in your browser!**
18
+
19
+ ## ✨ Features
20
+
21
+ - **🔄 SRT Resegmentation**: Optimize subtitle segments by character limits, respecting punctuation boundaries
22
+ - **🌍 SRT Translation**: Translate subtitle files using AI (OpenAI, Aliyun DashScope, or OpenRouter)
23
+ - **⚡ Automatic Resegmentation**: Translation automatically includes resegmentation for optimal chunk sizes
24
+ - **🚀 Production Ready**: Optimized for Hugging Face Spaces deployment
25
+
26
+ ## 🚀 Live Demo
27
+
28
+ **Try it live:** [https://huggingface.co/spaces/BiliSakura/SRT-Processing-Tool](https://huggingface.co/spaces/BiliSakura/SRT-Processing-Tool)
29
+
30
+ This app is deployed on Hugging Face Spaces! To deploy your own version:
31
+
32
+ 1. Fork this repository
33
+ 2. Go to [Hugging Face Spaces](https://huggingface.co/spaces)
34
+ 3. Create a new Space
35
+ 4. Connect your GitHub repository
36
+ 5. Select Gradio as the SDK
37
+ 6. Set the app file to `app.py`
38
+ 7. Add your API keys as secrets (see below)
39
+ 8. Deploy!
40
+
41
+ ## 🔑 API Keys Configuration
42
+
43
+ For translation features, add your API keys as secrets in Hugging Face Spaces:
44
+
45
+ 1. Go to your Space settings
46
+ 2. Navigate to "Variables and secrets"
47
+ 3. Add the following secrets:
48
+
49
+ ### Required Secrets (choose based on provider):
50
+
51
+ - **Aliyun DashScope**: `DASHSCOPE_API_KEY`
52
+ - **OpenAI**: `OPENAI_API_KEY`
53
+ - **OpenRouter**: `OPENROUTER_API_KEY`
54
+
55
+ ### Optional Secrets (for OpenRouter attribution):
56
+
57
+ - `OPENROUTER_SITE_URL` (maps to `HTTP-Referer`)
58
+ - `OPENROUTER_APP_TITLE` (maps to `X-Title`)
59
+
60
+ ## 📦 Local Installation
61
+
62
+ ```bash
63
+ # Clone the repository
64
+ git clone https://huggingface.co/spaces/BiliSakura/SRT-Processing-Tool
65
+ cd SRT-Processing-Tool
66
+
67
+ # Create virtual environment
68
+ python -m venv venv
69
+ source venv/bin/activate # On Windows: venv\Scripts\activate
70
+
71
+ # Install dependencies
72
+ pip install -r requirements.txt
73
+ ```
74
+
75
+ ## 🏃 Local Run
76
+
77
+ ```bash
78
+ python app.py
79
+ ```
80
+
81
+ The app will be available at `http://localhost:7860`
82
+
83
+ ## 📖 Usage
84
+
85
+ 1. Open the app in your browser
86
+ 2. Upload your SRT file
87
+ 3. Choose operation:
88
+ - **Translate only**: Translate subtitles to target language
89
+ - **Resegment only**: Optimize subtitle segments by character limits
90
+ 4. Configure settings:
91
+ - **Translation Settings**: Target language, provider, model, workers
92
+ - **Resegmentation Settings**: Maximum characters per segment
93
+ 5. Click "🚀 Process SRT File"
94
+ 6. Download your processed file!
95
+
96
+ ## 🔧 Configuration
97
+
98
+ ### Default Models
99
+
100
+ - **OpenAI**: `gpt-4.1` (uses Responses API)
101
+ - **Aliyun DashScope**: `qwen-max`
102
+ - **OpenRouter**: `openai/gpt-4o`
103
+
104
+ ### Environment Variables
105
+
106
+ You can also use a `.env` file for local development:
107
+
108
+ ```env
109
+ # Aliyun DashScope
110
+ DASHSCOPE_API_KEY=your_key_here
111
+
112
+ # OpenAI
113
+ OPENAI_API_KEY=your_key_here
114
+
115
+ # OpenRouter
116
+ OPENROUTER_API_KEY=your_key_here
117
+ OPENROUTER_SITE_URL=https://your-site.com
118
+ OPENROUTER_APP_TITLE=Your App Title
119
+
120
+ # Optional: override model for all providers
121
+ MODEL=your_model_name
122
+ ```
123
+
124
+ ## 💻 CLI Usage
125
+
126
+ You can also use the SRT processor from the command line:
127
+
128
+ ```bash
129
+ # Resegment only
130
+ python tools/srt_processor.py input.srt output.srt --operation resegment --max-chars 125
131
+
132
+ # Translate (OpenAI)
133
+ python tools/srt_processor.py input.srt output.srt --operation translate --target-lang zh --provider openai --model gpt-4.1 --workers 5
134
+
135
+ # Translate (OpenRouter)
136
+ python tools/srt_processor.py input.srt output.srt --operation translate --target-lang zh --provider openrouter --model openai/gpt-4o --workers 5
137
+
138
+ # Translate (DashScope)
139
+ python tools/srt_processor.py input.srt output.srt --operation translate --target-lang zh --provider dashscope --model qwen-max --workers 5
140
+ ```
141
+
142
+ ## 🏗️ Project Structure
143
+
144
+ ```
145
+ .
146
+ ├── app.py # Main Gradio application
147
+ ├── tools/
148
+ │ ├── __init__.py
149
+ │ └── srt_processor.py # Core SRT processing logic
150
+ ├── requirements.txt # Python dependencies
151
+ └── README.md # This file
152
+ ```
153
+
154
+ ## 📝 License
155
+
156
+ MIT License
157
+
158
+ ## 🤝 Contributing
159
+
160
+ Contributions are welcome! Please feel free to submit a Pull Request.
161
+
162
+ ---
163
+
164
+ **Made with ❤️ for subtitle processing**
app.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SRT Processing Tool - Gradio Interface
3
+ Production-ready for Hugging Face Spaces
4
+ """
5
+
6
+ import os
7
+ import tempfile
8
+ import gradio as gr
9
+ from tools import process_srt_file
10
+ from dotenv import load_dotenv
11
+
12
+ # Load environment variables from .env if present
13
+ load_dotenv(override=True)
14
+
15
+
16
+ def process_srt_interface(
17
+ file_path,
18
+ operation,
19
+ target_lang,
20
+ provider,
21
+ model,
22
+ workers,
23
+ max_chars,
24
+ ):
25
+ """
26
+ Process SRT file based on user inputs.
27
+
28
+ Args:
29
+ file_path: Path to uploaded file from Gradio
30
+ operation: "translate" or "resegment"
31
+ target_lang: Target language code (for translation)
32
+ provider: Translation provider ("Aliyun (DashScope)", "OpenAI", "OpenRouter")
33
+ model: Model name (optional)
34
+ workers: Number of concurrent workers
35
+ max_chars: Maximum characters per segment
36
+
37
+ Returns:
38
+ Tuple of (output_file_path, success_message)
39
+ """
40
+ if file_path is None:
41
+ return None, "❌ Please upload an SRT file first."
42
+
43
+ try:
44
+ # Map provider names to internal router values
45
+ provider_map = {
46
+ "Aliyun (DashScope)": "dashscope",
47
+ "OpenAI": "openai",
48
+ "OpenRouter": "openrouter",
49
+ }
50
+ router = provider_map.get(provider, "dashscope")
51
+
52
+ # Map operation names to internal values
53
+ operation_map = {
54
+ "Translate only": "translate",
55
+ "Resegment only": "resegment",
56
+ }
57
+ operation_value = operation_map.get(operation, "resegment")
58
+
59
+ # Validate inputs
60
+ if operation_value == "translate" and not target_lang:
61
+ return None, "❌ Target language is required for translation."
62
+
63
+ # Use the uploaded file path directly
64
+ temp_input_path = file_path
65
+
66
+ # Create temporary output file
67
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".srt") as temp_output:
68
+ temp_output_path = temp_output.name
69
+
70
+ # Process the file
71
+ process_srt_file(
72
+ temp_input_path,
73
+ temp_output_path,
74
+ operation=operation_value,
75
+ max_chars=int(max_chars),
76
+ target_lang=target_lang if operation_value == "translate" else None,
77
+ model=model if model else None,
78
+ workers=int(workers),
79
+ router=router,
80
+ )
81
+
82
+ # Generate output filename
83
+ input_filename = os.path.splitext(os.path.basename(file_path))[0]
84
+ if operation_value == "translate":
85
+ output_filename = f"{input_filename}_{target_lang}.srt"
86
+ else:
87
+ output_filename = f"{input_filename}_resentenced.srt"
88
+
89
+ # Read the output file and create download file
90
+ with open(temp_output_path, "r", encoding="utf-8") as f:
91
+ output_content = f.read()
92
+
93
+ # Create a temporary file for download with proper name
94
+ download_dir = tempfile.gettempdir()
95
+ download_path = os.path.join(download_dir, output_filename)
96
+ with open(download_path, "w", encoding="utf-8") as download_file:
97
+ download_file.write(output_content)
98
+
99
+ # Clean up temporary output file
100
+ try:
101
+ os.remove(temp_output_path)
102
+ except Exception:
103
+ pass
104
+
105
+ success_msg = f"✅ Processing complete! ({operation})"
106
+ return download_path, success_msg
107
+
108
+ except Exception as e:
109
+ # Clean up on error
110
+ try:
111
+ if "temp_output_path" in locals():
112
+ os.remove(temp_output_path)
113
+ except Exception:
114
+ pass
115
+ return None, f"❌ Processing failed: {str(e)}"
116
+
117
+
118
+ def create_interface():
119
+ """Create and configure the Gradio interface."""
120
+
121
+ with gr.Blocks(title="SRT Processing Tool", theme=gr.themes.Soft()) as app:
122
+ gr.Markdown(
123
+ """
124
+ # 🎬 SRT Processing Tool
125
+
126
+ Process and translate your subtitle files with AI-powered tools!
127
+
128
+ **Features:**
129
+ - 🔄 **Resegment** SRT files to optimize character limits per segment
130
+ - 🌍 **Translate** SRT files using AI (OpenAI, Aliyun DashScope, or OpenRouter)
131
+ - ⚡ **Automatic Resegmentation**: Translation automatically includes resegmentation for optimal chunk sizes
132
+ """
133
+ )
134
+
135
+ with gr.Row():
136
+ with gr.Column(scale=1):
137
+ gr.Markdown("### 📤 Upload & Settings")
138
+
139
+ uploaded_file = gr.File(
140
+ label="Upload SRT File",
141
+ file_types=[".srt"],
142
+ type="filepath",
143
+ )
144
+
145
+ operation = gr.Radio(
146
+ label="Processing Operation",
147
+ choices=["Translate only", "Resegment only"],
148
+ value="Translate only",
149
+ info="Choose what operation to perform on the SRT file",
150
+ )
151
+
152
+ with gr.Accordion("Translation Settings", open=True, visible=True) as translation_accordion:
153
+ target_lang = gr.Textbox(
154
+ label="Target Language Code",
155
+ placeholder="e.g., fr, es, de, zh",
156
+ value="zh",
157
+ info="ISO language code for translation",
158
+ )
159
+
160
+ provider = gr.Dropdown(
161
+ label="Translation Provider",
162
+ choices=["Aliyun (DashScope)", "OpenAI", "OpenRouter"],
163
+ value="Aliyun (DashScope)",
164
+ info="Choose the translation provider",
165
+ )
166
+
167
+ model = gr.Textbox(
168
+ label="Model Name",
169
+ placeholder="Leave blank for default",
170
+ value="qwen-max",
171
+ info="Model to use (defaults: qwen-max for DashScope, gpt-4.1 for OpenAI, openai/gpt-4o for OpenRouter)",
172
+ )
173
+
174
+ workers = gr.Slider(
175
+ label="Concurrent Workers",
176
+ minimum=1,
177
+ maximum=50,
178
+ value=25,
179
+ step=1,
180
+ info="Number of parallel translation requests",
181
+ )
182
+
183
+ with gr.Accordion("Resegmentation Settings", open=True) as resegment_accordion:
184
+ max_chars = gr.Slider(
185
+ label="Maximum Characters per Segment",
186
+ minimum=10,
187
+ maximum=500,
188
+ value=125,
189
+ step=5,
190
+ info="Controls how the SRT is resegmented before translation",
191
+ )
192
+
193
+ process_btn = gr.Button("🚀 Process SRT File", variant="primary", size="lg")
194
+
195
+ info_box = gr.Markdown(
196
+ """
197
+ **ℹ️ Note:** Translation automatically includes resegmentation for optimal chunk sizes.
198
+
199
+ **API Keys:** Set these as secrets in Hugging Face Spaces:
200
+ - `DASHSCOPE_API_KEY` for Aliyun DashScope
201
+ - `OPENAI_API_KEY` for OpenAI
202
+ - `OPENROUTER_API_KEY` for OpenRouter
203
+ """
204
+ )
205
+
206
+ with gr.Column(scale=1):
207
+ gr.Markdown("### 📥 Results")
208
+
209
+ status_output = gr.Textbox(
210
+ label="Status",
211
+ interactive=False,
212
+ value="Waiting for file upload...",
213
+ )
214
+
215
+ output_file = gr.File(
216
+ label="Download Processed SRT",
217
+ visible=False,
218
+ )
219
+
220
+ # Update UI visibility based on operation
221
+ def update_ui(selected_operation):
222
+ """Update UI components visibility based on selected operation."""
223
+ if selected_operation == "Translate only":
224
+ return (
225
+ gr.update(visible=True, open=True), # translation_accordion
226
+ gr.update(visible=True, open=True), # resegment_accordion
227
+ gr.update(value="qwen-max"), # model default
228
+ )
229
+ else: # Resegment only
230
+ return (
231
+ gr.update(visible=False), # translation_accordion
232
+ gr.update(visible=True, open=True), # resegment_accordion
233
+ gr.update(value=""), # model empty
234
+ )
235
+
236
+ operation.change(
237
+ fn=update_ui,
238
+ inputs=[operation],
239
+ outputs=[translation_accordion, resegment_accordion, model],
240
+ )
241
+
242
+ # Update model placeholder based on provider
243
+ def update_model_placeholder(selected_provider):
244
+ """Update model placeholder text based on provider."""
245
+ defaults = {
246
+ "Aliyun (DashScope)": "qwen-max",
247
+ "OpenAI": "gpt-4.1",
248
+ "OpenRouter": "openai/gpt-4o",
249
+ }
250
+ return gr.update(value=defaults.get(selected_provider, ""))
251
+
252
+ provider.change(
253
+ fn=update_model_placeholder,
254
+ inputs=[provider],
255
+ outputs=[model],
256
+ )
257
+
258
+ # Process button click handler
259
+ def handle_process(file_path, op, lang, prov, mod, wrk, chars):
260
+ """Handle the process button click."""
261
+ result_file, message = process_srt_interface(
262
+ file_path, op, lang, prov, mod, wrk, chars
263
+ )
264
+
265
+ if result_file:
266
+ return (
267
+ gr.update(value=message, visible=True),
268
+ gr.update(value=result_file, visible=True, label=f"Download: {os.path.basename(result_file)}")
269
+ )
270
+ else:
271
+ return (
272
+ gr.update(value=message, visible=True),
273
+ gr.update(visible=False)
274
+ )
275
+
276
+ process_btn.click(
277
+ fn=handle_process,
278
+ inputs=[uploaded_file, operation, target_lang, provider, model, workers, max_chars],
279
+ outputs=[status_output, output_file],
280
+ )
281
+
282
+ # Update status when file is uploaded
283
+ uploaded_file.change(
284
+ fn=lambda x: gr.update(value="✅ File uploaded! Configure settings and click 'Process SRT File'.") if x else gr.update(value="Waiting for file upload..."),
285
+ inputs=[uploaded_file],
286
+ outputs=[status_output],
287
+ )
288
+
289
+ return app
290
+
291
+
292
+ # Create the Gradio interface
293
+ demo = create_interface()
294
+
295
+ # For Hugging Face Spaces, expose the demo variable
296
+ # For local development, launch the app
297
+ if __name__ == "__main__":
298
+ demo.launch(
299
+ server_name="0.0.0.0",
300
+ server_port=7860,
301
+ share=False,
302
+ )
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio>=4.0.0
2
+ openai>=1.0.0
3
+ python-dotenv>=1.0.0
tools/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from .srt_processor import translate_srt, resegment_srt, process_srt_file
tools/srt_processor.py ADDED
@@ -0,0 +1,586 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unified SRT processing module combining resegmentation and translation functionality.
3
+ """
4
+
5
+ import os
6
+ import re
7
+ import concurrent.futures
8
+ from typing import List, Tuple, Optional
9
+ from dotenv import load_dotenv
10
+ from openai import OpenAI
11
+
12
+ # Load environment variables from .env if present
13
+ load_dotenv(override=True)
14
+
15
+
16
+ # ============================================================================
17
+ # Core SRT Utilities
18
+ # ============================================================================
19
+
20
+
21
+ def read_srt(file_path: str) -> str:
22
+ """Read SRT file content."""
23
+ with open(file_path, "r", encoding="utf-8") as f:
24
+ return f.read()
25
+
26
+
27
+ def write_srt(file_path: str, content: str) -> None:
28
+ """Write content to SRT file."""
29
+ with open(file_path, "w", encoding="utf-8") as f:
30
+ f.write(content)
31
+
32
+
33
+ def parse_srt_blocks(srt_content: str) -> List[Tuple[str, str, List[str]]]:
34
+ """
35
+ Parse SRT content into blocks.
36
+ Returns list of (index, time, text_lines).
37
+ """
38
+ blocks = re.split(r"\n\s*\n", srt_content.strip(), flags=re.MULTILINE)
39
+ parsed: List[Tuple[str, str, List[str]]] = []
40
+ for block in blocks:
41
+ lines = block.strip().splitlines()
42
+ if len(lines) < 3:
43
+ continue
44
+ index = lines[0].strip()
45
+ time_line = lines[1].strip()
46
+ text_lines = [line.rstrip() for line in lines[2:]]
47
+ parsed.append((index, time_line, text_lines))
48
+ return parsed
49
+
50
+
51
+ def parse_srt_block(block: str) -> Optional[Tuple[str, str, List[str]]]:
52
+ """Parse a single SRT block."""
53
+ lines = block.strip().splitlines()
54
+ if len(lines) < 3:
55
+ return None
56
+ index = lines[0]
57
+ time = lines[1]
58
+ text_lines = lines[2:]
59
+ return index, time, text_lines
60
+
61
+
62
+ def build_srt_block(index: int, start_time: str, end_time: str, text: str) -> str:
63
+ """Build SRT block with index, time range, and text."""
64
+ return f"{index}\n{start_time} --> {end_time}\n{text}"
65
+
66
+
67
+ def build_srt_block_from_lines(index: str, time: str, text_lines: List[str]) -> str:
68
+ """Build SRT block from parsed components."""
69
+ return f"{index}\n{time}\n" + "\n".join(text_lines)
70
+
71
+
72
+ # ============================================================================
73
+ # Time Utilities
74
+ # ============================================================================
75
+
76
+
77
+ def extract_times(time_line: str) -> Tuple[str, str]:
78
+ """Extract start and end times from time line."""
79
+ # Expected format: HH:MM:SS,mmm --> HH:MM:SS,mmm
80
+ parts = [p.strip() for p in time_line.split("-->")]
81
+ if len(parts) != 2:
82
+ raise ValueError(f"Invalid time line: {time_line}")
83
+ return parts[0], parts[1]
84
+
85
+
86
+ def time_str_to_ms(t: str) -> int:
87
+ """Convert time string to milliseconds."""
88
+ # HH:MM:SS,mmm
89
+ hms, ms = t.split(",")
90
+ hours, minutes, seconds = hms.split(":")
91
+ total_ms = (
92
+ int(hours) * 3600 * 1000
93
+ + int(minutes) * 60 * 1000
94
+ + int(seconds) * 1000
95
+ + int(ms)
96
+ )
97
+ return total_ms
98
+
99
+
100
+ def ms_to_time_str(ms: int) -> str:
101
+ """Convert milliseconds to time string."""
102
+ if ms < 0:
103
+ ms = 0
104
+ hours = ms // (3600 * 1000)
105
+ ms %= 3600 * 1000
106
+ minutes = ms // (60 * 1000)
107
+ ms %= 60 * 1000
108
+ seconds = ms // 1000
109
+ millis = ms % 1000
110
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}"
111
+
112
+
113
+ # ============================================================================
114
+ # Text Processing Utilities
115
+ # ============================================================================
116
+
117
+
118
+ def ends_with_preferred_punctuation(text: str) -> bool:
119
+ """Check if text ends with preferred punctuation."""
120
+ stripped = text.rstrip()
121
+ return stripped.endswith(".") or stripped.endswith(",")
122
+
123
+
124
+ def normalize_whitespace(text: str) -> str:
125
+ """Normalize whitespace in text."""
126
+ return re.sub(r"\s+", " ", text).strip()
127
+
128
+
129
+ def count_chars(text: str) -> int:
130
+ """Count characters including spaces after normalization."""
131
+ return len(text)
132
+
133
+
134
+ def split_text_into_chunks_by_chars_with_punctuation(
135
+ text: str, max_chars: int
136
+ ) -> List[str]:
137
+ """Split text into chunks respecting punctuation boundaries."""
138
+ text = normalize_whitespace(text)
139
+ chunks: List[str] = []
140
+ i = 0
141
+ n = len(text)
142
+ while i < n:
143
+ remaining = text[i:]
144
+ if len(remaining) <= max_chars:
145
+ chunks.append(remaining.strip())
146
+ break
147
+ window = remaining[:max_chars]
148
+ # Prefer last '.' or ',' within the window
149
+ last_dot = window.rfind(".")
150
+ last_comma = window.rfind(",")
151
+ cut_at = max(last_dot, last_comma)
152
+ if cut_at != -1:
153
+ end = cut_at + 1
154
+ else:
155
+ # If no punctuation found, look for the last space to avoid cutting words
156
+ last_space = window.rfind(" ")
157
+ if last_space != -1:
158
+ end = last_space
159
+ else:
160
+ # If no space found, we have to cut at max_chars (single long word)
161
+ end = max_chars
162
+ chunk = remaining[:end].strip()
163
+ if chunk:
164
+ chunks.append(chunk)
165
+ i += end
166
+ # Skip any following spaces before next chunk
167
+ while i < n and text[i] == " ":
168
+ i += 1
169
+ return [c for c in chunks if c]
170
+
171
+
172
+ # ============================================================================
173
+ # Translation Functionality
174
+ # ============================================================================
175
+
176
+
177
+ def translate_text(
178
+ text: str, target_lang: str, model: str, router: str = "dashscope"
179
+ ) -> str:
180
+ """Translate text using specified provider."""
181
+ if router == "dashscope":
182
+ client = OpenAI(
183
+ api_key=os.getenv("DASHSCOPE_API_KEY"),
184
+ base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
185
+ )
186
+ prompt = (
187
+ f"Translate the following subtitle text to {target_lang}. "
188
+ "Do not translate timestamps or numbers. Only translate the spoken text. "
189
+ "Return only the translated text, no explanations or formatting.\n\n"
190
+ f"{text}"
191
+ )
192
+ response = client.chat.completions.create(
193
+ model=model,
194
+ messages=[
195
+ {
196
+ "role": "system",
197
+ "content": "You are a helpful assistant that translates subtitles.",
198
+ },
199
+ {"role": "user", "content": prompt},
200
+ ],
201
+ temperature=0.3,
202
+ max_tokens=1024,
203
+ )
204
+ return response.choices[0].message.content.strip()
205
+
206
+ elif router == "openrouter":
207
+ client = OpenAI(
208
+ api_key=os.getenv("OPENROUTER_API_KEY"),
209
+ base_url="https://openrouter.ai/api/v1",
210
+ )
211
+ prompt = (
212
+ f"Translate the following subtitle text to {target_lang}. "
213
+ "Do not translate timestamps or numbers. Only translate the spoken text. "
214
+ "Return only the translated text, no explanations or formatting.\n\n"
215
+ f"{text}"
216
+ )
217
+ # Optional attribution headers
218
+ extra_headers = {}
219
+ referer = os.getenv("OPENROUTER_SITE_URL")
220
+ app_title = os.getenv("OPENROUTER_APP_TITLE")
221
+ if referer:
222
+ extra_headers["HTTP-Referer"] = referer
223
+ if app_title:
224
+ extra_headers["X-Title"] = app_title
225
+ response = client.chat.completions.create(
226
+ model=model,
227
+ messages=[
228
+ {
229
+ "role": "system",
230
+ "content": "You are a helpful assistant that translates subtitles.",
231
+ },
232
+ {"role": "user", "content": prompt},
233
+ ],
234
+ temperature=0.3,
235
+ max_tokens=1024,
236
+ extra_headers=extra_headers,
237
+ )
238
+ return response.choices[0].message.content.strip()
239
+
240
+ elif router == "openai":
241
+ client = OpenAI()
242
+ prompt = (
243
+ f"Translate the following subtitle text to {target_lang}. "
244
+ "Do not translate timestamps or numbers. Only translate the spoken text. "
245
+ "Return only the translated text, no explanations or formatting.\n\n"
246
+ f"{text}"
247
+ )
248
+ try:
249
+ # Use Responses API for newer models (e.g., gpt-4.1, gpt-4o)
250
+ if model and (model.startswith("gpt-4.1") or model.startswith("gpt-4o")):
251
+ response = client.responses.create(
252
+ model=model,
253
+ input=prompt,
254
+ instructions="You are a helpful assistant that translates subtitles.",
255
+ temperature=0.3,
256
+ max_output_tokens=1024,
257
+ )
258
+ # Prefer helper if available
259
+ try:
260
+ return response.output_text.strip()
261
+ except Exception:
262
+ # Fallback parsing if helper is unavailable
263
+ try:
264
+ segments = []
265
+ if hasattr(response, "output") and response.output:
266
+ for content_item in response.output[0].content:
267
+ text_val = getattr(content_item, "text", None)
268
+ if text_val:
269
+ segments.append(text_val)
270
+ if segments:
271
+ return "\n".join(segments).strip()
272
+ except Exception:
273
+ pass
274
+ return str(response).strip()
275
+ else:
276
+ # Backward compatibility: use Chat Completions for older models
277
+ response = client.chat.completions.create(
278
+ model=model,
279
+ messages=[
280
+ {
281
+ "role": "system",
282
+ "content": "You are a helpful assistant that translates subtitles.",
283
+ },
284
+ {"role": "user", "content": prompt},
285
+ ],
286
+ temperature=0.3,
287
+ max_tokens=1024,
288
+ )
289
+ return response.choices[0].message.content.strip()
290
+ except Exception as e:
291
+ # Last-resort fallback to ensure we return something
292
+ return str(e)
293
+ else:
294
+ return f"Unsupported provider: {router}"
295
+
296
+
297
+ def translate_block(args: Tuple[str, str, str, str]) -> str:
298
+ """Translate a single SRT block."""
299
+ block, target_lang, model, router = args
300
+ parsed = parse_srt_block(block)
301
+ if not parsed:
302
+ return block
303
+ index, time, text_lines = parsed
304
+ text = "\n".join(text_lines)
305
+ if text.strip():
306
+ translated_text = translate_text(text, target_lang, model=model, router=router)
307
+ translated_text_lines = translated_text.splitlines() or [translated_text]
308
+ else:
309
+ translated_text_lines = text_lines
310
+ translated_block = build_srt_block_from_lines(index, time, translated_text_lines)
311
+ return translated_block
312
+
313
+
314
+ def translate_srt(
315
+ input_path: str,
316
+ output_path: str,
317
+ target_lang: str,
318
+ model: Optional[str] = None,
319
+ workers: int = 15,
320
+ router: str = "dashscope",
321
+ max_chars: int = 125,
322
+ ) -> str:
323
+ """Translate SRT file using specified provider with resegmentation."""
324
+ # Check API keys based on router
325
+ if router == "openai":
326
+ api_key = os.getenv("OPENAI_API_KEY")
327
+ if not api_key:
328
+ raise RuntimeError(
329
+ "Error: OPENAI_API_KEY not found in environment variables."
330
+ )
331
+ if not model:
332
+ model = os.getenv("MODEL") or "gpt-4.1"
333
+ elif router == "openrouter":
334
+ openrouter_key = os.getenv("OPENROUTER_API_KEY")
335
+ if not openrouter_key:
336
+ raise RuntimeError(
337
+ "Error: OPENROUTER_API_KEY not found in environment variables."
338
+ )
339
+ if not model:
340
+ model = os.getenv("MODEL") or "openai/gpt-4o"
341
+ elif router == "dashscope":
342
+ dashscope_key = os.getenv("DASHSCOPE_API_KEY")
343
+ if not dashscope_key:
344
+ raise RuntimeError(
345
+ "Error: DASHSCOPE_API_KEY not found in environment variables."
346
+ )
347
+ if not model:
348
+ model = os.getenv("MODEL") or "qwen-max"
349
+ else:
350
+ raise RuntimeError(
351
+ f"Error: Unknown provider '{router}'. Expected one of: openai, openrouter, dashscope."
352
+ )
353
+
354
+ # First resegment the SRT to get optimal chunks for translation
355
+ srt_content = read_srt(input_path)
356
+ parsed_blocks = parse_srt_blocks(srt_content)
357
+ resegmented_blocks = resegment_blocks(parsed_blocks, max_chars)
358
+
359
+ # Now translate the resegmented blocks
360
+ block_args = [(block, target_lang, model, router) for block in resegmented_blocks]
361
+ translated_blocks = []
362
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
363
+ for translated_block in executor.map(translate_block, block_args):
364
+ translated_blocks.append(translated_block)
365
+ translated_content = "\n\n".join(translated_blocks)
366
+ write_srt(output_path, translated_content)
367
+ return output_path
368
+
369
+
370
+ # ============================================================================
371
+ # Resegmentation Functionality
372
+ # ============================================================================
373
+
374
+
375
+ def resegment_blocks(
376
+ parsed_blocks: List[Tuple[str, str, List[str]]], max_chars: int
377
+ ) -> List[str]:
378
+ """Resegment SRT blocks based on character limit."""
379
+ output_blocks: List[str] = []
380
+
381
+ current_index = 1
382
+ group_start_time: str = ""
383
+ group_end_time: str = ""
384
+ group_text_parts: List[str] = []
385
+ group_char_count = 0
386
+
387
+ def flush_group():
388
+ nonlocal current_index, group_start_time, group_end_time, group_text_parts, group_char_count
389
+ if group_char_count > 0 and group_text_parts:
390
+ block_text = normalize_whitespace(" ".join(group_text_parts))
391
+ output_blocks.append(
392
+ build_srt_block(
393
+ current_index, group_start_time, group_end_time, block_text
394
+ )
395
+ )
396
+ current_index += 1
397
+ group_start_time = ""
398
+ group_end_time = ""
399
+ group_text_parts = []
400
+ group_char_count = 0
401
+
402
+ for _, time_line, text_lines in parsed_blocks:
403
+ start_time_str, end_time_str = extract_times(time_line)
404
+ start_ms = time_str_to_ms(start_time_str)
405
+ end_ms = time_str_to_ms(end_time_str)
406
+ duration_ms = max(0, end_ms - start_ms)
407
+
408
+ text = normalize_whitespace(" ".join(text_lines))
409
+ if not text:
410
+ continue
411
+
412
+ this_count = count_chars(text)
413
+
414
+ # If adding this block would exceed the limit, flush the current group first
415
+ if group_char_count > 0 and (group_char_count + this_count) > max_chars:
416
+ flush_group()
417
+
418
+ # If the single block itself exceeds max_chars, split it internally
419
+ if this_count > max_chars:
420
+ # Ensure any pending group is flushed before inserting split pieces
421
+ flush_group()
422
+ sub_texts = split_text_into_chunks_by_chars_with_punctuation(
423
+ text, max_chars
424
+ )
425
+ # Distribute timings proportionally by character count
426
+ total_chars = sum(count_chars(st) for st in sub_texts) or 1
427
+ accumulated_ms = 0
428
+ for idx, st in enumerate(sub_texts):
429
+ chars_in_chunk = count_chars(st) or 1
430
+ # compute chunk duration (last chunk takes remaining to avoid rounding drift)
431
+ if idx < len(sub_texts) - 1:
432
+ chunk_ms = int(duration_ms * (chars_in_chunk / total_chars))
433
+ else:
434
+ chunk_ms = max(0, duration_ms - accumulated_ms)
435
+ chunk_start_ms = start_ms + accumulated_ms
436
+ chunk_end_ms = chunk_start_ms + chunk_ms
437
+ accumulated_ms += chunk_ms
438
+
439
+ output_blocks.append(
440
+ build_srt_block(
441
+ current_index,
442
+ ms_to_time_str(chunk_start_ms),
443
+ ms_to_time_str(chunk_end_ms),
444
+ st,
445
+ )
446
+ )
447
+ current_index += 1
448
+ # Done with this overlong block
449
+ continue
450
+
451
+ # Otherwise, safe to merge this whole block into the group
452
+ if group_char_count == 0:
453
+ group_start_time = start_time_str
454
+ group_text_parts.append(text)
455
+ group_end_time = end_time_str
456
+ group_char_count += this_count
457
+
458
+ # Prefer flushing on punctuation at the end of this block
459
+ if ends_with_preferred_punctuation(text):
460
+ flush_group()
461
+ elif group_char_count >= max_chars:
462
+ flush_group()
463
+
464
+ # Flush any remaining group
465
+ if group_char_count > 0:
466
+ flush_group()
467
+
468
+ return output_blocks
469
+
470
+
471
+ def resegment_srt(input_path: str, output_path: str, max_chars: int = 125) -> str:
472
+ """Resegment SRT file based on character limit."""
473
+ srt_content = read_srt(input_path)
474
+ parsed = parse_srt_blocks(srt_content)
475
+ merged_blocks = resegment_blocks(parsed, max_chars=max_chars)
476
+ output_content = "\n\n".join(merged_blocks) + "\n"
477
+ write_srt(output_path, output_content)
478
+ return output_path
479
+
480
+
481
+ # ============================================================================
482
+ # Combined Processing Functions
483
+ # ============================================================================
484
+
485
+
486
+ def process_srt_file(
487
+ input_path: str,
488
+ output_path: str,
489
+ operation: str = "resegment",
490
+ max_chars: int = 125,
491
+ target_lang: Optional[str] = None,
492
+ model: Optional[str] = None,
493
+ workers: int = 15,
494
+ router: str = "dashscope",
495
+ ) -> str:
496
+ """
497
+ Process SRT file with specified operation.
498
+
499
+ Args:
500
+ input_path: Path to input SRT file
501
+ output_path: Path to output SRT file
502
+ operation: "resegment" or "translate"
503
+ max_chars: Maximum characters per segment (for resegmentation)
504
+ target_lang: Target language code (for translation)
505
+ model: Model to use for translation
506
+ workers: Number of concurrent workers for translation
507
+ router: Translation provider ("dashscope", "openai", "openrouter")
508
+
509
+ Returns:
510
+ Path to output file
511
+ """
512
+ if operation == "resegment":
513
+ return resegment_srt(input_path, output_path, max_chars)
514
+ elif operation == "translate":
515
+ if not target_lang:
516
+ raise ValueError("target_lang is required for translation")
517
+ return translate_srt(
518
+ input_path, output_path, target_lang, model, workers, router, max_chars
519
+ )
520
+ else:
521
+ raise ValueError(
522
+ f"Unknown operation: {operation}. Must be 'resegment' or 'translate'"
523
+ )
524
+
525
+
526
+ # ============================================================================
527
+ # CLI Interface (for backward compatibility)
528
+ # ============================================================================
529
+
530
+ if __name__ == "__main__":
531
+ import argparse
532
+
533
+ parser = argparse.ArgumentParser(
534
+ description="Unified SRT processing tool for resegmentation and translation. Translation automatically includes resegmentation for optimal chunk sizes."
535
+ )
536
+ parser.add_argument("input", help="Input SRT file path")
537
+ parser.add_argument("output", help="Output SRT file path")
538
+ parser.add_argument(
539
+ "--operation",
540
+ choices=["resegment", "translate"],
541
+ default="resegment",
542
+ help="Operation to perform (default: resegment)",
543
+ )
544
+ parser.add_argument(
545
+ "--max-chars",
546
+ dest="max_chars",
547
+ type=int,
548
+ default=125,
549
+ help="Maximum characters per segment (default: 125)",
550
+ )
551
+ parser.add_argument(
552
+ "--target-lang", help="Target language code (e.g., fr, es, de, zh)"
553
+ )
554
+ parser.add_argument(
555
+ "--model", help="Model to use for translation (default: value of MODEL in .env)"
556
+ )
557
+ parser.add_argument(
558
+ "--workers",
559
+ type=int,
560
+ default=25,
561
+ help="Number of concurrent workers for translation (default: 25)",
562
+ )
563
+ parser.add_argument(
564
+ "--provider",
565
+ choices=["openai", "dashscope", "openrouter"],
566
+ default="dashscope",
567
+ help="Translation provider (default: dashscope)",
568
+ )
569
+
570
+ args = parser.parse_args()
571
+
572
+ try:
573
+ result = process_srt_file(
574
+ args.input,
575
+ args.output,
576
+ operation=args.operation,
577
+ max_chars=args.max_chars,
578
+ target_lang=args.target_lang,
579
+ model=args.model,
580
+ workers=args.workers,
581
+ router=args.provider,
582
+ )
583
+ print(f"Processing complete. Output written to {result}")
584
+ except Exception as e:
585
+ print(f"Error: {e}")
586
+ exit(1)