diff --git a/CHANGELOG.md b/CHANGELOG.md index 20a0c309..739632f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed +- SRT files selected during source analysis now wait for speaker cloning, then replace transcript text without losing voices (#1709) - Windows MSI deployments can now prohibit WebView2 bootstrap with `DISABLEWEBVIEW2BOOTSTRAP=1`, and `AUTOLAUNCHAPP=0` reliably suppresses first launch (#1714) - Subtitle rows now provide 100 ms timing steppers and flag adjacent overlaps without requiring precise timeline dragging (#1710) - Repair-sync failures now retain uv's final dependency error instead of reporting only an opaque exit status (#1705) diff --git a/frontend/src/api/dub.ts b/frontend/src/api/dub.ts index 4d515f09..723f08d9 100644 --- a/frontend/src/api/dub.ts +++ b/frontend/src/api/dub.ts @@ -117,10 +117,11 @@ export interface DubImportSrtResponse { export async function dubImportSrt( jobId: string, file: File | Blob, + { signal }: { signal?: AbortSignal } = {}, ): Promise { const fd = new FormData(); fd.append('file', file); - return apiPost(`/dub/import-srt/${jobId}`, fd); + return apiPost(`/dub/import-srt/${jobId}`, fd, { signal }); } export interface ParsedSubtitleCue { diff --git a/frontend/src/hooks/useDubWorkflow.js b/frontend/src/hooks/useDubWorkflow.js index c6a6fb3f..40494ca2 100644 --- a/frontend/src/hooks/useDubWorkflow.js +++ b/frontend/src/hooks/useDubWorkflow.js @@ -49,6 +49,18 @@ export function isExpiredDubJobError(err) { ); } +export function shouldQueueSrtImport(dubStep, sourceAnalysisComplete = false) { + return !sourceAnalysisComplete && ['uploading', 'transcribing'].includes(dubStep); +} + +export async function applyQueuedSrtImport(pendingRef, jobId, signal, performImport) { + const file = pendingRef.current; + if (!file) return false; + const imported = await performImport(jobId, file, signal); + if (imported && pendingRef.current === file) pendingRef.current = null; + return imported; +} + /** * Encapsulates the entire dub pipeline workflow: * upload → prep → transcribe → translate → generate → export @@ -113,6 +125,7 @@ export default function useDubWorkflow({ const dubClientJobIdRef = useRef(null); const asrInstallTaskRef = useRef(null); const retryTranscribeRef = useRef(null); + const pendingSrtRef = useRef(null); const _showMissingAsr = useCallback( (payload) => { @@ -191,6 +204,7 @@ export default function useDubWorkflow({ // clear the dead id/state, drop any pill, and prompt a fresh upload with a // calm info toast — never a bug-report prompt, since this is expected. const _resetStaleDubSession = useCallback(() => { + pendingSrtRef.current = null; setDubJobId(''); setDubTaskId(''); setDubSegments([]); @@ -212,6 +226,95 @@ export default function useDubWorkflow({ ); }, [setDubJobId, setDubTaskId, setDubSegments, setDubError, setDubStep]); + const _performSrtImport = useCallback( + async (jobId, file, signal) => { + if ( + signal?.aborted || + useAppStore.getState().dubJobId !== jobId || + pendingSrtRef.current !== file + ) + return false; + try { + setDubError(''); + const res = await dubImportSrt(jobId, file, { signal }); + if ( + signal?.aborted || + useAppStore.getState().dubJobId !== jobId || + pendingSrtRef.current !== file + ) + return false; + const segs = (res && res.segments) || []; + setDubSegments( + segs.map((s) => ({ + ...s, + id: s.id != null ? String(s.id) : String(Math.random()), + })), + ); + setDubStep('editing'); + const stats = res?.stats || {}; + const noteParts = [ + t('dub_workflow.imported_cues', { + count: stats.imported ?? segs.length, + file: file.name || '.srt', + }), + ]; + if (stats.skipped_malformed) + noteParts.push(t('dub_workflow.skipped_malformed', { count: stats.skipped_malformed })); + if (stats.dropped_overlap) + noteParts.push(t('dub_workflow.dropped_overlap', { count: stats.dropped_overlap })); + if (stats.clamped_to_duration) + noteParts.push( + t('dub_workflow.clamped_to_duration', { count: stats.clamped_to_duration }), + ); + toast.success(noteParts.join(' · '), { duration: 6000 }); + loadProjects(); + return true; + } catch (err) { + if (err?.name === 'AbortError') throw err; + if ( + signal?.aborted || + useAppStore.getState().dubJobId !== jobId || + pendingSrtRef.current !== file + ) + return false; + if (isExpiredDubJobError(err)) { + _resetStaleDubSession(); + return false; + } + const msg = err?.message || t('dub_workflow.srt_import_failed'); + setDubError(msg); + setDubStep('editing'); + toast.error(msg); + return false; + } + }, + [setDubError, setDubSegments, setDubStep, loadProjects, _resetStaleDubSession], + ); + + const _applyQueuedSrt = useCallback( + async (jobId, ctrl) => { + let queuedSrt = pendingSrtRef.current; + if (!queuedSrt) { + setDubStep('editing'); + return true; + } + while (true) { + const imported = await applyQueuedSrtImport( + pendingSrtRef, + jobId, + ctrl?.signal, + _performSrtImport, + ); + if (ctrl?.signal.aborted || useAppStore.getState().dubJobId !== jobId) return false; + const replacement = pendingSrtRef.current; + if (!replacement) return imported; + if (replacement === queuedSrt) return false; + queuedSrt = replacement; + } + }, + [setDubStep, _performSrtImport], + ); + // Timer for transcribe elapsed useEffect(() => { if (!transcribeStart) { @@ -529,6 +632,7 @@ export default function useDubWorkflow({ setDubError(''); setDubFailure(null); setDubTracks([]); + pendingSrtRef.current = null; setDubPrepStage('download'); setDubPrepProgress({ percent: null, speedBps: null, etaS: null, stageStartedAt: Date.now() }); const ctrl = new AbortController(); @@ -573,7 +677,7 @@ export default function useDubWorkflow({ }); await _waitForTranscribe(data.job_id, ctrl); setTranscribeStart(null); - setDubStep('editing'); + await _applyQueuedSrt(data.job_id, ctrl); useAppStore.getState().completePill(t('dub_workflow.transcription_complete')); loadProjects(); loadProfiles(); @@ -611,6 +715,7 @@ export default function useDubWorkflow({ setDubSegments, _waitForPrep, _waitForTranscribe, + _applyQueuedSrt, loadProjects, loadProfiles, _resetStaleDubSession, @@ -634,6 +739,7 @@ export default function useDubWorkflow({ setDubError(''); setDubFailure(null); setDubTracks([]); + pendingSrtRef.current = null; setDubPrepStage('download'); setDubPrepProgress({ percent: null, speedBps: null, etaS: null, stageStartedAt: Date.now() }); const ctrl = new AbortController(); @@ -672,7 +778,7 @@ export default function useDubWorkflow({ }); await _waitForTranscribe(data.job_id, ctrl); setTranscribeStart(null); - setDubStep('editing'); + await _applyQueuedSrt(data.job_id, ctrl); useAppStore.getState().completePill(t('dub_workflow.transcription_complete')); loadProjects(); loadProfiles(); @@ -716,6 +822,7 @@ export default function useDubWorkflow({ setDubSegments, _waitForPrep, _waitForTranscribe, + _applyQueuedSrt, loadProjects, loadProfiles, _resetStaleDubSession, @@ -725,6 +832,7 @@ export default function useDubWorkflow({ ); const handleDubAbort = useCallback(async () => { + pendingSrtRef.current = null; const pendingInstall = asrInstallTaskRef.current; if (pendingInstall) { pendingInstall.ctrl.abort(); @@ -750,7 +858,7 @@ export default function useDubWorkflow({ try { await _waitForTranscribe(dubJobId, ctrl); setTranscribeStart(null); - setDubStep('editing'); + await _applyQueuedSrt(dubJobId, ctrl); loadProjects(); } catch (err) { setTranscribeStart(null); @@ -775,6 +883,7 @@ export default function useDubWorkflow({ setDubSegments, setDubStep, _waitForTranscribe, + _applyQueuedSrt, loadProjects, _resetStaleDubSession, _showMissingAsr, @@ -784,51 +893,21 @@ export default function useDubWorkflow({ }, [handleDubRetryTranscribe]); const handleDubImportSrt = useCallback( - async (file) => { - if (!dubJobId) { + async (file, { jobId = dubJobId, sourceAnalysisComplete = false, signal } = {}) => { + if (!jobId) { toast.error(t('dub_workflow.import_srt_no_job')); return; } if (!file) return; - try { - setDubError(''); - const res = await dubImportSrt(dubJobId, file); - const segs = (res && res.segments) || []; - setDubSegments( - segs.map((s) => ({ - ...s, - id: s.id != null ? String(s.id) : String(Math.random()), - })), - ); - setDubStep('editing'); - const stats = res?.stats || {}; - const noteParts = [ - t('dub_workflow.imported_cues', { - count: stats.imported ?? segs.length, - file: file.name || '.srt', - }), - ]; - if (stats.skipped_malformed) - noteParts.push(t('dub_workflow.skipped_malformed', { count: stats.skipped_malformed })); - if (stats.dropped_overlap) - noteParts.push(t('dub_workflow.dropped_overlap', { count: stats.dropped_overlap })); - if (stats.clamped_to_duration) - noteParts.push( - t('dub_workflow.clamped_to_duration', { count: stats.clamped_to_duration }), - ); - toast.success(noteParts.join(' · '), { duration: 6000 }); - loadProjects(); - } catch (err) { - if (isExpiredDubJobError(err)) { - _resetStaleDubSession(); - return; - } - const msg = err?.message || t('dub_workflow.srt_import_failed'); - setDubError(msg); - toast.error(msg); + if (shouldQueueSrtImport(dubStep, sourceAnalysisComplete)) { + pendingSrtRef.current = file; + toast(t('dub_workflow.import_srt_after_speakers')); + return false; } + pendingSrtRef.current = file; + return applyQueuedSrtImport(pendingSrtRef, jobId, signal, _performSrtImport); }, - [dubJobId, setDubError, setDubSegments, setDubStep, loadProjects, _resetStaleDubSession], + [dubJobId, dubStep, _performSrtImport], ); const handleCleanupSegments = useCallback(async () => { diff --git a/frontend/src/i18n/locales/ar.json b/frontend/src/i18n/locales/ar.json index 6b30a9b2..dbe13a5e 100644 --- a/frontend/src/i18n/locales/ar.json +++ b/frontend/src/i18n/locales/ar.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "تم إلغاء إعادة المحاولة", "transcription_failed": "فشل النسخ: {{message}}", "import_srt_no_job": "قم بتحميل مقطع فيديو أو استيعابه أولاً - لا توجد مهمة لإرفاق الترجمة بها.", + "import_srt_after_speakers": "تم وضع ملف SRT في قائمة الانتظار. سيكتمل تحليل المتحدثين أولاً للحفاظ على الأصوات المستنسخة.", "imported_cues": "تم استيراد {{count}} إشارة (إشارات) من {{file}}", "skipped_malformed": "تم تخطي {{count}} (مشوه)", "dropped_overlap": "تم إسقاط {{count}} (تداخل)", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 691cc88a..af4ecec1 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "Wiederholungsversuch abgebrochen", "transcription_failed": "Transkription fehlgeschlagen: {{message}}", "import_srt_no_job": "Laden Sie zuerst ein Video hoch oder nehmen Sie es auf – es gibt keinen Job, dem Sie Untertitel hinzufügen müssen.", + "import_srt_after_speakers": "SRT vorgemerkt. Zuerst wird die Sprecheranalyse abgeschlossen, damit geklonte Stimmen erhalten bleiben.", "imported_cues": "{{count}} Cue(s) von {{file}} importiert", "skipped_malformed": "{{count}} übersprungen (fehlerhaft)", "dropped_overlap": "{{count}} gelöscht (Überlappung)", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c2671ed7..8d9c4f14 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2707,6 +2707,7 @@ "session_expired": "This dub session expired or was cleaned up — re-upload your video to start a new one.", "transcription_failed": "Transcription failed: {{message}}", "import_srt_no_job": "Upload or ingest a video first — there is no job to attach subtitles to.", + "import_srt_after_speakers": "SRT queued. Speaker analysis will finish first so cloned voices are preserved.", "imported_cues": "Imported {{count}} cue(s) from {{file}}", "skipped_malformed": "{{count}} skipped (malformed)", "dropped_overlap": "{{count}} dropped (overlap)", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 60a4b095..d9a7fd87 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "Reintento cancelado", "transcription_failed": "Error de transcripción: {{message}}", "import_srt_no_job": "Primero cargue o ingiera un video: no hay ningún trabajo al que adjuntar subtítulos.", + "import_srt_after_speakers": "SRT en cola. Primero finalizará el análisis de hablantes para conservar las voces clonadas.", "imported_cues": "{{count}} cue(s) importadas de {{file}}", "skipped_malformed": "{{count}} omitido (mal formado)", "dropped_overlap": "{{count}} caído (superposición)", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index c68a977e..08982478 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "Nouvelle tentative annulée", "transcription_failed": "Échec de la transcription : {{message}}", "import_srt_no_job": "Téléchargez ou ingérez d’abord une vidéo – il n’y a aucune tâche à laquelle attacher des sous-titres.", + "import_srt_after_speakers": "SRT mis en attente. L’analyse des locuteurs se terminera d’abord afin de préserver les voix clonées.", "imported_cues": "Cues {{count}} importées de {{file}}", "skipped_malformed": "{{count}} ignoré (mal formé)", "dropped_overlap": "{{count}} supprimé (chevauchement)", diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index e31a249c..fcf3f2a7 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "पुनः प्रयास रद्द कर दिया गया", "transcription_failed": "प्रतिलेखन विफल: {{message}}", "import_srt_no_job": "पहले एक वीडियो अपलोड या इंजेस्ट करें - इसमें उपशीर्षक संलग्न करना कोई काम नहीं है।", + "import_srt_after_speakers": "SRT कतार में है। क्लोन की गई आवाज़ों को सुरक्षित रखने के लिए पहले वक्ता विश्लेषण पूरा होगा।", "imported_cues": "{{file}} से आयातित {{count}} संकेत", "skipped_malformed": "{{count}} छोड़ दिया गया (विकृत)", "dropped_overlap": "{{count}} गिरा दिया गया (ओवरलैप)", diff --git a/frontend/src/i18n/locales/id.json b/frontend/src/i18n/locales/id.json index 13c01da5..c43aa41b 100644 --- a/frontend/src/i18n/locales/id.json +++ b/frontend/src/i18n/locales/id.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "Coba lagi dibatalkan", "transcription_failed": "Transkripsi gagal: {{message}}", "import_srt_no_job": "Unggah atau serap video terlebih dahulu — tidak ada tugas untuk melampirkan subtitle.", + "import_srt_after_speakers": "SRT masuk antrean. Analisis pembicara akan diselesaikan lebih dahulu agar suara kloning tetap dipertahankan.", "imported_cues": "{{count}} isyarat yang diimpor dari {{file}}", "skipped_malformed": "{{count}} dilewati (format salah)", "dropped_overlap": "{{count}} terjatuh (tumpang tindih)", diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 08f7ba05..1eb01d40 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "Nuovo tentativo annullato", "transcription_failed": "Trascrizione non riuscita: {{message}}", "import_srt_no_job": "Carica o importa prima un video: non esiste un lavoro a cui allegare i sottotitoli.", + "import_srt_after_speakers": "SRT in coda. Prima verrà completata l’analisi dei parlanti per conservare le voci clonate.", "imported_cues": "Cue {{count}} importati da {{file}}", "skipped_malformed": "{{count}} saltato (formato non valido)", "dropped_overlap": "{{count}} eliminato (sovrapposizione)", diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index e3ff43bc..78a6bc38 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "再試行がキャンセルされました", "transcription_failed": "転写に失敗しました: {{message}}", "import_srt_no_job": "まずビデオをアップロードまたは取り込みます。字幕を付ける作業はありません。", + "import_srt_after_speakers": "SRTをキューに追加しました。クローン音声を保持するため、先に話者分析を完了します。", "imported_cues": "{{file}} から {{count}} キューをインポートしました", "skipped_malformed": "{{count}} がスキップされました (不正な形式)", "dropped_overlap": "{{count}} が削除されました (重複)", diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index b991ec7d..5c819a40 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "재시도가 취소되었습니다.", "transcription_failed": "스크립트 작성 실패: {{message}}", "import_srt_no_job": "먼저 비디오를 업로드하거나 처리하십시오. 자막을 첨부할 작업이 없습니다.", + "import_srt_after_speakers": "SRT가 대기열에 추가되었습니다. 복제된 음성을 유지하기 위해 화자 분석을 먼저 완료합니다.", "imported_cues": "{{file}}에서 {{count}} 큐를 가져왔습니다.", "skipped_malformed": "{{count}} 건너뛰기(잘못된 형식)", "dropped_overlap": "{{count}} 삭제됨(겹침)", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index c182d89b..07b09fe2 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "Nieuwe poging geannuleerd", "transcription_failed": "Transcriptie mislukt: {{message}}", "import_srt_no_job": "Upload of neem eerst een video op - er is geen taak om ondertitels aan toe te voegen.", + "import_srt_after_speakers": "SRT staat in de wachtrij. De sprekeranalyse wordt eerst voltooid zodat gekloonde stemmen behouden blijven.", "imported_cues": "{{count}} cue(s) geïmporteerd uit {{file}}", "skipped_malformed": "{{count}} overgeslagen (misvormd)", "dropped_overlap": "{{count}} weggevallen (overlap)", diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index f8a8c16a..54f92339 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "Ponowna próba anulowana", "transcription_failed": "Transkrypcja nie powiodła się: {{message}}", "import_srt_no_job": "Najpierw prześlij lub pobierz film — nie ma zadania, do którego można by dołączyć napisy.", + "import_srt_after_speakers": "Plik SRT dodano do kolejki. Najpierw zakończy się analiza mówców, aby zachować sklonowane głosy.", "imported_cues": "Zaimportowano {{count}} pamięci z {{file}}", "skipped_malformed": "{{count}} pominięty (zniekształcony)", "dropped_overlap": "{{count}} upuszczony (nakładanie się)", diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 194829c8..b38589b1 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "Retry cancelled", "transcription_failed": "Falha na transcrição: {{message}}", "import_srt_no_job": "Carregue ou ingira um vídeo primeiro – não há trabalho para anexar legendas.", + "import_srt_after_speakers": "SRT na fila. A análise dos falantes será concluída primeiro para preservar as vozes clonadas.", "imported_cues": "Sugestão(s) {{count}} importada(s) de {{file}}", "skipped_malformed": "{{count}} ignorado (malformado)", "dropped_overlap": "{{count}} caiu (sobreposição)", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 2d2a8140..455f6b00 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "Повторная попытка отменена", "transcription_failed": "Транскрипция не удалась: {{message}}", "import_srt_no_job": "Сначала загрузите или импортируйте видео — прикрепить субтитры к нему не нужно.", + "import_srt_after_speakers": "SRT поставлен в очередь. Сначала завершится анализ говорящих, чтобы сохранить клонированные голоса.", "imported_cues": "Импортированы реплики {{count}} из {{file}}.", "skipped_malformed": "{{count}} пропущен (неверный формат)", "dropped_overlap": "{{count}} удалено (перекрытие)", diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index 402c199e..cf24b8d4 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "Försök igen avbröts", "transcription_failed": "Transkription misslyckades: {{message}}", "import_srt_no_job": "Ladda upp eller mata in en video först – det finns inget jobb att bifoga undertexter till.", + "import_srt_after_speakers": "SRT har köats. Talaranalysen slutförs först så att klonade röster bevaras.", "imported_cues": "Importerade {{count}} signal(er) från {{file}}", "skipped_malformed": "{{count}} hoppade över (felformat)", "dropped_overlap": "{{count}} tappade (överlappning)", diff --git a/frontend/src/i18n/locales/th.json b/frontend/src/i18n/locales/th.json index 4a0cc9ea..8021d4fc 100644 --- a/frontend/src/i18n/locales/th.json +++ b/frontend/src/i18n/locales/th.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "ลองอีกครั้ง ยกเลิก", "transcription_failed": "การถอดเสียงล้มเหลว: {{message}}", "import_srt_no_job": "อัปโหลดหรือนำเข้าวิดีโอก่อน ไม่มีงานให้แนบคำบรรยาย", + "import_srt_after_speakers": "เพิ่ม SRT ลงในคิวแล้ว ระบบจะวิเคราะห์ผู้พูดให้เสร็จก่อนเพื่อรักษาเสียงที่โคลนไว้", "imported_cues": "นำเข้า {{count}} คิวจาก {{file}}", "skipped_malformed": "{{count}} ข้ามไป (มีรูปแบบไม่ถูกต้อง)", "dropped_overlap": "{{count}} ลดลง (ทับซ้อนกัน)", diff --git a/frontend/src/i18n/locales/tr.json b/frontend/src/i18n/locales/tr.json index d16c8989..84cd2a59 100644 --- a/frontend/src/i18n/locales/tr.json +++ b/frontend/src/i18n/locales/tr.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "Yeniden deneme iptal edildi", "transcription_failed": "Transkripsiyon başarısız oldu: {{message}}", "import_srt_no_job": "Önce bir video yükleyin veya alın; altyazı eklenecek bir iş yoktur.", + "import_srt_after_speakers": "SRT sıraya alındı. Klonlanmış sesleri korumak için önce konuşmacı analizi tamamlanacak.", "imported_cues": "{{file}}'den {{count}} işaret(ler) içe aktarıldı", "skipped_malformed": "{{count}} atlandı (hatalı biçimlendirilmiş)", "dropped_overlap": "{{count}} düştü (örtüşme)", diff --git a/frontend/src/i18n/locales/uk.json b/frontend/src/i18n/locales/uk.json index fa12041c..1a3644f7 100644 --- a/frontend/src/i18n/locales/uk.json +++ b/frontend/src/i18n/locales/uk.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "Повторну спробу скасовано", "transcription_failed": "Помилка транскрипції: {{message}}", "import_srt_no_job": "Спершу завантажте або завантажте відео — немає завдання додавати субтитри.", + "import_srt_after_speakers": "SRT додано до черги. Спершу завершиться аналіз мовців, щоб зберегти клоновані голоси.", "imported_cues": "Імпортовано {{count}} репліки з {{file}}", "skipped_malformed": "{{count}} пропущено (неправильно)", "dropped_overlap": "{{count}} вилучено (перекриття)", diff --git a/frontend/src/i18n/locales/vi.json b/frontend/src/i18n/locales/vi.json index a0376dfa..464b7ffe 100644 --- a/frontend/src/i18n/locales/vi.json +++ b/frontend/src/i18n/locales/vi.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "Đã hủy thử lại", "transcription_failed": "Phiên âm không thành công: {{message}}", "import_srt_no_job": "Trước tiên hãy tải lên hoặc nhập video — không cần phải đính kèm phụ đề.", + "import_srt_after_speakers": "Đã xếp SRT vào hàng đợi. Phân tích người nói sẽ hoàn tất trước để giữ nguyên giọng nói nhân bản.", "imported_cues": "Đã nhập {{count}} tín hiệu từ {{file}}", "skipped_malformed": "{{count}} bị bỏ qua (không đúng định dạng)", "dropped_overlap": "{{count}} bị rơi (chồng chéo)", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 79f64acd..c8ffdb85 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2043,6 +2043,7 @@ "retry_cancelled": "重试已取消", "transcription_failed": "转录失败:{{message}}", "import_srt_no_job": "请先上传或导入视频——尚无作业可附加字幕。", + "import_srt_after_speakers": "SRT 已加入队列。系统会先完成说话人分析,以保留克隆声音。", "imported_cues": "从 {{file}} 导入了 {{count}} 条字幕提示", "skipped_malformed": "{{count}} 条已跳过(格式错误)", "dropped_overlap": "{{count}} 条因重叠被丢弃", diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 8cbf61ea..6ef9791d 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -2036,6 +2036,7 @@ "retry_cancelled": "重試已取消", "transcription_failed": "轉錄失敗:{{message}}", "import_srt_no_job": "首先上传或摄取视频 - 没有附加字幕的作业。", + "import_srt_after_speakers": "SRT 已加入佇列。系統會先完成說話者分析,以保留複製語音。", "imported_cues": "從 {{file}} 導入了 {{count}} 提示", "skipped_malformed": "{{count}} 已跳過(格式錯誤)", "dropped_overlap": "{{count}} 掉落(重疊)", diff --git a/frontend/src/test/dubQueuedSrtImport.test.js b/frontend/src/test/dubQueuedSrtImport.test.js new file mode 100644 index 00000000..b2e67fad --- /dev/null +++ b/frontend/src/test/dubQueuedSrtImport.test.js @@ -0,0 +1,396 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAppStore } from '../store'; + +const dubApi = vi.hoisted(() => ({ + dubUpload: vi.fn(), + dubIngestUrl: vi.fn(), + dubAbort: vi.fn(), + dubCleanupSegments: vi.fn(), + dubTranslate: vi.fn(), + dubGenerate: vi.fn(), + tasksStreamUrl: vi.fn((taskId) => `/tasks/${taskId}`), + tasksCancel: vi.fn(), + transcribeStreamUrl: vi.fn((jobId) => `/transcribe/${jobId}`), + dubImportSrt: vi.fn(), +})); +vi.mock('../api/dub', () => ({ + ...dubApi, + DUB_COOKIE_TRANSPORT_ERROR: 'cookie_transport_error', + DUB_COOKIE_SIZE_ERROR: 'cookie_size_error', +})); + +const setupApi = vi.hoisted(() => ({ + cancelInstallModel: vi.fn(), +})); +vi.mock('../api/setup', () => ({ + ...setupApi, + installModel: vi.fn(), + listModels: vi.fn(), + setupDownloadStreamUrl: () => '/setup/download-stream', +})); + +vi.mock('../api/client', () => ({ + apiPost: vi.fn(), + apiFetch: vi.fn(), + apiJson: vi.fn(), + API: '', +})); + +import useDubWorkflow, { shouldQueueSrtImport } from '../hooks/useDubWorkflow'; + +const baseState = useAppStore.getState(); +let streams; + +class FakeEventSource { + static CLOSED = 2; + + constructor(url) { + this.url = url; + this.readyState = 1; + this.listeners = new Map(); + streams.push(this); + } + + addEventListener(name, handler) { + this.listeners.set(name, handler); + } + + close() { + this.readyState = FakeEventSource.CLOSED; + } + + emit(name, data = {}) { + const event = { data: JSON.stringify(data) }; + if (name === 'message') this.onmessage?.(event); + else this.listeners.get(name)?.(event); + } +} + +function renderWorkflow() { + return renderHook(() => + useDubWorkflow({ + loadProjects: vi.fn(), + loadProfiles: vi.fn(), + loadDubHistory: vi.fn(), + setLastGenFingerprints: vi.fn(), + }), + ); +} + +describe('SRT import during source-speaker analysis', () => { + beforeEach(() => { + streams = []; + globalThis.EventSource = FakeEventSource; + useAppStore.setState(baseState, true); + useAppStore.setState({ dubJobId: '', dubStep: 'idle', dubSegments: [] }); + for (const mock of Object.values(dubApi)) mock.mockReset?.(); + dubApi.tasksStreamUrl.mockImplementation((taskId) => `/tasks/${taskId}`); + dubApi.transcribeStreamUrl.mockImplementation((jobId) => `/transcribe/${jobId}`); + dubApi.dubAbort.mockResolvedValue({}); + dubApi.dubImportSrt.mockResolvedValue({ + segments: [{ id: 'srt', text: 'selected subtitle' }], + stats: { imported: 1 }, + }); + setupApi.cancelInstallModel.mockReset().mockResolvedValue({}); + }); + + it('queues only while source analysis is incomplete', () => { + expect(shouldQueueSrtImport('uploading')).toBe(true); + expect(shouldQueueSrtImport('transcribing')).toBe(true); + expect(shouldQueueSrtImport('transcribing', true)).toBe(false); + expect(shouldQueueSrtImport('editing')).toBe(false); + }); + + it.each([ + ['upload', 'job-upload'], + ['URL ingest', 'job-url'], + ])('applies the selected file after %s analysis completes', async (source, jobId) => { + const file = new File(['subtitle'], 'selected.srt'); + const { result } = renderWorkflow(); + let operation; + + if (source === 'upload') { + dubApi.dubUpload.mockResolvedValue({ job_id: jobId, task_id: `prep-${jobId}` }); + act(() => { + operation = result.current.handleDubUpload(new File(['video'], 'source.mp4')); + }); + } else { + dubApi.dubIngestUrl.mockResolvedValue({ job_id: jobId, task_id: `prep-${jobId}` }); + act(() => { + operation = result.current.handleDubIngestUrl('https://example.test/video'); + }); + } + + await waitFor(() => expect(streams).toHaveLength(1)); + act(() => streams[0].emit('message', { type: 'ready' })); + await waitFor(() => expect(useAppStore.getState().dubStep).toBe('transcribing')); + await act(async () => result.current.handleDubImportSrt(file)); + expect(dubApi.dubImportSrt).not.toHaveBeenCalled(); + + await waitFor(() => expect(streams).toHaveLength(2)); + act(() => { + streams[1].emit('final', { segments: [{ id: 'asr', text: 'generated' }] }); + streams[1].emit('done'); + }); + await act(async () => operation); + + expect(dubApi.dubImportSrt).toHaveBeenCalledWith(jobId, file, { + signal: expect.any(AbortSignal), + }); + expect(useAppStore.getState().dubSegments).toEqual([ + expect.objectContaining({ id: 'srt', text: 'selected subtitle' }), + ]); + expect(useAppStore.getState().dubStep).toBe('editing'); + }); + + it('retains the queued file through transcription and import failures until retry succeeds', async () => { + useAppStore.setState({ dubJobId: 'job-retry', dubStep: 'transcribing' }); + const file = new File(['subtitle'], 'retry.srt'); + dubApi.dubImportSrt + .mockRejectedValueOnce(new Error('SRT import failed')) + .mockResolvedValueOnce({ + segments: [{ id: 'srt', text: 'selected subtitle' }], + stats: { imported: 1 }, + }); + const { result } = renderWorkflow(); + await act(async () => result.current.handleDubImportSrt(file)); + + let failedAttempt; + act(() => { + failedAttempt = result.current.handleDubRetryTranscribe(); + }); + await waitFor(() => expect(streams).toHaveLength(1)); + act(() => streams[0].emit('error', { detail: 'transcription failed' })); + await act(async () => failedAttempt); + expect(dubApi.dubImportSrt).not.toHaveBeenCalled(); + + let importFailure; + act(() => { + importFailure = result.current.handleDubRetryTranscribe(); + }); + await waitFor(() => expect(streams).toHaveLength(2)); + act(() => { + streams[1].emit('final', { segments: [{ id: 'asr', text: 'generated' }] }); + streams[1].emit('done'); + }); + await act(async () => importFailure); + + expect(dubApi.dubImportSrt).toHaveBeenCalledOnce(); + expect(useAppStore.getState().dubError).toBe('SRT import failed'); + expect(useAppStore.getState().dubStep).toBe('editing'); + + let successfulRetry; + act(() => { + successfulRetry = result.current.handleDubRetryTranscribe(); + }); + await waitFor(() => expect(streams).toHaveLength(3)); + act(() => { + streams[2].emit('final', { segments: [{ id: 'asr', text: 'generated again' }] }); + streams[2].emit('done'); + }); + await act(async () => successfulRetry); + + expect(dubApi.dubImportSrt).toHaveBeenLastCalledWith('job-retry', file, { + signal: expect.any(AbortSignal), + }); + expect(dubApi.dubImportSrt).toHaveBeenCalledTimes(2); + expect(useAppStore.getState().dubSegments[0].text).toBe('selected subtitle'); + }); + + it('replaces a queued file when the user imports a newer SRT after transcription fails', async () => { + useAppStore.setState({ dubJobId: 'job-replace', dubStep: 'transcribing' }); + const oldFile = new File(['old'], 'old.srt'); + const newFile = new File(['new'], 'new.srt'); + const { result } = renderWorkflow(); + await act(async () => result.current.handleDubImportSrt(oldFile)); + + let failedAttempt; + act(() => { + failedAttempt = result.current.handleDubRetryTranscribe(); + }); + await waitFor(() => expect(streams).toHaveLength(1)); + act(() => streams[0].emit('error', { detail: 'transcription failed' })); + await act(async () => failedAttempt); + await waitFor(() => expect(useAppStore.getState().dubStep).toBe('idle')); + + await act(async () => result.current.handleDubImportSrt(newFile)); + expect(dubApi.dubImportSrt).toHaveBeenCalledOnce(); + expect(dubApi.dubImportSrt.mock.calls[0][1]).toBe(newFile); + + let retry; + act(() => { + retry = result.current.handleDubRetryTranscribe(); + }); + await waitFor(() => expect(streams).toHaveLength(2)); + act(() => { + streams[1].emit('final', { segments: [{ id: 'asr', text: 'generated' }] }); + streams[1].emit('done'); + }); + await act(async () => retry); + + expect(dubApi.dubImportSrt).toHaveBeenCalledOnce(); + expect(useAppStore.getState().dubSegments[0].text).toBe('generated'); + }); + + it('ignores an older manual import that finishes after a newer one', async () => { + useAppStore.setState({ dubJobId: 'job-current', dubStep: 'editing', dubSegments: [] }); + const oldFile = new File(['old'], 'old.srt'); + const newFile = new File(['new'], 'new.srt'); + const resolvers = new Map(); + dubApi.dubImportSrt.mockImplementation( + (_jobId, file) => + new Promise((resolve) => { + resolvers.set(file.name, resolve); + }), + ); + const { result } = renderWorkflow(); + let oldImport; + let newImport; + act(() => { + oldImport = result.current.handleDubImportSrt(oldFile); + newImport = result.current.handleDubImportSrt(newFile); + }); + await waitFor(() => expect(dubApi.dubImportSrt).toHaveBeenCalledTimes(2)); + + resolvers.get('new.srt')({ segments: [{ id: 'new', text: 'new subtitle' }] }); + await act(async () => newImport); + resolvers.get('old.srt')({ segments: [{ id: 'old', text: 'old subtitle' }] }); + await act(async () => oldImport); + + expect(useAppStore.getState().dubSegments).toEqual([ + expect.objectContaining({ id: 'new', text: 'new subtitle' }), + ]); + }); + + it('imports a replacement selected while the deferred import is awaiting', async () => { + const oldFile = new File(['old'], 'old.srt'); + const newFile = new File(['new'], 'new.srt'); + const resolvers = new Map(); + dubApi.dubUpload.mockResolvedValue({ job_id: 'job-replace-live', task_id: 'prep-live' }); + dubApi.dubImportSrt.mockImplementation( + (_jobId, file) => + new Promise((resolve) => { + resolvers.set(file.name, resolve); + }), + ); + const { result } = renderWorkflow(); + let upload; + act(() => { + upload = result.current.handleDubUpload(new File(['video'], 'source.mp4')); + }); + await waitFor(() => expect(streams).toHaveLength(1)); + act(() => streams[0].emit('message', { type: 'ready' })); + await waitFor(() => expect(useAppStore.getState().dubStep).toBe('transcribing')); + await act(async () => result.current.handleDubImportSrt(oldFile)); + await waitFor(() => expect(streams).toHaveLength(2)); + act(() => { + streams[1].emit('final', { segments: [{ id: 'asr', text: 'generated' }] }); + streams[1].emit('done'); + }); + await waitFor(() => expect(dubApi.dubImportSrt).toHaveBeenCalledOnce()); + + await act(async () => result.current.handleDubImportSrt(newFile)); + act(() => resolvers.get('old.srt')({ segments: [{ id: 'old', text: 'old subtitle' }] })); + await waitFor(() => expect(dubApi.dubImportSrt).toHaveBeenCalledTimes(2)); + expect(dubApi.dubImportSrt.mock.calls[1][1]).toBe(newFile); + act(() => resolvers.get('new.srt')({ segments: [{ id: 'new', text: 'new subtitle' }] })); + await act(async () => upload); + + expect(useAppStore.getState().dubSegments).toEqual([ + expect.objectContaining({ id: 'new', text: 'new subtitle' }), + ]); + expect(useAppStore.getState().dubStep).toBe('editing'); + }); + + it('ignores an older manual import failure after a newer import succeeds', async () => { + useAppStore.setState({ dubJobId: 'job-current', dubStep: 'editing', dubSegments: [] }); + const oldFile = new File(['old'], 'old.srt'); + const newFile = new File(['new'], 'new.srt'); + const promises = new Map(); + dubApi.dubImportSrt.mockImplementation( + (_jobId, file) => + new Promise((resolve, reject) => { + promises.set(file.name, { resolve, reject }); + }), + ); + const { result } = renderWorkflow(); + let oldImport; + let newImport; + act(() => { + oldImport = result.current.handleDubImportSrt(oldFile); + newImport = result.current.handleDubImportSrt(newFile); + }); + await waitFor(() => expect(dubApi.dubImportSrt).toHaveBeenCalledTimes(2)); + + promises.get('new.srt').resolve({ segments: [{ id: 'new', text: 'new subtitle' }] }); + await act(async () => newImport); + promises.get('old.srt').reject(new Error('old import failed')); + await act(async () => oldImport); + + expect(useAppStore.getState().dubSegments).toEqual([ + expect.objectContaining({ id: 'new', text: 'new subtitle' }), + ]); + expect(useAppStore.getState().dubError).toBe(''); + }); + + it('aborts a deferred import without applying its result', async () => { + const file = new File(['subtitle'], 'abort.srt'); + dubApi.dubUpload.mockResolvedValue({ job_id: 'job-abort', task_id: 'prep-abort' }); + dubApi.dubImportSrt.mockImplementation( + (_jobId, _file, { signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + { once: true }, + ); + }), + ); + const { result } = renderWorkflow(); + let upload; + act(() => { + upload = result.current.handleDubUpload(new File(['video'], 'source.mp4')); + }); + await waitFor(() => expect(streams).toHaveLength(1)); + act(() => streams[0].emit('message', { type: 'ready' })); + await waitFor(() => expect(useAppStore.getState().dubStep).toBe('transcribing')); + await act(async () => result.current.handleDubImportSrt(file)); + await waitFor(() => expect(streams).toHaveLength(2)); + act(() => { + streams[1].emit('final', { segments: [{ id: 'asr', text: 'generated' }] }); + streams[1].emit('done'); + }); + await waitFor(() => expect(dubApi.dubImportSrt).toHaveBeenCalledOnce()); + + await act(async () => result.current.handleDubAbort()); + await act(async () => upload); + + expect(useAppStore.getState().dubSegments[0].text).toBe('generated'); + expect(useAppStore.getState().dubStep).toBe('idle'); + }); + + it('ignores a completed import after another job replaces it', async () => { + useAppStore.setState({ dubJobId: 'job-old', dubStep: 'editing', dubSegments: [] }); + let resolveImport; + dubApi.dubImportSrt.mockImplementation( + () => + new Promise((resolve) => { + resolveImport = resolve; + }), + ); + const { result } = renderWorkflow(); + let importOperation; + act(() => { + importOperation = result.current.handleDubImportSrt(new File(['subtitle'], 'old.srt')); + }); + await waitFor(() => expect(dubApi.dubImportSrt).toHaveBeenCalledOnce()); + act(() => useAppStore.setState({ dubJobId: 'job-new', dubSegments: [] })); + resolveImport({ segments: [{ id: 'stale', text: 'stale subtitle' }] }); + await act(async () => importOperation); + + expect(useAppStore.getState().dubJobId).toBe('job-new'); + expect(useAppStore.getState().dubSegments).toEqual([]); + }); +});