mirror of
https://github.com/yusufipk/better-ai-srt-translation.git
synced 2026-09-11 10:56:18 +00:00
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:
+612
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user