feat: AI-powered SRT subtitle translator with marker-based alignment

- Smart chunking with [B#] markers for precise block-to-timestamp mapping
- 50 parallel requests for blazing fast translation
- Custom instruction support for domain-specific terminology
- Retry with exponential backoff for reliability
- Dark theme UI with real-time progress visualization
- Default model: google/gemini-3-flash-preview for best performance/cost
- Supports 20+ languages via OpenRouter API
This commit is contained in:
Yusuf İpek
2026-01-27 22:43:42 +03:00
commit cfa48395a3
5 changed files with 1284 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
# SRT Translator
A powerful, browser-based tool for translating SRT subtitle files using AI. Built for speed, accuracy, and ease of use.
![SRT Translator UI](assets/ui.webp)
## 🎯 What Problem Does It Solve?
Translating subtitles is tedious and expensive:
- **Manual translation** is slow and costly
- **Generic AI translation** loses subtitle timing and structure
- **Existing tools** don't handle the unique challenges of SRT format (split sentences, timing markers, etc.)
**SRT Translator** solves this by:
1. Preserving all timestamps exactly as-is
2. Using a smart marker system to maintain line-by-line correspondence
3. Processing chunks in parallel for maximum speed
4. Allowing custom instructions for domain-specific terminology
## ✨ Features
- **Drag & Drop** - Just drop your SRT file and go
- **50 Parallel Requests** - Blazing fast translation
- **Smart Chunking** - Processes 75 subtitle blocks per request for optimal speed/accuracy balance
- **Marker-Based Alignment** - Each subtitle block stays aligned with its timestamp
- **Custom Instructions** - Add context-specific translation rules (e.g., "Use informal 'sen' instead of formal 'siz'")
- **Custom Model Support** - Use any model available on OpenRouter
- **Multi-Language** - Translate to 15+ popular languages
- **Real-Time Progress** - Visual feedback for each chunk's status
- **Retry with Backoff** - Automatic retry on failures
## 🚀 Quick Start
1. Open `index.html` in your browser
2. Enter your [OpenRouter API key](https://openrouter.ai/keys)
3. Drop an SRT file
4. Select target language
5. Click "Translate"
6. Download your translated SRT
## ⚙️ Configuration
| Setting | Default | Description |
|---------|---------|-------------|
| **Model** | `google/gemini-3-flash-preview` | AI model for translation |
| **Chunk Size** | 75 | Number of subtitle blocks per API request |
| **Parallel Requests** | 50 | Maximum concurrent API calls |
### Recommended Model
For the best **performance/cost** balance, we recommend:
```
google/gemini-3-flash-preview
```
This model offers:
- Fast response times
- Excellent instruction following
- Great translation quality
- Cost-effective pricing
Other options:
- `google/gemini-2.5-flash` - Slightly cheaper, good quality
- `anthropic/claude-3.5-sonnet` - Premium quality, higher cost
- `google/gemini-2.5-flash-lite` - Budget option, may have lower accuracy
## 🧠 How It Works
### The Marker System
Traditional AI translation loses track of which text belongs to which subtitle block. We solve this with markers:
**Input to AI:**
```
[B1] I don't think OpenAI will
[B2] be around in 5 years.
[B3] They're burning cash.
```
**AI Output:**
```
[B1] OpenAI'ın var olacağını
[B2] 5 yıl içinde sanmıyorum.
[B3] Paralarını yakıyorlar.
```
Each `[B#]` marker ensures the translated text maps back to the correct timestamp.
### Translation Priority
1. **Line Structure** (Mandatory) - Each marker line stays separate
2. **Natural Translation** - Idiomatic, not word-for-word
3. **Word Count** (Soft) - Similar length per line when possible
## 📁 Project Structure
```
srt-translator/
├── index.html # Main UI entry point
├── README.md # Documentation
├── src/
│ ├── app.js # Core application logic
│ └── styles.css # Dark theme styling
└── assets/
└── ui.webp # UI screenshot
```
## 🔧 Custom Instructions Examples
**Turkish informal:**
```
Türkçe çeviride "siz" yerine "sen" formu kullan.
```
**Technical content:**
```
Keep technical terms like "API", "SDK", "cache" untranslated.
```
**YouTube tone:**
```
Use casual, engaging language suitable for YouTube videos.
```
## 📝 API Requirements
- **Provider:** [OpenRouter](https://openrouter.ai)
- **API Key:** Get one at https://openrouter.ai/keys
- **Models:** Any chat completion model on OpenRouter
## 🛡️ Privacy
- Your API key is stored locally in your browser (localStorage)
- SRT files are processed client-side
- Only subtitle text is sent to the AI API
- No data is stored on any server
## 📄 License
MIT License - Use freely for personal or commercial projects.
---
Built with ❤️ for content creators who need fast, accurate subtitle translations.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+142
View File
@@ -0,0 +1,142 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SRT Translator</title>
<link rel="stylesheet" href="src/styles.css">
</head>
<body>
<div class="container">
<header>
<h1>SRT Translator</h1>
<p class="subtitle">AI-Powered Subtitle Translation via OpenRouter</p>
</header>
<div class="card">
<div class="card-title">API Configuration</div>
<input type="password" id="apiKey" class="input-field" placeholder="Enter your OpenRouter API key...">
<div class="model-row">
<input type="text" id="customModel" class="input-field"
placeholder="Custom model (leave empty for default: google/gemini-3-flash-preview)">
</div>
</div>
<div class="card">
<div class="card-title">Translation Settings</div>
<div class="settings-grid">
<label>
<span>Target Language</span>
<select id="targetLanguage" class="select-field">
<option value="Turkish">Turkish</option>
<option value="Spanish">Spanish</option>
<option value="French">French</option>
<option value="German">German</option>
<option value="Italian">Italian</option>
<option value="Portuguese">Portuguese</option>
<option value="Russian">Russian</option>
<option value="Japanese">Japanese</option>
<option value="Korean">Korean</option>
<option value="Chinese (Simplified)">Chinese (Simplified)</option>
<option value="Chinese (Traditional)">Chinese (Traditional)</option>
<option value="Arabic">Arabic</option>
<option value="Hindi">Hindi</option>
<option value="Dutch">Dutch</option>
<option value="Polish">Polish</option>
<option value="Swedish">Swedish</option>
<option value="Vietnamese">Vietnamese</option>
<option value="Thai">Thai</option>
<option value="Indonesian">Indonesian</option>
<option value="Greek">Greek</option>
</select>
</label>
<label>
<span>Chunk Size (SRT blocks)</span>
<input type="number" id="chunkSize" class="input-field" value="75" min="10" max="200">
</label>
<label>
<span>Parallel Requests</span>
<input type="number" id="parallelRequests" class="input-field" value="50" min="1" max="100">
</label>
</div>
<div class="instruction-section">
<label>
<span>Custom Instructions (optional)</span>
<textarea id="customInstructions" class="textarea-field"
placeholder="Add any specific translation instructions here... (e.g., 'Keep technical terms in English', 'Use informal tone')"
rows="3"></textarea>
</label>
</div>
</div>
<div class="card">
<div class="card-title">Upload SRT File</div>
<div class="drop-zone" id="dropZone">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p id="dropText">Drop your .srt file here or click to browse</p>
<p class="filename hidden" id="fileName"></p>
</div>
<input type="file" id="fileInput" accept=".srt" class="hidden">
</div>
<button class="btn btn-primary" id="translateBtn" disabled>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
</svg>
<span id="translateBtnText">Translate</span>
</button>
<div class="card hidden" id="progressCard">
<div class="card-title">Translation Progress</div>
<div class="stats">
<div class="stat">
<div class="stat-value" id="totalChunks">0</div>
<div class="stat-label">Total Chunks</div>
</div>
<div class="stat">
<div class="stat-value" id="completedChunks">0</div>
<div class="stat-label">Completed</div>
</div>
<div class="stat">
<div class="stat-value" id="failedChunks">0</div>
<div class="stat-label">Failed</div>
</div>
</div>
<div class="progress-section">
<div class="progress-header">
<span class="progress-label">Overall Progress</span>
<span class="progress-percentage" id="progressPercent">0%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
</div>
<div class="chunks-grid" id="chunksGrid"></div>
<div class="log-container" id="logContainer"></div>
</div>
<button class="btn btn-success hidden" id="downloadBtn">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download Translated SRT
</button>
</div>
<script src="src/app.js"></script>
</body>
</html>
+612
View File
@@ -0,0 +1,612 @@
// ============================================
// SRT Translator - App Logic
// ============================================
const DEFAULT_MODEL = 'google/gemini-3-flash-preview';
const DEFAULT_PARALLEL = 50;
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 1000; // 1 second
const REQUEST_TIMEOUT_MS = 30000; // 30 seconds
// State
let srtContent = '';
let srtBlocks = [];
let translatedResult = '';
let originalFileName = '';
// DOM Elements
const elements = {
dropZone: document.getElementById('dropZone'),
fileInput: document.getElementById('fileInput'),
dropText: document.getElementById('dropText'),
fileName: document.getElementById('fileName'),
translateBtn: document.getElementById('translateBtn'),
translateBtnText: document.getElementById('translateBtnText'),
progressCard: document.getElementById('progressCard'),
downloadBtn: document.getElementById('downloadBtn'),
apiKey: document.getElementById('apiKey'),
customModel: document.getElementById('customModel'),
targetLanguage: document.getElementById('targetLanguage'),
chunkSize: document.getElementById('chunkSize'),
parallelRequests: document.getElementById('parallelRequests'),
customInstructions: document.getElementById('customInstructions'),
chunksGrid: document.getElementById('chunksGrid'),
logContainer: document.getElementById('logContainer'),
progressFill: document.getElementById('progressFill'),
progressPercent: document.getElementById('progressPercent'),
totalChunks: document.getElementById('totalChunks'),
completedChunks: document.getElementById('completedChunks'),
failedChunks: document.getElementById('failedChunks')
};
// ============================================
// Initialization
// ============================================
function init() {
loadSavedSettings();
setupEventListeners();
}
function loadSavedSettings() {
const savedApiKey = localStorage.getItem('srt_openrouter_api_key');
const savedModel = localStorage.getItem('srt_custom_model');
const savedLanguage = localStorage.getItem('srt_target_language');
const savedChunkSize = localStorage.getItem('srt_chunk_size');
const savedParallel = localStorage.getItem('srt_parallel_requests');
const savedInstructions = localStorage.getItem('srt_custom_instructions');
if (savedApiKey) elements.apiKey.value = savedApiKey;
if (savedModel) elements.customModel.value = savedModel;
if (savedLanguage) elements.targetLanguage.value = savedLanguage;
if (savedChunkSize) elements.chunkSize.value = savedChunkSize;
if (savedParallel) elements.parallelRequests.value = savedParallel;
else elements.parallelRequests.value = DEFAULT_PARALLEL;
if (savedInstructions) elements.customInstructions.value = savedInstructions;
}
function saveSettings() {
localStorage.setItem('srt_openrouter_api_key', elements.apiKey.value);
localStorage.setItem('srt_custom_model', elements.customModel.value);
localStorage.setItem('srt_target_language', elements.targetLanguage.value);
localStorage.setItem('srt_chunk_size', elements.chunkSize.value);
localStorage.setItem('srt_parallel_requests', elements.parallelRequests.value);
localStorage.setItem('srt_custom_instructions', elements.customInstructions.value);
}
// ============================================
// Event Listeners
// ============================================
function setupEventListeners() {
// File upload
elements.dropZone.addEventListener('click', () => elements.fileInput.click());
elements.dropZone.addEventListener('dragover', handleDragOver);
elements.dropZone.addEventListener('dragleave', handleDragLeave);
elements.dropZone.addEventListener('drop', handleDrop);
elements.fileInput.addEventListener('change', handleFileSelect);
// Settings changes
elements.apiKey.addEventListener('input', () => {
saveSettings();
updateTranslateButton();
});
elements.customModel.addEventListener('change', saveSettings);
elements.targetLanguage.addEventListener('change', () => {
saveSettings();
updateTranslateButtonText();
});
elements.chunkSize.addEventListener('change', saveSettings);
elements.parallelRequests.addEventListener('change', saveSettings);
elements.customInstructions.addEventListener('change', saveSettings);
// Actions
elements.translateBtn.addEventListener('click', startTranslation);
elements.downloadBtn.addEventListener('click', downloadResult);
// Initial UI state
updateTranslateButton();
updateTranslateButtonText();
}
function handleDragOver(e) {
e.preventDefault();
elements.dropZone.classList.add('dragover');
}
function handleDragLeave() {
elements.dropZone.classList.remove('dragover');
}
function handleDrop(e) {
e.preventDefault();
elements.dropZone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file && file.name.endsWith('.srt')) {
processFile(file);
}
}
function handleFileSelect(e) {
const file = e.target.files[0];
if (file) processFile(file);
}
// ============================================
// File Processing
// ============================================
function processFile(file) {
originalFileName = file.name;
const reader = new FileReader();
reader.onload = (e) => {
srtContent = e.target.result;
srtBlocks = parseSRT(srtContent);
elements.dropText.textContent = `Loaded: ${srtBlocks.length} subtitle blocks`;
elements.fileName.textContent = file.name;
elements.fileName.classList.remove('hidden');
updateTranslateButton();
};
reader.readAsText(file);
}
function parseSRT(content) {
const blocks = [];
const lines = content.split('\n');
let currentBlock = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (/^\d+$/.test(line)) {
if (currentBlock) {
blocks.push(currentBlock);
}
currentBlock = {
index: parseInt(line),
timestamp: '',
text: []
};
} else if (currentBlock && line.includes('-->')) {
currentBlock.timestamp = line;
} else if (currentBlock && line !== '') {
currentBlock.text.push(line);
}
}
if (currentBlock) {
blocks.push(currentBlock);
}
return blocks;
}
// ============================================
// Smart Chunking
// ============================================
function createSmartChunks(blocks, targetSize) {
const chunks = [];
let currentChunk = [];
for (let i = 0; i < blocks.length; i++) {
currentChunk.push(blocks[i]);
if (currentChunk.length >= targetSize) {
let splitIndex = -1;
// Look for sentence ending in last 10 blocks
for (let j = currentChunk.length - 1; j >= Math.max(0, currentChunk.length - 10); j--) {
const text = currentChunk[j].text.join(' ');
if (/[.!?][\s"']*$/.test(text)) {
splitIndex = j;
break;
}
}
if (splitIndex === -1) {
chunks.push([...currentChunk]);
currentChunk = [];
} else {
chunks.push(currentChunk.slice(0, splitIndex + 1));
currentChunk = currentChunk.slice(splitIndex + 1);
}
}
}
if (currentChunk.length > 0) {
chunks.push(currentChunk);
}
return chunks;
}
function blocksToSRT(blocks) {
return blocks.map(block => {
return `${block.index}\n${block.timestamp}\n${block.text.join('\n')}`;
}).join('\n\n');
}
// ============================================
// UI Updates
// ============================================
function updateTranslateButton() {
elements.translateBtn.disabled = !srtContent || !elements.apiKey.value;
}
function updateTranslateButtonText() {
const lang = elements.targetLanguage.value;
elements.translateBtnText.textContent = `Translate to ${lang}`;
}
function log(message, type = 'info') {
const time = new Date().toLocaleTimeString();
const entry = document.createElement('div');
entry.className = 'log-entry';
entry.innerHTML = `<span class="log-time">${time}</span><span class="log-${type}">${message}</span>`;
elements.logContainer.insertBefore(entry, elements.logContainer.firstChild);
}
function updateProgress(completed, failed, total) {
elements.completedChunks.textContent = completed;
elements.failedChunks.textContent = failed;
const percent = Math.round((completed + failed) / total * 100);
elements.progressPercent.textContent = `${percent}%`;
elements.progressFill.style.width = `${percent}%`;
}
function setChunkStatus(index, status) {
const indicator = document.querySelector(`[data-chunk="${index}"]`);
if (indicator) {
indicator.className = `chunk-indicator ${status}`;
}
}
// ============================================
// Translation API
// ============================================
function buildSystemPrompt(targetLanguage, customInstructions) {
let prompt = `You are an expert subtitle translator for ${targetLanguage}.
INPUT: Lines starting with markers [B1], [B2], etc.
PRIORITY ORDER (highest to lowest):
1. LINE STRUCTURE (MANDATORY): Each [B#] line stays separate. Never merge or swap content between lines.
2. NATURAL TRANSLATION: Translate idiomatically, not word-for-word. Omit unnecessary pronouns if the language allows it.
3. WORD COUNT (SOFT): Try to keep similar word count per line, but naturalness comes first.
RULES:
- Keep [B#] markers at the start of each output line
- Return EXACTLY the same number of lines as input
- Do NOT add any annotations, word counts, or comments
- Just output the translated text with markers, nothing else
EXAMPLE:
Input:
[B1] I don't think OpenAI will
[B2] be around in 5 years.
Output:
[B1] OpenAI'ın var olacağını
[B2] 5 yıl içinde sanmıyorum.
CRITICAL: [B1] → [B1], [B2] → [B2]. Never shift content.`;
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.`;
}
return prompt;
}
// Extract text with block markers - one per line
function extractTextWithMarkers(blocks) {
return blocks.map((block, i) => `[B${i + 1}] ${block.text.join(' ')}`).join('\n');
}
// Parse translated text with markers back into blocks
function parseMarkedTranslation(blocks, translatedText) {
const result = [];
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
const markerStart = `[B${i + 1}]`;
const markerEnd = `[B${i + 2}]`;
const startIdx = translatedText.indexOf(markerStart);
if (startIdx === -1) {
// Marker not found, use original
result.push({
index: block.index,
timestamp: block.timestamp,
text: [block.text.join(' ')]
});
continue;
}
const textStart = startIdx + markerStart.length;
let textEnd;
if (i === blocks.length - 1) {
textEnd = translatedText.length;
} else {
const endIdx = translatedText.indexOf(markerEnd);
textEnd = endIdx !== -1 ? endIdx : translatedText.length;
}
const blockText = translatedText.substring(textStart, textEnd).trim();
result.push({
index: block.index,
timestamp: block.timestamp,
text: [blockText]
});
}
return result;
}
async function translateChunk(chunk, index, apiKey, model, targetLanguage, customInstructions) {
// Extract text with block markers
const markedText = extractTextWithMarkers(chunk);
const systemPrompt = buildSystemPrompt(targetLanguage, customInstructions);
console.log(`[Chunk ${index + 1}] Sending API request...`);
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => {
console.log(`[Chunk ${index + 1}] TIMEOUT - aborting request`);
controller.abort();
}, REQUEST_TIMEOUT_MS);
try {
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: markedText }
],
temperature: 0.3
}),
signal: controller.signal
});
console.log(`[Chunk ${index + 1}] Response received, status: ${response.status}`);
clearTimeout(timeoutId);
if (!response.ok) {
const error = await response.text();
throw new Error(`API Error: ${response.status} - ${error}`);
}
const data = await response.json();
console.log(`[Chunk ${index + 1}] Parsed response, parsing markers...`);
const translatedText = data.choices[0].message.content;
// Parse markers and map back to blocks
const translatedBlocks = parseMarkedTranslation(chunk, translatedText);
// Convert back to SRT format
return translatedBlocks.map(block => {
return `${block.index}\n${block.timestamp}\n${block.text.join('\n')}`;
}).join('\n\n');
} catch (error) {
clearTimeout(timeoutId);
console.log(`[Chunk ${index + 1}] Error:`, error.name, error.message);
if (error.name === 'AbortError') {
throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`);
}
throw error;
}
}
// Retry wrapper with exponential backoff
async function translateChunkWithRetry(chunk, index, apiKey, model, targetLanguage, customInstructions, onRetry) {
let lastError;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
return await translateChunk(chunk, index, apiKey, model, targetLanguage, customInstructions);
} catch (error) {
lastError = error;
if (attempt < MAX_RETRIES) {
const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1); // Exponential backoff
onRetry(index, attempt, delay, error.message);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError;
}
// ============================================
// Main Translation Flow
// ============================================
async function startTranslation() {
const apiKey = elements.apiKey.value;
if (!apiKey || !srtContent) return;
// Get settings
const model = elements.customModel.value.trim() || DEFAULT_MODEL;
const targetLanguage = elements.targetLanguage.value;
const targetChunkSize = parseInt(elements.chunkSize.value) || 50;
const maxParallel = parseInt(elements.parallelRequests.value) || DEFAULT_PARALLEL;
const customInstructions = elements.customInstructions.value;
// Create chunks
const chunks = createSmartChunks(srtBlocks, targetChunkSize);
// Setup UI
elements.progressCard.classList.remove('hidden');
elements.downloadBtn.classList.add('hidden');
elements.translateBtn.disabled = true;
elements.logContainer.innerHTML = '';
elements.totalChunks.textContent = chunks.length;
// Create chunk indicators
elements.chunksGrid.innerHTML = '';
chunks.forEach((_, i) => {
const indicator = document.createElement('div');
indicator.className = 'chunk-indicator pending';
indicator.dataset.chunk = i;
indicator.textContent = i + 1;
elements.chunksGrid.appendChild(indicator);
});
log(`Starting translation to ${targetLanguage}...`, 'info');
log(`Model: ${model}`, 'info');
log(`Chunks: ${chunks.length} | Size: ~${targetChunkSize} | Parallel: ${maxParallel}`, 'info');
let completed = 0;
let failed = 0;
const results = new Array(chunks.length).fill(null);
// Process a single chunk
async function processChunk(chunk, index) {
setChunkStatus(index, 'processing');
const blockCount = chunk.length;
const textLength = chunk.reduce((sum, b) => sum + b.text.join(' ').length, 0);
// Debug for problematic chunks
if (index === 107) {
log(`⚠️ CHUNK 108 DEBUG: ${blockCount} blocks, ${textLength} chars`, 'error');
console.log('Chunk 108 content:', chunk);
}
try {
log(`Chunk ${index + 1} starting (${blockCount} blocks, ${textLength} chars)...`, 'info');
const translated = await translateChunkWithRetry(
chunk,
index,
apiKey,
model,
targetLanguage,
customInstructions,
(idx, attempt, delay, errorMsg) => {
setChunkStatus(idx, 'error');
log(`Chunk ${idx + 1} retry ${attempt}/${MAX_RETRIES}: ${errorMsg}`, 'error');
setTimeout(() => setChunkStatus(idx, 'processing'), 100);
}
);
results[index] = translated;
completed++;
setChunkStatus(index, 'done');
log(`Chunk ${index + 1} completed`, 'success');
} catch (error) {
failed++;
setChunkStatus(index, 'error');
log(`Chunk ${index + 1} FAILED: ${error.message}`, 'error');
results[index] = blocksToSRT(chunk);
}
updateProgress(completed, failed, chunks.length);
}
// Process all chunks with controlled concurrency
const processing = new Set();
let nextIndex = 0;
async function processNext() {
while (nextIndex < chunks.length) {
const index = nextIndex++;
const chunk = chunks[index];
const promise = processChunk(chunk, index).finally(() => {
processing.delete(promise);
});
processing.add(promise);
// If at max concurrency, wait for one to complete
if (processing.size >= maxParallel) {
await Promise.race(processing);
}
}
// Wait for remaining
await Promise.all(processing);
}
await processNext();
// Merge results
translatedResult = results.join('\n\n');
const status = completed === chunks.length ? 'success' : 'error';
log(`Translation complete! ${completed} successful, ${failed} failed`, status);
elements.translateBtn.disabled = false;
elements.downloadBtn.classList.remove('hidden');
}
// ============================================
// Download
// ============================================
function downloadResult() {
const lang = elements.targetLanguage.value;
const langCode = getLanguageCode(lang);
const blob = new Blob([translatedResult], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = originalFileName.replace('.srt', `_${langCode}.srt`);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
log(`Downloaded: ${a.download}`, 'success');
}
function getLanguageCode(language) {
const codes = {
'Turkish': 'TR',
'Spanish': 'ES',
'French': 'FR',
'German': 'DE',
'Italian': 'IT',
'Portuguese': 'PT',
'Russian': 'RU',
'Japanese': 'JA',
'Korean': 'KO',
'Chinese (Simplified)': 'ZH-CN',
'Chinese (Traditional)': 'ZH-TW',
'Arabic': 'AR',
'Hindi': 'HI',
'Dutch': 'NL',
'Polish': 'PL',
'Swedish': 'SV',
'Vietnamese': 'VI',
'Thai': 'TH',
'Indonesian': 'ID',
'Greek': 'EL'
};
return codes[language] || 'TRANSLATED';
}
// ============================================
// Initialize App
// ============================================
init();
+385
View File
@@ -0,0 +1,385 @@
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: #1a1a25;
--accent: #6366f1;
--accent-glow: rgba(99, 102, 241, 0.3);
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
--border: rgba(255, 255, 255, 0.08);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
}
.container {
width: 100%;
max-width: 800px;
}
header {
text-align: center;
margin-bottom: 2.5rem;
}
h1 {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent) 0%, #a855f7 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.subtitle {
color: var(--text-secondary);
font-size: 1rem;
}
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 16px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.card-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1rem;
}
.input-field,
.select-field,
.textarea-field {
width: 100%;
padding: 0.875rem 1rem;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text-primary);
font-size: 0.95rem;
font-family: inherit;
transition: all 0.2s ease;
}
.input-field:focus,
.select-field:focus,
.textarea-field:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.select-field {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 1rem;
padding-right: 2.5rem;
}
.select-field option {
background: var(--bg-secondary);
color: var(--text-primary);
}
.textarea-field {
resize: vertical;
min-height: 80px;
}
.model-row {
margin-top: 1rem;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.settings-grid label span,
.instruction-section label span {
display: block;
font-size: 0.8rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.instruction-section {
margin-top: 1rem;
}
.drop-zone {
border: 2px dashed var(--border);
border-radius: 12px;
padding: 3rem 2rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: var(--bg-tertiary);
}
.drop-zone:hover,
.drop-zone.dragover {
border-color: var(--accent);
background: rgba(99, 102, 241, 0.05);
}
.drop-zone svg {
width: 48px;
height: 48px;
color: var(--text-secondary);
margin-bottom: 1rem;
}
.drop-zone p {
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.drop-zone .filename {
color: var(--accent);
font-weight: 600;
}
.hidden {
display: none !important;
}
.btn {
width: 100%;
padding: 1rem 1.5rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%);
color: white;
box-shadow: 0 4px 15px var(--accent-glow);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px var(--accent-glow);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-success {
background: linear-gradient(135deg, var(--success) 0%, #16a34a 100%);
color: white;
margin-top: 1.5rem;
}
.btn-success:hover {
transform: translateY(-2px);
}
.progress-section {
margin-top: 1.5rem;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.progress-label {
font-size: 0.875rem;
color: var(--text-secondary);
}
.progress-percentage {
font-size: 0.875rem;
font-weight: 600;
color: var(--accent);
}
.progress-bar {
height: 8px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent) 0%, #a855f7 100%);
border-radius: 4px;
transition: width 0.3s ease;
width: 0%;
}
.chunks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
gap: 0.5rem;
margin-top: 1rem;
}
.chunk-indicator {
aspect-ratio: 1;
border-radius: 6px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
font-weight: 600;
color: var(--text-secondary);
transition: all 0.3s ease;
}
.chunk-indicator.pending {
background: var(--bg-tertiary);
}
.chunk-indicator.processing {
background: rgba(245, 158, 11, 0.2);
border-color: var(--warning);
color: var(--warning);
animation: pulse 1.5s infinite;
}
.chunk-indicator.done {
background: rgba(34, 197, 94, 0.2);
border-color: var(--success);
color: var(--success);
}
.chunk-indicator.error {
background: rgba(239, 68, 68, 0.2);
border-color: var(--error);
color: var(--error);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-top: 1rem;
}
.stat {
background: var(--bg-tertiary);
padding: 1rem;
border-radius: 10px;
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--accent);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}
.log-container {
max-height: 200px;
overflow-y: auto;
background: var(--bg-tertiary);
border-radius: 10px;
padding: 1rem;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.8rem;
margin-top: 1rem;
}
.log-entry {
padding: 0.25rem 0;
border-bottom: 1px solid var(--border);
}
.log-entry:last-child {
border-bottom: none;
}
.log-time {
color: var(--text-secondary);
margin-right: 0.5rem;
}
.log-success { color: var(--success); }
.log-error { color: var(--error); }
.log-info { color: var(--accent); }
/* Responsive */
@media (max-width: 640px) {
body {
padding: 1rem;
}
h1 {
font-size: 1.75rem;
}
.settings-grid {
grid-template-columns: 1fr;
}
.stats {
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.stat {
padding: 0.75rem 0.5rem;
}
.stat-value {
font-size: 1.25rem;
}
}