From cbe5955c6ee8b7c196a5a77e3f885b3ef1004cca Mon Sep 17 00:00:00 2001 From: kura Date: Fri, 1 May 2026 22:31:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=88=86=E9=A1=B5=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9Ewin=E6=89=93=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- package.json | 4 +- scripts/prepare-ffmpeg-windows.ps1 | 83 +++ src-tauri/src/whisper.rs | 98 ++- src/components/SubtitleEditor.vue | 42 +- src/components/TaskQueue.vue | 1 + src/locales/en.ts | 1 + src/locales/zh-CN.ts | 1 + src/style.css | 79 +++ yarn.lock | 941 ++++++++++------------------- 10 files changed, 604 insertions(+), 649 deletions(-) create mode 100644 scripts/prepare-ffmpeg-windows.ps1 diff --git a/.gitignore b/.gitignore index d50c0e2..e9c74d2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /node_modules /src-tauri/target/ **/*.rs.bk -/src-tauri/model/ \ No newline at end of file +/src-tauri/model/ +/src-tauri/vendor/ffmpeg/ diff --git a/package.json b/package.json index 0181ef1..0918ac4 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,11 @@ "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", "prepare-ffmpeg-macos": "sh ./scripts/prepare-bundled-ffmpeg.sh", + "prepare-ffmpeg-windows": "powershell -ExecutionPolicy Bypass -File ./scripts/prepare-ffmpeg-windows.ps1", "prepare-licenses-macos": "python3 ./scripts/prepare-bundled-licenses.py", "tauri-build-app": "npm run prepare-ffmpeg-macos && npm run prepare-licenses-macos && tauri build --bundles app", - "tauri-build-dmg": "sh ./scripts/build-macos-dmg.sh" + "tauri-build-dmg": "sh ./scripts/build-macos-dmg.sh", + "tauri-build-windows": "tauri build --bundles nsis" }, "dependencies": { "@tauri-apps/api": "^2.0.0", diff --git a/scripts/prepare-ffmpeg-windows.ps1 b/scripts/prepare-ffmpeg-windows.ps1 new file mode 100644 index 0000000..e408808 --- /dev/null +++ b/scripts/prepare-ffmpeg-windows.ps1 @@ -0,0 +1,83 @@ +param( + [string]$FFmpegSource = "" +) + +$ErrorActionPreference = "Stop" +$ROOT_DIR = Split-Path -Parent (Split-Path -Parent $PSCommandPath) +$VENDOR_DIR = Join-Path $ROOT_DIR "src-tauri\vendor\ffmpeg\windows-x86_64" +$BIN_DIR = Join-Path $VENDOR_DIR "bin" +$FFMPEG_URL = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.7z" + +# Ensure target directory exists +if (-not (Test-Path $BIN_DIR)) { + New-Item -ItemType Directory -Path $BIN_DIR -Force | Out-Null +} + +# Strategy 1: if -FFmpegSource explicitly provided, use it +if ($FFmpegSource) { + if (-not (Test-Path $FFmpegSource)) { + Write-Host "ffmpeg not found at: $FFmpegSource" -ForegroundColor Red + exit 1 + } + Write-Host "Copying ffmpeg from: $FFmpegSource" + Copy-Item -Path $FFmpegSource -Destination (Join-Path $BIN_DIR "ffmpeg.exe") -Force + Write-Host "ffmpeg bundled at: $BIN_DIR\ffmpeg.exe" -ForegroundColor Green + exit 0 +} + +# Strategy 2: download Gyan Essentials build via 7-Zip +$sevenZipPaths = @( + "${env:ProgramFiles}\7-Zip\7z.exe", + "${env:ProgramFiles(x86)}\7-Zip\7z.exe", + "${env:LOCALAPPDATA}\Programs\7-Zip\7z.exe" +) +$sevenZip = $sevenZipPaths | Where-Object { Test-Path $_ } | Select-Object -First 1 + +if ($sevenZip) { + $tempDir = Join-Path $env:TEMP "ffmpeg-essentials" + $archivePath = Join-Path $tempDir "ffmpeg.7z" + + try { + Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + + Write-Host "Downloading ffmpeg essentials from: $FFMPEG_URL" -ForegroundColor Cyan + Invoke-WebRequest -Uri $FFMPEG_URL -OutFile $archivePath -UseBasicParsing + + Write-Host "Extracting..." -ForegroundColor Cyan + & $sevenZip x $archivePath -o"$tempDir\out" -y -bso0 | Out-Null + + $extractedExe = Get-ChildItem -Path "$tempDir\out" -Recurse -Filter "ffmpeg.exe" | Select-Object -First 1 -ExpandProperty FullName + if (-not $extractedExe) { + throw "ffmpeg.exe not found in extracted archive" + } + + Copy-Item -Path $extractedExe -Destination (Join-Path $BIN_DIR "ffmpeg.exe") -Force + Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue + + $size = (Get-Item (Join-Path $BIN_DIR "ffmpeg.exe")).Length / 1MB + Write-Host "ffmpeg essentials bundled at: $BIN_DIR\ffmpeg.exe ($([math]::Round($size)) MB)" -ForegroundColor Green + exit 0 + } + catch { + Write-Host "Download/extraction failed: $_" -ForegroundColor Yellow + Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } +} +else { + Write-Host "7-Zip not found, recommend installing it for automatic download." -ForegroundColor Yellow + Write-Host "Download manually from: $FFMPEG_URL and extract ffmpeg.exe to $BIN_DIR" -ForegroundColor Yellow +} + +# Strategy 3: fall back to copying system ffmpeg +$systemFFmpeg = (Get-Command ffmpeg -ErrorAction SilentlyContinue).Source +if ($systemFFmpeg) { + Write-Host "Falling back to system ffmpeg: $systemFFmpeg" -ForegroundColor Cyan + Copy-Item -Path $systemFFmpeg -Destination (Join-Path $BIN_DIR "ffmpeg.exe") -Force + $size = (Get-Item (Join-Path $BIN_DIR "ffmpeg.exe")).Length / 1MB + Write-Host "ffmpeg bundled at: $BIN_DIR\ffmpeg.exe ($([math]::Round($size)) MB)" -ForegroundColor Green + exit 0 +} + +Write-Host "ERROR: No ffmpeg found. Install ffmpeg or specify path via -FFmpegSource." -ForegroundColor Red +exit 1 diff --git a/src-tauri/src/whisper.rs b/src-tauri/src/whisper.rs index 59d312b..072d54b 100644 --- a/src-tauri/src/whisper.rs +++ b/src-tauri/src/whisper.rs @@ -109,45 +109,81 @@ impl WhisperEngine { || (total_seconds > 60.0 && vad_text_len < (total_seconds / 3.0) as usize)); if should_retry_full_audio { - on_log(format!( - "whisper: VAD result looks incomplete, retrying full audio (segments={}, chars={}, end={:.2}s/{:.2}s, coverage={:.1}%)", - segments.len(), - vad_text_len, - vad_end, - total_seconds, - vad_coverage * 100.0 - ))?; - on_reset_segments()?; - let full_audio_segments = transcribe_clip( - &mut state, - &audio, - 0, - 0.0, - total_seconds, - task_id, - detected_language, - target_lang, - should_translate, - 0, - &mut on_segment, - &mut on_log, - )?; + // 如果 VAD 覆盖率足够高且仅尾部缺失,只转录尾部而非全音频 + // 避免数小时的长音频被重新处理一遍 + let is_tail_only = !segments.is_empty() + && vad_coverage >= 0.60 + && vad_end + 5.0 < total_seconds + && vad_text_len >= (total_seconds / 3.0) as usize; - if should_prefer_full_audio(&segments, &full_audio_segments, total_seconds) { + if is_tail_only { + let tail_start = vad_end.max(0.0); on_log(format!( - "whisper: using full-audio transcript (vad_segments={}, full_segments={})", - segments.len(), - full_audio_segments.len() + "whisper: VAD tail incomplete, retrying tail ({:.2}s-{:.2}s, existing_segments={})", + tail_start, + total_seconds, + segments.len() + ))?; + let tail_segments = transcribe_clip( + &mut state, + &audio, + normalized_ranges.len(), + tail_start, + total_seconds, + task_id, + detected_language, + target_lang, + should_translate, + segments.len(), + &mut on_segment, + &mut on_log, + )?; + segments.extend(tail_segments); + on_log(format!( + "whisper: total segments after tail retry={}", + segments.len() ))?; - segments = full_audio_segments; } else { on_log(format!( - "whisper: keeping VAD-based transcript (vad_segments={}, full_segments={})", + "whisper: VAD result looks incomplete, retrying full audio (segments={}, chars={}, end={:.2}s/{:.2}s, coverage={:.1}%)", segments.len(), - full_audio_segments.len() + vad_text_len, + vad_end, + total_seconds, + vad_coverage * 100.0 ))?; on_reset_segments()?; - segments.iter().cloned().try_for_each(&mut on_segment)?; + let full_audio_segments = transcribe_clip( + &mut state, + &audio, + 0, + 0.0, + total_seconds, + task_id, + detected_language, + target_lang, + should_translate, + 0, + &mut on_segment, + &mut on_log, + )?; + + if should_prefer_full_audio(&segments, &full_audio_segments, total_seconds) { + on_log(format!( + "whisper: using full-audio transcript (vad_segments={}, full_segments={})", + segments.len(), + full_audio_segments.len() + ))?; + segments = full_audio_segments; + } else { + on_log(format!( + "whisper: keeping VAD-based transcript (vad_segments={}, full_segments={})", + segments.len(), + full_audio_segments.len() + ))?; + on_reset_segments()?; + segments.iter().cloned().try_for_each(&mut on_segment)?; + } } } diff --git a/src/components/SubtitleEditor.vue b/src/components/SubtitleEditor.vue index e4d2f17..d7a6d89 100644 --- a/src/components/SubtitleEditor.vue +++ b/src/components/SubtitleEditor.vue @@ -2,6 +2,8 @@ import { computed, ref, nextTick, watch } from 'vue' import type { SubtitleSegment, SubtitleTask } from '../lib/types' +const PAGE_SIZE = 200 + const props = defineProps<{ task: SubtitleTask | null logs: string[] @@ -38,6 +40,33 @@ const sortedSegments = computed(() => }), ) +const currentPage = ref(1) +const totalPages = computed(() => Math.max(1, Math.ceil(sortedSegments.value.length / PAGE_SIZE))) +const hasPagination = computed(() => sortedSegments.value.length > PAGE_SIZE) + +const visibleSegments = computed(() => { + const start = (currentPage.value - 1) * PAGE_SIZE + return sortedSegments.value.slice(start, start + PAGE_SIZE) +}) + +watch(totalPages, (newTotal) => { + if (currentPage.value > newTotal) { + currentPage.value = newTotal + } +}) + +function goToPage(page: number) { + currentPage.value = Math.max(1, Math.min(page, totalPages.value)) +} + +function prevPage() { + goToPage(currentPage.value - 1) +} + +function nextPage() { + goToPage(currentPage.value + 1) +} + const logsExpanded = ref(false) const logContainerRef = ref(null) @@ -98,8 +127,14 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
+
+ + {{ $t('editor.pageInfo', { current: currentPage, total: totalPages }) }} + + {{ $t('editor.segments', { count: sortedSegments.length }) }} +
@@ -116,6 +151,11 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) { @change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)" />
+
+ + {{ $t('editor.pageInfo', { current: currentPage, total: totalPages }) }} + +
diff --git a/src/components/TaskQueue.vue b/src/components/TaskQueue.vue index 8379895..f8af299 100644 --- a/src/components/TaskQueue.vue +++ b/src/components/TaskQueue.vue @@ -55,6 +55,7 @@ function isActive(task: SubtitleTask): boolean { v-for="task in tasks" :key="task.id" class="task-item-wrapper" + :class="{ processing: isActive(task) }" >