新增分页,新增win打包
This commit is contained in:
parent
15ad4a1f07
commit
cbe5955c6e
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,3 +3,4 @@
|
|||||||
/src-tauri/target/
|
/src-tauri/target/
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
/src-tauri/model/
|
/src-tauri/model/
|
||||||
|
/src-tauri/vendor/ffmpeg/
|
||||||
|
|||||||
@ -10,9 +10,11 @@
|
|||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare-ffmpeg-macos": "sh ./scripts/prepare-bundled-ffmpeg.sh",
|
"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",
|
"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-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": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
|||||||
83
scripts/prepare-ffmpeg-windows.ps1
Normal file
83
scripts/prepare-ffmpeg-windows.ps1
Normal file
@ -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
|
||||||
@ -109,45 +109,81 @@ impl WhisperEngine {
|
|||||||
|| (total_seconds > 60.0 && vad_text_len < (total_seconds / 3.0) as usize));
|
|| (total_seconds > 60.0 && vad_text_len < (total_seconds / 3.0) as usize));
|
||||||
|
|
||||||
if should_retry_full_audio {
|
if should_retry_full_audio {
|
||||||
on_log(format!(
|
// 如果 VAD 覆盖率足够高且仅尾部缺失,只转录尾部而非全音频
|
||||||
"whisper: VAD result looks incomplete, retrying full audio (segments={}, chars={}, end={:.2}s/{:.2}s, coverage={:.1}%)",
|
// 避免数小时的长音频被重新处理一遍
|
||||||
segments.len(),
|
let is_tail_only = !segments.is_empty()
|
||||||
vad_text_len,
|
&& vad_coverage >= 0.60
|
||||||
vad_end,
|
&& vad_end + 5.0 < total_seconds
|
||||||
total_seconds,
|
&& vad_text_len >= (total_seconds / 3.0) as usize;
|
||||||
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,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
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!(
|
on_log(format!(
|
||||||
"whisper: using full-audio transcript (vad_segments={}, full_segments={})",
|
"whisper: VAD tail incomplete, retrying tail ({:.2}s-{:.2}s, existing_segments={})",
|
||||||
segments.len(),
|
tail_start,
|
||||||
full_audio_segments.len()
|
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 {
|
} else {
|
||||||
on_log(format!(
|
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(),
|
segments.len(),
|
||||||
full_audio_segments.len()
|
vad_text_len,
|
||||||
|
vad_end,
|
||||||
|
total_seconds,
|
||||||
|
vad_coverage * 100.0
|
||||||
))?;
|
))?;
|
||||||
on_reset_segments()?;
|
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)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
import { computed, ref, nextTick, watch } from 'vue'
|
import { computed, ref, nextTick, watch } from 'vue'
|
||||||
import type { SubtitleSegment, SubtitleTask } from '../lib/types'
|
import type { SubtitleSegment, SubtitleTask } from '../lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 200
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
task: SubtitleTask | null
|
task: SubtitleTask | null
|
||||||
logs: string[]
|
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 logsExpanded = ref(false)
|
||||||
|
|
||||||
const logContainerRef = ref<HTMLElement | null>(null)
|
const logContainerRef = ref<HTMLElement | null>(null)
|
||||||
@ -98,8 +127,14 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="segment-list">
|
<div v-else class="segment-list">
|
||||||
|
<div v-if="hasPagination" class="pagination-bar">
|
||||||
|
<button class="button small" :disabled="currentPage <= 1" @click="prevPage">‹</button>
|
||||||
|
<span class="pagination-info">{{ $t('editor.pageInfo', { current: currentPage, total: totalPages }) }}</span>
|
||||||
|
<button class="button small" :disabled="currentPage >= totalPages" @click="nextPage">›</button>
|
||||||
|
<span class="pagination-count">{{ $t('editor.segments', { count: sortedSegments.length }) }}</span>
|
||||||
|
</div>
|
||||||
<article
|
<article
|
||||||
v-for="segment in sortedSegments"
|
v-for="segment in visibleSegments"
|
||||||
:key="segment.id"
|
:key="segment.id"
|
||||||
class="segment-item"
|
class="segment-item"
|
||||||
>
|
>
|
||||||
@ -116,6 +151,11 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
|
|||||||
@change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)"
|
@change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)"
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
<div v-if="hasPagination" class="pagination-bar bottom">
|
||||||
|
<button class="button small" :disabled="currentPage <= 1" @click="prevPage">‹</button>
|
||||||
|
<span class="pagination-info">{{ $t('editor.pageInfo', { current: currentPage, total: totalPages }) }}</span>
|
||||||
|
<button class="button small" :disabled="currentPage >= totalPages" @click="nextPage">›</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="log-drawer" :class="{ expanded: logsExpanded }">
|
<div class="log-drawer" :class="{ expanded: logsExpanded }">
|
||||||
|
|||||||
@ -55,6 +55,7 @@ function isActive(task: SubtitleTask): boolean {
|
|||||||
v-for="task in tasks"
|
v-for="task in tasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
class="task-item-wrapper"
|
class="task-item-wrapper"
|
||||||
|
:class="{ processing: isActive(task) }"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="task-item"
|
class="task-item"
|
||||||
|
|||||||
@ -86,5 +86,6 @@ export default {
|
|||||||
source: 'Source',
|
source: 'Source',
|
||||||
logs: 'Logs',
|
logs: 'Logs',
|
||||||
noLogs: 'No logs',
|
noLogs: 'No logs',
|
||||||
|
pageInfo: 'Page {current}/{total}',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,5 +86,6 @@ export default {
|
|||||||
source: '原文',
|
source: '原文',
|
||||||
logs: '日志',
|
logs: '日志',
|
||||||
noLogs: '暂无日志',
|
noLogs: '暂无日志',
|
||||||
|
pageInfo: '第 {current}/{total} 页',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -443,6 +443,50 @@ textarea {
|
|||||||
box-shadow: 0 0 6px rgba(45, 106, 79, 0.4);
|
box-shadow: 0 0 6px rgba(45, 106, 79, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes rotate-glow {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item-wrapper.processing {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item-wrapper.processing::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 150%;
|
||||||
|
height: 300%;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 0;
|
||||||
|
background: conic-gradient(
|
||||||
|
transparent 0deg,
|
||||||
|
#8a9bb5 90deg,
|
||||||
|
#d0d7e0 180deg,
|
||||||
|
transparent 270deg
|
||||||
|
);
|
||||||
|
animation: rotate-glow 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item-wrapper.processing::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 3px;
|
||||||
|
border-radius: calc(var(--radius-md) - 3px);
|
||||||
|
background: var(--c-bg);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item-wrapper.processing .task-item {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.task-item.failed {
|
.task-item.failed {
|
||||||
border-color: var(--c-error);
|
border-color: var(--c-error);
|
||||||
background: rgba(220, 38, 38, 0.03);
|
background: rgba(220, 38, 38, 0.03);
|
||||||
@ -789,6 +833,41 @@ textarea {
|
|||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--c-surface-gradient);
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-bar.bottom {
|
||||||
|
position: static;
|
||||||
|
border-bottom: none;
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--c-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--c-text-tertiary);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1360px) {
|
@media (max-width: 1360px) {
|
||||||
.content-grid {
|
.content-grid {
|
||||||
grid-template-columns: 280px minmax(0, 1fr);
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user