新增分页,新增win打包
This commit is contained in:
parent
15ad4a1f07
commit
cbe5955c6e
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,3 +3,4 @@
|
||||
/src-tauri/target/
|
||||
**/*.rs.bk
|
||||
/src-tauri/model/
|
||||
/src-tauri/vendor/ffmpeg/
|
||||
|
||||
@ -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",
|
||||
|
||||
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));
|
||||
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<HTMLElement | null>(null)
|
||||
@ -98,8 +127,14 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
|
||||
</div>
|
||||
|
||||
<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
|
||||
v-for="segment in sortedSegments"
|
||||
v-for="segment in visibleSegments"
|
||||
:key="segment.id"
|
||||
class="segment-item"
|
||||
>
|
||||
@ -116,6 +151,11 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
|
||||
@change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</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 class="log-drawer" :class="{ expanded: logsExpanded }">
|
||||
|
||||
@ -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) }"
|
||||
>
|
||||
<button
|
||||
class="task-item"
|
||||
|
||||
@ -86,5 +86,6 @@ export default {
|
||||
source: 'Source',
|
||||
logs: 'Logs',
|
||||
noLogs: 'No logs',
|
||||
pageInfo: 'Page {current}/{total}',
|
||||
},
|
||||
}
|
||||
|
||||
@ -86,5 +86,6 @@ export default {
|
||||
source: '原文',
|
||||
logs: '日志',
|
||||
noLogs: '暂无日志',
|
||||
pageInfo: '第 {current}/{total} 页',
|
||||
},
|
||||
}
|
||||
|
||||
@ -443,6 +443,50 @@ textarea {
|
||||
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 {
|
||||
border-color: var(--c-error);
|
||||
background: rgba(220, 38, 38, 0.03);
|
||||
@ -789,6 +833,41 @@ textarea {
|
||||
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) {
|
||||
.content-grid {
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user