// ============================================ // SRT Translator - App Logic // ============================================ const DEFAULT_MODEL_GEMINI = 'gemini-3.1-flash-lite'; 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 RETRY_DELAY_MS = 1000; // 1 second 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 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'), optimizeTimings: document.getElementById('optimizeTimings'), mergeSubtitles: document.getElementById('mergeSubtitles'), apiProvider: document.getElementById('apiProvider') }; // ============================================ // Initialization // ============================================ function init() { loadSavedSettings(); updateProviderUI(); 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() { 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 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'); const savedOptimize = localStorage.getItem('srt_optimize_timings'); const savedMerge = localStorage.getItem('srt_merge_subtitles'); if (savedProvider) elements.apiProvider.value = savedProvider; else elements.apiProvider.value = 'gemini'; // Load saved API key (check provider-specific keys first) 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; 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() { const provider = elements.apiProvider.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_chunk_size', elements.chunkSize.value); localStorage.setItem('srt_parallel_requests', elements.parallelRequests.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); } // ============================================ // 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.apiProvider.addEventListener('change', () => { saveSettings(); updateProviderUI(); 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); elements.optimizeTimings.addEventListener('change', () => { saveSettings(); reprocessLoadedFile(); }); elements.mergeSubtitles.addEventListener('change', () => { saveSettings(); reprocessLoadedFile(); }); // 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 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) { originalFileName = file.name; const reader = new FileReader(); reader.onload = (e) => { srtContent = e.target.result; reprocessLoadedFile(); 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.trim(); } 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 = `${time}${message}`; 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 (['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()) { 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, provider) { // 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 { let response; if (provider === 'openrouter') { response = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', '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({ model: model, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: markedText } ], temperature: 0.3 }), 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}`); clearTimeout(timeoutId); if (!response.ok) { const errorText = await response.text(); 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(); console.log(`[Chunk ${index + 1}] Parsed response, parsing markers...`); 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 const translatedBlocks = parseMarkedTranslation(chunk, translatedText); // 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 => { 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(/(? { 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'); } 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, and smart rate-limit (429) delay handling async function translateChunkWithRetry(chunk, index, apiKey, model, targetLanguage, customInstructions, provider, onRetry) { let lastError; let maxAttempts = MAX_RETRIES; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await translateChunk(chunk, index, apiKey, model, targetLanguage, customInstructions, provider); } catch (error) { lastError = error; if (attempt < maxAttempts) { 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); await new Promise(resolve => setTimeout(resolve, delay)); } } } throw lastError; } // ============================================ // Main Translation Flow // ============================================ async function startTranslation() { const apiKey = elements.apiKey.value.trim(); if (!apiKey || !srtContent) return; // Get settings const provider = elements.apiProvider.value; const defaultModel = provider === 'openrouter' ? DEFAULT_MODEL_OPENROUTER : DEFAULT_MODEL_GEMINI; const model = elements.customModel.value.trim() || defaultModel; 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); try { log(`Chunk ${index + 1} starting (${blockCount} blocks, ${textLength} chars)...`, 'info'); const translated = await translateChunkWithRetry( chunk, index, apiKey, model, targetLanguage, customInstructions, provider, (idx, attempt, delay, errorMsg) => { setChunkStatus(idx, '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); } ); 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', 'Persian': 'FA', 'Hindi': 'HI', 'Dutch': 'NL', 'Polish': 'PL', 'Swedish': 'SV', 'Vietnamese': 'VI', 'Thai': 'TH', 'Indonesian': 'ID', 'Greek': 'EL' }; 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 // ============================================ init();