Preserve cloned voices during queued SRT imports (#1745)

Closes #1709.

Queues SRT selection until speaker analysis and clone extraction finish, then applies the newest selected subtitle file with stale-result, failure, replacement, retry, and abort guards.
This commit is contained in:
Palash Debnath
2026-09-01 23:15:43 +05:30
committed by GitHub
parent 51bbf50ce3
commit a4f68e4ea0
25 changed files with 541 additions and 43 deletions
+1
View File
@@ -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)
+2 -1
View File
@@ -117,10 +117,11 @@ export interface DubImportSrtResponse {
export async function dubImportSrt(
jobId: string,
file: File | Blob,
{ signal }: { signal?: AbortSignal } = {},
): Promise<DubImportSrtResponse> {
const fd = new FormData();
fd.append('file', file);
return apiPost<DubImportSrtResponse>(`/dub/import-srt/${jobId}`, fd);
return apiPost<DubImportSrtResponse>(`/dub/import-srt/${jobId}`, fd, { signal });
}
export interface ParsedSubtitleCue {
+121 -42
View File
@@ -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 () => {
+1
View File
@@ -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}} (تداخل)",
+1
View File
@@ -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)",
+1
View File
@@ -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)",
+1
View File
@@ -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)",
+1
View File
@@ -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 dabord une vidéo il ny a aucune tâche à laquelle attacher des sous-titres.",
"import_srt_after_speakers": "SRT mis en attente. Lanalyse des locuteurs se terminera dabord 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)",
+1
View File
@@ -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}} गिरा दिया गया (ओवरलैप)",
+1
View File
@@ -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)",
+1
View File
@@ -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 lanalisi 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)",
+1
View File
@@ -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}} が削除されました (重複)",
+1
View File
@@ -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}} 삭제됨(겹침)",
+1
View File
@@ -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)",
+1
View File
@@ -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ę)",
+1
View File
@@ -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)",
+1
View File
@@ -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}} удалено (перекрытие)",
+1
View File
@@ -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)",
+1
View File
@@ -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}} ลดลง (ทับซ้อนกัน)",
+1
View File
@@ -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)",
+1
View File
@@ -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}} вилучено (перекриття)",
+1
View File
@@ -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)",
+1
View File
@@ -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}} 条因重叠被丢弃",
+1
View File
@@ -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}} 掉落(重疊)",
@@ -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([]);
});
});