mirror of
https://github.com/yusufipk/better-ai-srt-translation.git
synced 2026-09-11 10:56:18 +00:00
Add Persian target language and fix API key authentication header bugs
This commit is contained in:
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
A powerful, browser-based tool for translating SRT subtitle files using AI. Built for speed, accuracy, and ease of use.
|
A powerful, browser-based tool for translating SRT subtitle files using AI. Built for speed, accuracy, and ease of use.
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 🎯 What Problem Does It Solve?
|
## 🎯 What Problem Does It Solve?
|
||||||
|
|
||||||
Translating subtitles is tedious and expensive:
|
Translating subtitles is tedious and expensive:
|
||||||
@@ -19,54 +17,56 @@ Translating subtitles is tedious and expensive:
|
|||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- **Drag & Drop** - Just drop your SRT file and go
|
- **API Provider Toggle** - Use **Google AI Studio (Gemini API)** directly or connect via **OpenRouter**
|
||||||
- **100 Parallel Requests** - Blazing fast translation
|
- **Drag & Drop** - Just drop your SRT file and start translating
|
||||||
- **Smart Chunking** - Processes 20 subtitle blocks per request for optimal precision
|
- **Smart Split-Sentence Merging** - Automatically merges short trailing subtitle blocks (like single words) into the previous block if they are part of the same sentence, preventing them from flashing on screen too fast and providing the AI with better context.
|
||||||
- **Marker-Based Alignment** - Each subtitle block stays aligned with its timestamp
|
- **Extend Short Subtitles** - Automatically increase display time for fast subtitles with custom safety gaps
|
||||||
- **Custom Instructions** - Add context-specific translation rules (e.g., "Use informal 'sen' instead of formal 'siz'")
|
- **Arabic & Persian Bidirectional Formatting** - Wrap lines in Unicode RLE/PDF embedding tags and automatically replace standard English punctuation with proper RTL punctuation (`،`, `؟`, `؛`) so that video players (like VLC) render text and word order correctly
|
||||||
- **Custom Model Support** - Use any model available on OpenRouter
|
- **Safe Parallel Requests** - Defaults to `5` parallel requests to avoid free tier rate-limiting (429), customizable up to `100` for premium plans
|
||||||
- **Multi-Language** - Translate to 15+ popular languages
|
- **Smart Chunking** - Processes 20 subtitle blocks per request for optimal translation accuracy
|
||||||
- **Real-Time Progress** - Visual feedback for each chunk's status
|
- **Marker-Based Alignment** - Uses custom boundaries so each subtitle block stays aligned with its timestamp
|
||||||
- **Retry with Backoff** - Automatic retry on failures
|
- **Custom Instructions** - Add custom parameters (e.g. "Use informal tone", "Do not translate coding tags")
|
||||||
|
- **Multi-Language** - Translate to 20+ popular languages, including **Persian**
|
||||||
|
- **Real-Time Progress** - Visual feedback for chunk progress, errors, and rate-limit countdown timers
|
||||||
|
- **Smart Quota Retry** - Automatically parses Google's `RetryInfo` on 429 quota errors and counts down the exact cooldown delay before resuming
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
1. Open `index.html` in your browser
|
1. Open `index.html` in your browser (double-click the file)
|
||||||
2. Enter your [OpenRouter API key](https://openrouter.ai/keys)
|
2. Select your **API Provider** (Google AI Studio or OpenRouter) and enter your API Key
|
||||||
3. Drop an SRT file
|
3. Drop an SRT file into the upload zone
|
||||||
4. Select target language
|
4. Select target language (e.g., Persian, Arabic, Spanish)
|
||||||
5. Click "Translate"
|
5. (Optional) Toggle **Merge short split sentences** or **Extend short subtitles**
|
||||||
6. Download your translated SRT
|
6. Click **Translate**
|
||||||
|
7. Click **Download Translated SRT**
|
||||||
|
|
||||||
## ⚙️ Configuration
|
## ⚙️ Configuration
|
||||||
|
|
||||||
| Setting | Default | Description |
|
| Setting | Default | Description |
|
||||||
|---------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
| **Model** | `google/gemini-3-flash-preview` | AI model for translation |
|
| **API Provider** | `Google AI Studio` | Choose direct Gemini API or OpenRouter |
|
||||||
|
| **Model** | `gemini-3.1-flash-lite` | default model ID (`google/gemini-3.1-flash-lite` on OpenRouter) |
|
||||||
| **Chunk Size** | 20 | Number of subtitle blocks per API request |
|
| **Chunk Size** | 20 | Number of subtitle blocks per API request |
|
||||||
| **Parallel Requests** | 100 | Maximum concurrent API calls |
|
| **Parallel Requests** | 5 | Safe concurrent API requests to avoid rate limits (exceeding 15 RPM) |
|
||||||
|
|
||||||
### Recommended Settings
|
### Recommended Settings
|
||||||
|
|
||||||
**Chunk Size: 20** is recommended for better precision. Smaller chunks mean:
|
- **Chunk Size: 20**: Recommended for better precision. Smaller chunks mean less text drift between subtitle blocks and more accurate timing alignment.
|
||||||
- Less text drift between subtitle blocks
|
- **Parallel Requests: 5**: Highly recommended for the Gemini Free Tier to keep your request rate below the 15 requests-per-minute (RPM) quota. If you have a pay-as-you-go key, you can increase this to `50` or `100` for faster speed.
|
||||||
- More accurate timing alignment
|
|
||||||
- Slightly more API calls, but with 100 parallel requests it's still fast
|
|
||||||
|
|
||||||
For the best **performance/cost** balance, we recommend:
|
For the best **performance/cost** balance, we recommend:
|
||||||
|
|
||||||
```
|
```
|
||||||
google/gemini-3-flash-preview
|
gemini-3.1-flash-lite
|
||||||
```
|
```
|
||||||
|
|
||||||
This model offers:
|
This model offers:
|
||||||
- Fast response times
|
- Extremely fast response times
|
||||||
- Excellent instruction following
|
- High instruction following capability
|
||||||
- Great translation quality
|
- Excellent cost-effective translation quality
|
||||||
- Cost-effective pricing
|
|
||||||
|
|
||||||
Other options:
|
Other options:
|
||||||
- `google/gemini-2.5-flash-lite` - Budget option, may have lower accuracy
|
- `gemini-1.5-pro` / `gemini-1.5-flash` - Supported via custom model input field.
|
||||||
|
|
||||||
## 🧠 How It Works
|
## 🧠 How It Works
|
||||||
|
|
||||||
@@ -158,9 +158,9 @@ Use casual, engaging language suitable for YouTube videos.
|
|||||||
|
|
||||||
## 📝 API Requirements
|
## 📝 API Requirements
|
||||||
|
|
||||||
- **Provider:** [OpenRouter](https://openrouter.ai)
|
- **Providers:** [Google AI Studio](https://aistudio.google.com/) (direct API keys) or [OpenRouter](https://openrouter.ai/) (multi-model gateway)
|
||||||
- **API Key:** Get one at https://openrouter.ai/keys
|
- **API Keys:** Get a Gemini API key at [Google AI Studio Keys](https://aistudio.google.com/app/apikey) or OpenRouter API key at [OpenRouter Keys](https://openrouter.ai/keys)
|
||||||
- **Models:** Any chat completion model on OpenRouter
|
- **Models:** Defaults to `gemini-3.1-flash-lite` (Google AI Studio) and `google/gemini-3.1-flash-lite` (OpenRouter). Any custom compatible model is supported.
|
||||||
|
|
||||||
## 🛡️ Privacy
|
## 🛡️ Privacy
|
||||||
|
|
||||||
|
|||||||
+25
-5
@@ -12,15 +12,24 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<h1>SRT Translator</h1>
|
<h1>SRT Translator</h1>
|
||||||
<p class="subtitle">AI-Powered Subtitle Translation via OpenRouter</p>
|
<p class="subtitle">AI-Powered Subtitle Translation via Google AI Studio & OpenRouter</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">API Configuration</div>
|
<div class="card-title">API Configuration</div>
|
||||||
<input type="password" id="apiKey" class="input-field" placeholder="Enter your OpenRouter API key...">
|
<div class="settings-grid" style="grid-template-columns: 1fr; margin-bottom: 0.8rem;">
|
||||||
<div class="model-row">
|
<label style="margin: 0;">
|
||||||
|
<span style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 0.5rem; display: block;">API Provider</span>
|
||||||
|
<select id="apiProvider" class="select-field" style="width: 100%;">
|
||||||
|
<option value="gemini">Google AI Studio (Gemini API)</option>
|
||||||
|
<option value="openrouter">OpenRouter</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input type="password" id="apiKey" class="input-field" placeholder="Enter your Gemini API key...">
|
||||||
|
<div class="model-row" style="margin-top: 0.8rem;">
|
||||||
<input type="text" id="customModel" class="input-field"
|
<input type="text" id="customModel" class="input-field"
|
||||||
placeholder="Custom model (leave empty for default: google/gemini-3-flash-preview)">
|
placeholder="Custom model (leave empty for default: gemini-3.1-flash-lite)">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -42,6 +51,7 @@
|
|||||||
<option value="Chinese (Simplified)">Chinese (Simplified)</option>
|
<option value="Chinese (Simplified)">Chinese (Simplified)</option>
|
||||||
<option value="Chinese (Traditional)">Chinese (Traditional)</option>
|
<option value="Chinese (Traditional)">Chinese (Traditional)</option>
|
||||||
<option value="Arabic">Arabic</option>
|
<option value="Arabic">Arabic</option>
|
||||||
|
<option value="Persian">Persian</option>
|
||||||
<option value="Hindi">Hindi</option>
|
<option value="Hindi">Hindi</option>
|
||||||
<option value="Dutch">Dutch</option>
|
<option value="Dutch">Dutch</option>
|
||||||
<option value="Polish">Polish</option>
|
<option value="Polish">Polish</option>
|
||||||
@@ -58,7 +68,17 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Parallel Requests</span>
|
<span>Parallel Requests</span>
|
||||||
<input type="number" id="parallelRequests" class="input-field" value="100" min="1" max="100">
|
<input type="number" id="parallelRequests" class="input-field" value="5" min="1" max="100">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-row" style="margin-top: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.5rem; color: var(--text-secondary); font-size: 0.9rem; cursor: pointer; user-select: none;">
|
||||||
|
<input type="checkbox" id="optimizeTimings" style="cursor: pointer; width: 1.2rem; height: 1.2rem; border-radius: 4px; accent-color: var(--accent-primary);">
|
||||||
|
<span>Extend short subtitles (increase display time for fast subtitles)</span>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.5rem; color: var(--text-secondary); font-size: 0.9rem; cursor: pointer; user-select: none;">
|
||||||
|
<input type="checkbox" id="mergeSubtitles" style="cursor: pointer; width: 1.2rem; height: 1.2rem; border-radius: 4px; accent-color: var(--accent-primary);">
|
||||||
|
<span>Merge short split sentences (prevent single words from flashing on screen)</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="instruction-section">
|
<div class="instruction-section">
|
||||||
|
|||||||
+350
-31
@@ -2,12 +2,22 @@
|
|||||||
// SRT Translator - App Logic
|
// SRT Translator - App Logic
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
const DEFAULT_MODEL = 'google/gemini-3-flash-preview';
|
const DEFAULT_MODEL_GEMINI = 'gemini-3.1-flash-lite';
|
||||||
const DEFAULT_PARALLEL = 100;
|
const DEFAULT_MODEL_OPENROUTER = 'google/gemini-3.1-flash-lite';
|
||||||
|
const DEFAULT_PARALLEL = 5; // Safe default for Gemini API Free Tier
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 3;
|
||||||
const RETRY_DELAY_MS = 1000; // 1 second
|
const RETRY_DELAY_MS = 1000; // 1 second
|
||||||
const REQUEST_TIMEOUT_MS = 30000; // 30 seconds
|
const REQUEST_TIMEOUT_MS = 30000; // 30 seconds
|
||||||
|
|
||||||
|
// Custom API Error class to propagate status and detailed payload
|
||||||
|
class APIError extends Error {
|
||||||
|
constructor(status, message, details) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let srtContent = '';
|
let srtContent = '';
|
||||||
let srtBlocks = [];
|
let srtBlocks = [];
|
||||||
@@ -36,7 +46,10 @@ const elements = {
|
|||||||
progressPercent: document.getElementById('progressPercent'),
|
progressPercent: document.getElementById('progressPercent'),
|
||||||
totalChunks: document.getElementById('totalChunks'),
|
totalChunks: document.getElementById('totalChunks'),
|
||||||
completedChunks: document.getElementById('completedChunks'),
|
completedChunks: document.getElementById('completedChunks'),
|
||||||
failedChunks: document.getElementById('failedChunks')
|
failedChunks: document.getElementById('failedChunks'),
|
||||||
|
optimizeTimings: document.getElementById('optimizeTimings'),
|
||||||
|
mergeSubtitles: document.getElementById('mergeSubtitles'),
|
||||||
|
apiProvider: document.getElementById('apiProvider')
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -45,33 +58,69 @@ const elements = {
|
|||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
loadSavedSettings();
|
loadSavedSettings();
|
||||||
|
updateProviderUI();
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateProviderUI() {
|
||||||
|
const provider = elements.apiProvider.value;
|
||||||
|
if (provider === 'openrouter') {
|
||||||
|
elements.apiKey.placeholder = 'Enter your OpenRouter API key...';
|
||||||
|
elements.customModel.placeholder = `Custom model (leave empty for default: ${DEFAULT_MODEL_OPENROUTER})`;
|
||||||
|
} else {
|
||||||
|
elements.apiKey.placeholder = 'Enter your Gemini API key...';
|
||||||
|
elements.customModel.placeholder = `Custom model (leave empty for default: ${DEFAULT_MODEL_GEMINI})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadSavedSettings() {
|
function loadSavedSettings() {
|
||||||
const savedApiKey = localStorage.getItem('srt_openrouter_api_key');
|
const savedProvider = localStorage.getItem('srt_api_provider');
|
||||||
|
const savedApiKey = localStorage.getItem('srt_gemini_api_key') || localStorage.getItem('srt_openrouter_api_key');
|
||||||
const savedModel = localStorage.getItem('srt_custom_model');
|
const savedModel = localStorage.getItem('srt_custom_model');
|
||||||
const savedLanguage = localStorage.getItem('srt_target_language');
|
const savedLanguage = localStorage.getItem('srt_target_language');
|
||||||
const savedChunkSize = localStorage.getItem('srt_chunk_size');
|
const savedChunkSize = localStorage.getItem('srt_chunk_size');
|
||||||
const savedParallel = localStorage.getItem('srt_parallel_requests');
|
const savedParallel = localStorage.getItem('srt_parallel_requests');
|
||||||
const savedInstructions = localStorage.getItem('srt_custom_instructions');
|
const savedInstructions = localStorage.getItem('srt_custom_instructions');
|
||||||
|
const savedOptimize = localStorage.getItem('srt_optimize_timings');
|
||||||
|
const savedMerge = localStorage.getItem('srt_merge_subtitles');
|
||||||
|
|
||||||
if (savedApiKey) elements.apiKey.value = savedApiKey;
|
if (savedProvider) elements.apiProvider.value = savedProvider;
|
||||||
if (savedModel) elements.customModel.value = savedModel;
|
else elements.apiProvider.value = 'gemini';
|
||||||
if (savedLanguage) elements.targetLanguage.value = savedLanguage;
|
|
||||||
if (savedChunkSize) elements.chunkSize.value = savedChunkSize;
|
// Load saved API key (check provider-specific keys first)
|
||||||
if (savedParallel) elements.parallelRequests.value = savedParallel;
|
const provider = elements.apiProvider.value;
|
||||||
|
const providerKey = localStorage.getItem(`srt_${provider}_api_key`);
|
||||||
|
if (providerKey) {
|
||||||
|
elements.apiKey.value = providerKey.trim();
|
||||||
|
} else if (savedApiKey) {
|
||||||
|
elements.apiKey.value = savedApiKey.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedModel) elements.customModel.value = savedModel.trim();
|
||||||
|
if (savedLanguage) elements.targetLanguage.value = savedLanguage.trim();
|
||||||
|
if (savedChunkSize) elements.chunkSize.value = savedChunkSize.trim();
|
||||||
|
if (savedParallel) elements.parallelRequests.value = savedParallel.trim();
|
||||||
else elements.parallelRequests.value = DEFAULT_PARALLEL;
|
else elements.parallelRequests.value = DEFAULT_PARALLEL;
|
||||||
if (savedInstructions) elements.customInstructions.value = savedInstructions;
|
if (savedInstructions) elements.customInstructions.value = savedInstructions;
|
||||||
|
|
||||||
|
if (savedOptimize !== null) elements.optimizeTimings.checked = savedOptimize === 'true';
|
||||||
|
else elements.optimizeTimings.checked = false; // Default to false (disabled) to avoid messing up original timings
|
||||||
|
|
||||||
|
if (savedMerge !== null) elements.mergeSubtitles.checked = savedMerge === 'true';
|
||||||
|
else elements.mergeSubtitles.checked = false; // Default to false (disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSettings() {
|
function saveSettings() {
|
||||||
localStorage.setItem('srt_openrouter_api_key', elements.apiKey.value);
|
const provider = elements.apiProvider.value;
|
||||||
localStorage.setItem('srt_custom_model', elements.customModel.value);
|
localStorage.setItem('srt_api_provider', provider);
|
||||||
|
localStorage.setItem(`srt_${provider}_api_key`, elements.apiKey.value.trim());
|
||||||
|
localStorage.setItem('srt_custom_model', elements.customModel.value.trim());
|
||||||
localStorage.setItem('srt_target_language', elements.targetLanguage.value);
|
localStorage.setItem('srt_target_language', elements.targetLanguage.value);
|
||||||
localStorage.setItem('srt_chunk_size', elements.chunkSize.value);
|
localStorage.setItem('srt_chunk_size', elements.chunkSize.value);
|
||||||
localStorage.setItem('srt_parallel_requests', elements.parallelRequests.value);
|
localStorage.setItem('srt_parallel_requests', elements.parallelRequests.value);
|
||||||
localStorage.setItem('srt_custom_instructions', elements.customInstructions.value);
|
localStorage.setItem('srt_custom_instructions', elements.customInstructions.value);
|
||||||
|
localStorage.setItem('srt_optimize_timings', elements.optimizeTimings.checked);
|
||||||
|
localStorage.setItem('srt_merge_subtitles', elements.mergeSubtitles.checked);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -91,6 +140,11 @@ function setupEventListeners() {
|
|||||||
saveSettings();
|
saveSettings();
|
||||||
updateTranslateButton();
|
updateTranslateButton();
|
||||||
});
|
});
|
||||||
|
elements.apiProvider.addEventListener('change', () => {
|
||||||
|
saveSettings();
|
||||||
|
updateProviderUI();
|
||||||
|
updateTranslateButton();
|
||||||
|
});
|
||||||
elements.customModel.addEventListener('change', saveSettings);
|
elements.customModel.addEventListener('change', saveSettings);
|
||||||
elements.targetLanguage.addEventListener('change', () => {
|
elements.targetLanguage.addEventListener('change', () => {
|
||||||
saveSettings();
|
saveSettings();
|
||||||
@@ -99,6 +153,14 @@ function setupEventListeners() {
|
|||||||
elements.chunkSize.addEventListener('change', saveSettings);
|
elements.chunkSize.addEventListener('change', saveSettings);
|
||||||
elements.parallelRequests.addEventListener('change', saveSettings);
|
elements.parallelRequests.addEventListener('change', saveSettings);
|
||||||
elements.customInstructions.addEventListener('change', saveSettings);
|
elements.customInstructions.addEventListener('change', saveSettings);
|
||||||
|
elements.optimizeTimings.addEventListener('change', () => {
|
||||||
|
saveSettings();
|
||||||
|
reprocessLoadedFile();
|
||||||
|
});
|
||||||
|
elements.mergeSubtitles.addEventListener('change', () => {
|
||||||
|
saveSettings();
|
||||||
|
reprocessLoadedFile();
|
||||||
|
});
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
elements.translateBtn.addEventListener('click', startTranslation);
|
elements.translateBtn.addEventListener('click', startTranslation);
|
||||||
@@ -136,15 +198,27 @@ function handleFileSelect(e) {
|
|||||||
// File Processing
|
// File Processing
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
function reprocessLoadedFile() {
|
||||||
|
if (srtContent) {
|
||||||
|
srtBlocks = parseSRT(srtContent);
|
||||||
|
if (elements.mergeSubtitles.checked) {
|
||||||
|
mergeShortSubtitles(srtBlocks);
|
||||||
|
}
|
||||||
|
if (elements.optimizeTimings.checked) {
|
||||||
|
optimizeTimings(srtBlocks);
|
||||||
|
}
|
||||||
|
elements.dropText.textContent = `Loaded: ${srtBlocks.length} subtitle blocks`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function processFile(file) {
|
function processFile(file) {
|
||||||
originalFileName = file.name;
|
originalFileName = file.name;
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
srtContent = e.target.result;
|
srtContent = e.target.result;
|
||||||
srtBlocks = parseSRT(srtContent);
|
reprocessLoadedFile();
|
||||||
|
|
||||||
elements.dropText.textContent = `Loaded: ${srtBlocks.length} subtitle blocks`;
|
|
||||||
elements.fileName.textContent = file.name;
|
elements.fileName.textContent = file.name;
|
||||||
elements.fileName.classList.remove('hidden');
|
elements.fileName.classList.remove('hidden');
|
||||||
|
|
||||||
@@ -236,7 +310,7 @@ function blocksToSRT(blocks) {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
function updateTranslateButton() {
|
function updateTranslateButton() {
|
||||||
elements.translateBtn.disabled = !srtContent || !elements.apiKey.value;
|
elements.translateBtn.disabled = !srtContent || !elements.apiKey.value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTranslateButtonText() {
|
function updateTranslateButtonText() {
|
||||||
@@ -299,6 +373,14 @@ Output:
|
|||||||
|
|
||||||
CRITICAL: [B1] → [B1], [B2] → [B2]. Never shift content.`;
|
CRITICAL: [B1] → [B1], [B2] → [B2]. Never shift content.`;
|
||||||
|
|
||||||
|
if (['Arabic', 'Persian'].includes(targetLanguage)) {
|
||||||
|
prompt += `
|
||||||
|
|
||||||
|
⚠️ IMPORTANT FOR RIGHT-TO-LEFT TRANSLATION:
|
||||||
|
- You MUST use correct RTL punctuation (e.g. use Arabic/Persian comma '،' instead of English ',', and Arabic/Persian question mark '؟' instead of English '?').
|
||||||
|
- Do NOT translate English names, technical terms, or code if they are best kept in English, but ensure they are embedded naturally in the RTL structure.`;
|
||||||
|
}
|
||||||
|
|
||||||
if (customInstructions && customInstructions.trim()) {
|
if (customInstructions && customInstructions.trim()) {
|
||||||
prompt += `\n\n⚠️ MANDATORY USER INSTRUCTIONS (MUST FOLLOW):\n${customInstructions.trim()}\n\nYou MUST follow these instructions exactly. They override any conflicting rules.`;
|
prompt += `\n\n⚠️ MANDATORY USER INSTRUCTIONS (MUST FOLLOW):\n${customInstructions.trim()}\n\nYou MUST follow these instructions exactly. They override any conflicting rules.`;
|
||||||
}
|
}
|
||||||
@@ -353,7 +435,7 @@ function parseMarkedTranslation(blocks, translatedText) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function translateChunk(chunk, index, apiKey, model, targetLanguage, customInstructions) {
|
async function translateChunk(chunk, index, apiKey, model, targetLanguage, customInstructions, provider) {
|
||||||
// Extract text with block markers
|
// Extract text with block markers
|
||||||
const markedText = extractTextWithMarkers(chunk);
|
const markedText = extractTextWithMarkers(chunk);
|
||||||
const systemPrompt = buildSystemPrompt(targetLanguage, customInstructions);
|
const systemPrompt = buildSystemPrompt(targetLanguage, customInstructions);
|
||||||
@@ -368,11 +450,15 @@ async function translateChunk(chunk, index, apiKey, model, targetLanguage, custo
|
|||||||
}, REQUEST_TIMEOUT_MS);
|
}, REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
let response;
|
||||||
|
if (provider === 'openrouter') {
|
||||||
|
response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${apiKey}`
|
'Authorization': `Bearer ${apiKey.trim()}`,
|
||||||
|
'HTTP-Referer': window.location.origin && window.location.origin !== 'null' ? window.location.origin : 'https://github.com/AmiraliNotFound/better-ai-srt-translation',
|
||||||
|
'X-Title': 'Better AI SRT Translation'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: model,
|
model: model,
|
||||||
@@ -384,25 +470,107 @@ async function translateChunk(chunk, index, apiKey, model, targetLanguage, custo
|
|||||||
}),
|
}),
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey.trim()}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [
|
||||||
|
{ text: markedText }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
systemInstruction: {
|
||||||
|
parts: [
|
||||||
|
{ text: systemPrompt }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
generationConfig: {
|
||||||
|
temperature: 0.3
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[Chunk ${index + 1}] Response received, status: ${response.status}`);
|
console.log(`[Chunk ${index + 1}] Response received, status: ${response.status}`);
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.text();
|
const errorText = await response.text();
|
||||||
throw new Error(`API Error: ${response.status} - ${error}`);
|
let details = null;
|
||||||
|
let message = errorText;
|
||||||
|
try {
|
||||||
|
const errorObj = JSON.parse(errorText);
|
||||||
|
if (errorObj.error) {
|
||||||
|
message = errorObj.error.message || errorText;
|
||||||
|
details = errorObj.error.details || null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// not JSON
|
||||||
|
}
|
||||||
|
throw new APIError(response.status, message, details);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log(`[Chunk ${index + 1}] Parsed response, parsing markers...`);
|
console.log(`[Chunk ${index + 1}] Parsed response, parsing markers...`);
|
||||||
const translatedText = data.choices[0].message.content;
|
|
||||||
|
let translatedText;
|
||||||
|
if (provider === 'openrouter') {
|
||||||
|
if (!data.choices || data.choices.length === 0 || !data.choices[0].message || !data.choices[0].message.content) {
|
||||||
|
throw new Error('Invalid or empty response structure from OpenRouter');
|
||||||
|
}
|
||||||
|
translatedText = data.choices[0].message.content;
|
||||||
|
} else {
|
||||||
|
if (!data.candidates || data.candidates.length === 0 || !data.candidates[0].content || !data.candidates[0].content.parts || data.candidates[0].content.parts.length === 0) {
|
||||||
|
throw new Error('Invalid or empty response structure from Gemini API');
|
||||||
|
}
|
||||||
|
translatedText = data.candidates[0].content.parts[0].text;
|
||||||
|
}
|
||||||
|
|
||||||
// Parse markers and map back to blocks
|
// Parse markers and map back to blocks
|
||||||
const translatedBlocks = parseMarkedTranslation(chunk, translatedText);
|
const translatedBlocks = parseMarkedTranslation(chunk, translatedText);
|
||||||
|
|
||||||
// Convert back to SRT format
|
// Convert back to SRT format and handle RTL formatting if target language is RTL (Arabic, Persian)
|
||||||
|
const isRTL = ['Arabic', 'Persian'].includes(targetLanguage);
|
||||||
return translatedBlocks.map(block => {
|
return translatedBlocks.map(block => {
|
||||||
return `${block.index}\n${block.timestamp}\n${block.text.join('\n')}`;
|
let text = block.text.join('\n');
|
||||||
|
if (isRTL) {
|
||||||
|
const rlm = '\u200F';
|
||||||
|
const rle = '\u202B';
|
||||||
|
const pdf = '\u202C';
|
||||||
|
|
||||||
|
// Replace English commas not between digits with Arabic/Persian comma
|
||||||
|
text = text.replace(/(?<!\d),|,(?!\d)/g, '،');
|
||||||
|
// Replace English question mark with Arabic/Persian question mark
|
||||||
|
text = text.replace(/\?/g, '؟');
|
||||||
|
// Replace English semicolon with Arabic/Persian semicolon
|
||||||
|
text = text.replace(/;/g, '؛');
|
||||||
|
|
||||||
|
text = text.split('\n').map(line => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed === '') return line;
|
||||||
|
|
||||||
|
let formattedLine = line;
|
||||||
|
// Wrap line in RLE/PDF and prepend RLM
|
||||||
|
if (!formattedLine.startsWith(rle)) {
|
||||||
|
formattedLine = rle + formattedLine;
|
||||||
|
}
|
||||||
|
if (!formattedLine.endsWith(pdf)) {
|
||||||
|
formattedLine = formattedLine + pdf;
|
||||||
|
}
|
||||||
|
if (!formattedLine.startsWith(rlm)) {
|
||||||
|
formattedLine = rlm + formattedLine;
|
||||||
|
}
|
||||||
|
return formattedLine;
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
return `${block.index}\n${block.timestamp}\n${text}`;
|
||||||
}).join('\n\n');
|
}).join('\n\n');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -415,18 +583,43 @@ async function translateChunk(chunk, index, apiKey, model, targetLanguage, custo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry wrapper with exponential backoff
|
// Retry wrapper with exponential backoff, and smart rate-limit (429) delay handling
|
||||||
async function translateChunkWithRetry(chunk, index, apiKey, model, targetLanguage, customInstructions, onRetry) {
|
async function translateChunkWithRetry(chunk, index, apiKey, model, targetLanguage, customInstructions, provider, onRetry) {
|
||||||
let lastError;
|
let lastError;
|
||||||
|
let maxAttempts = MAX_RETRIES;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
return await translateChunk(chunk, index, apiKey, model, targetLanguage, customInstructions);
|
return await translateChunk(chunk, index, apiKey, model, targetLanguage, customInstructions, provider);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
|
|
||||||
if (attempt < MAX_RETRIES) {
|
if (attempt < maxAttempts) {
|
||||||
const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1); // Exponential backoff
|
let delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1); // Exponential backoff
|
||||||
|
|
||||||
|
// Smart handling of 429 Rate Limits
|
||||||
|
if (error.status === 429) {
|
||||||
|
// Increase max attempts for rate limit retries to give it more chances
|
||||||
|
maxAttempts = Math.max(maxAttempts, 8);
|
||||||
|
|
||||||
|
let retrySeconds = 60; // Default fallback wait is 60 seconds
|
||||||
|
|
||||||
|
// Try to parse retryDelay from Google API error details
|
||||||
|
if (error.details && Array.isArray(error.details)) {
|
||||||
|
const retryInfo = error.details.find(d => d['@type'] === 'type.googleapis.com/google.rpc.RetryInfo');
|
||||||
|
if (retryInfo && retryInfo.retryDelay) {
|
||||||
|
// Format: "50s" or "50.93s"
|
||||||
|
const secondsMatch = retryInfo.retryDelay.match(/^([\d.]+)\s*s$/i);
|
||||||
|
if (secondsMatch) {
|
||||||
|
retrySeconds = parseFloat(secondsMatch[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a small safety buffer (2 seconds)
|
||||||
|
delay = (retrySeconds + 2) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
onRetry(index, attempt, delay, error.message);
|
onRetry(index, attempt, delay, error.message);
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
}
|
}
|
||||||
@@ -441,11 +634,12 @@ async function translateChunkWithRetry(chunk, index, apiKey, model, targetLangua
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
async function startTranslation() {
|
async function startTranslation() {
|
||||||
const apiKey = elements.apiKey.value;
|
const apiKey = elements.apiKey.value.trim();
|
||||||
if (!apiKey || !srtContent) return;
|
if (!apiKey || !srtContent) return;
|
||||||
|
|
||||||
// Get settings
|
// Get settings
|
||||||
const model = elements.customModel.value.trim() || DEFAULT_MODEL;
|
const defaultModel = provider === 'openrouter' ? DEFAULT_MODEL_OPENROUTER : DEFAULT_MODEL_GEMINI;
|
||||||
|
const model = elements.customModel.value.trim() || defaultModel;
|
||||||
const targetLanguage = elements.targetLanguage.value;
|
const targetLanguage = elements.targetLanguage.value;
|
||||||
const targetChunkSize = parseInt(elements.chunkSize.value) || 50;
|
const targetChunkSize = parseInt(elements.chunkSize.value) || 50;
|
||||||
const maxParallel = parseInt(elements.parallelRequests.value) || DEFAULT_PARALLEL;
|
const maxParallel = parseInt(elements.parallelRequests.value) || DEFAULT_PARALLEL;
|
||||||
@@ -502,9 +696,11 @@ async function startTranslation() {
|
|||||||
model,
|
model,
|
||||||
targetLanguage,
|
targetLanguage,
|
||||||
customInstructions,
|
customInstructions,
|
||||||
|
provider,
|
||||||
(idx, attempt, delay, errorMsg) => {
|
(idx, attempt, delay, errorMsg) => {
|
||||||
setChunkStatus(idx, 'error');
|
setChunkStatus(idx, 'error');
|
||||||
log(`Chunk ${idx + 1} retry ${attempt}/${MAX_RETRIES}: ${errorMsg}`, 'error');
|
const waitSec = Math.round(delay / 1000);
|
||||||
|
log(`Chunk ${idx + 1} rate limited/failed. Retrying (attempt ${attempt}) in ${waitSec}s...`, 'error');
|
||||||
setTimeout(() => setChunkStatus(idx, 'processing'), 100);
|
setTimeout(() => setChunkStatus(idx, 'processing'), 100);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -593,6 +789,7 @@ function getLanguageCode(language) {
|
|||||||
'Chinese (Simplified)': 'ZH-CN',
|
'Chinese (Simplified)': 'ZH-CN',
|
||||||
'Chinese (Traditional)': 'ZH-TW',
|
'Chinese (Traditional)': 'ZH-TW',
|
||||||
'Arabic': 'AR',
|
'Arabic': 'AR',
|
||||||
|
'Persian': 'FA',
|
||||||
'Hindi': 'HI',
|
'Hindi': 'HI',
|
||||||
'Dutch': 'NL',
|
'Dutch': 'NL',
|
||||||
'Polish': 'PL',
|
'Polish': 'PL',
|
||||||
@@ -605,6 +802,128 @@ function getLanguageCode(language) {
|
|||||||
return codes[language] || 'TRANSLATED';
|
return codes[language] || 'TRANSLATED';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Timing optimization to extend short "flash-and-go" subtitles (minimum 1.3 seconds)
|
||||||
|
function optimizeTimings(blocks, minDurationMs = 1300) {
|
||||||
|
for (let i = 0; i < blocks.length; i++) {
|
||||||
|
const block = blocks[i];
|
||||||
|
const times = parseTimestampRange(block.timestamp);
|
||||||
|
if (!times) continue;
|
||||||
|
|
||||||
|
const duration = times.end - times.start;
|
||||||
|
if (duration < minDurationMs) {
|
||||||
|
let nextStart = Infinity;
|
||||||
|
if (i < blocks.length - 1) {
|
||||||
|
const nextTimes = parseTimestampRange(blocks[i + 1].timestamp);
|
||||||
|
if (nextTimes) {
|
||||||
|
nextStart = nextTimes.start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend duration up to minDurationMs, leaving a 50ms gap before the next subtitle starts
|
||||||
|
const extendedEnd = times.start + minDurationMs;
|
||||||
|
const maxAllowedEnd = nextStart - 50;
|
||||||
|
const newEnd = Math.max(times.end, Math.min(extendedEnd, maxAllowedEnd));
|
||||||
|
|
||||||
|
if (newEnd > times.end) {
|
||||||
|
block.timestamp = `${formatMs(times.start)} --> ${formatMs(newEnd)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge short trailing subtitle blocks into the previous block if they are part of the same sentence
|
||||||
|
function mergeShortSubtitles(blocks, maxCombinedWords = 15, maxCombinedChars = 80, shortBlockDurationMs = 1200, shortWordCount = 4) {
|
||||||
|
let i = 0;
|
||||||
|
while (i < blocks.length - 1) {
|
||||||
|
const currentBlock = blocks[i];
|
||||||
|
const nextBlock = blocks[i + 1];
|
||||||
|
|
||||||
|
const currentTimes = parseTimestampRange(currentBlock.timestamp);
|
||||||
|
const nextTimes = parseTimestampRange(nextBlock.timestamp);
|
||||||
|
|
||||||
|
if (!currentTimes || !nextTimes) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDuration = nextTimes.end - nextTimes.start;
|
||||||
|
const nextTextJoined = nextBlock.text.join(' ');
|
||||||
|
const nextWords = nextTextJoined.split(/\s+/).filter(Boolean);
|
||||||
|
|
||||||
|
// Next block is candidates for merging if it has short duration or very few words
|
||||||
|
const isNextShort = nextDuration < shortBlockDurationMs || nextWords.length <= shortWordCount;
|
||||||
|
|
||||||
|
const currentTextJoined = currentBlock.text.join(' ');
|
||||||
|
const endsWithPunctuation = /[.!?]['"]*$/.test(currentTextJoined.trim());
|
||||||
|
const startsWithCapital = /^[A-Z]/.test(nextTextJoined.trim());
|
||||||
|
const isContinuingSentence = !endsWithPunctuation && !startsWithCapital;
|
||||||
|
|
||||||
|
if (isNextShort && isContinuingSentence) {
|
||||||
|
const combinedText = currentBlock.text.concat(nextBlock.text);
|
||||||
|
const combinedTextJoined = combinedText.join(' ');
|
||||||
|
const combinedWords = combinedTextJoined.split(/\s+/).filter(Boolean);
|
||||||
|
|
||||||
|
// Check if combined block length is within limits to avoid "filling the page"
|
||||||
|
if (combinedWords.length <= maxCombinedWords && combinedTextJoined.length <= maxCombinedChars) {
|
||||||
|
// Merge nextBlock into currentBlock (join text into a single line)
|
||||||
|
const joinedText = currentBlock.text.join(' ') + ' ' + nextBlock.text.join(' ');
|
||||||
|
currentBlock.text = [joinedText];
|
||||||
|
currentBlock.timestamp = `${formatMs(currentTimes.start)} --> ${formatMs(nextTimes.end)}`;
|
||||||
|
|
||||||
|
// Remove nextBlock from array
|
||||||
|
blocks.splice(i + 1, 1);
|
||||||
|
|
||||||
|
// Do not increment i, so we can check if this new combined block can merge with the next one
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-index all blocks
|
||||||
|
for (let idx = 0; idx < blocks.length; idx++) {
|
||||||
|
blocks[idx].index = idx + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimestampRange(timestampStr) {
|
||||||
|
if (!timestampStr) return null;
|
||||||
|
const parts = timestampStr.split('-->');
|
||||||
|
if (parts.length !== 2) return null;
|
||||||
|
|
||||||
|
function parseTime(t) {
|
||||||
|
const clean = t.trim().replace('.', ',');
|
||||||
|
const subparts = clean.split(',');
|
||||||
|
if (subparts.length !== 2) return 0;
|
||||||
|
|
||||||
|
const hms = subparts[0].split(':');
|
||||||
|
if (hms.length !== 3) return 0;
|
||||||
|
|
||||||
|
const h = parseInt(hms[0]) || 0;
|
||||||
|
const m = parseInt(hms[1]) || 0;
|
||||||
|
const s = parseInt(hms[2]) || 0;
|
||||||
|
const ms = parseInt(subparts[1]) || 0;
|
||||||
|
|
||||||
|
return (h * 3600 + m * 60 + s) * 1000 + ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: parseTime(parts[0]),
|
||||||
|
end: parseTime(parts[1])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMs(ms) {
|
||||||
|
const hours = Math.floor(ms / 3600000);
|
||||||
|
const minutes = Math.floor((ms % 3600000) / 60000);
|
||||||
|
const seconds = Math.floor((ms % 60000) / 1000);
|
||||||
|
const msec = ms % 1000;
|
||||||
|
|
||||||
|
const pad = (num, size) => num.toString().padStart(size, '0');
|
||||||
|
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)},${pad(msec, 3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Initialize App
|
// Initialize App
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
Reference in New Issue
Block a user