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:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}} (تداخل)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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}} गिरा दिया गया (ओवरलैप)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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}} が削除されました (重複)",
|
||||
|
||||
@@ -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}} 삭제됨(겹침)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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ę)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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}} удалено (перекрытие)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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}} ลดลง (ทับซ้อนกัน)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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}} вилучено (перекриття)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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}} 条因重叠被丢弃",
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user