新增实时流式翻译 避免等待

This commit is contained in:
kura 2026-05-01 18:46:23 +08:00
parent 61d23c7d30
commit 47356a5ea9
4 changed files with 196 additions and 28 deletions

View File

@ -265,7 +265,13 @@ async fn run_pipeline(
},
|segment| {
if let Some(ref tx) = seg_tx_for_callback {
let _ = tx.try_send(segment.clone());
let tx = tx.clone();
let seg = segment.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = tx.send(seg).await {
eprintln!("translation: send error: {e}");
}
});
}
if let Ok(mut current_task) = app_state_for_segment.get_task(&task_id_for_segment) {
upsert_segment(&mut current_task.segments, segment.clone());
@ -310,7 +316,7 @@ async fn incremental_translate(
task_id: &str,
target_lang: &TargetLanguage,
) -> Result<()> {
let batch_size = translator.batch_size().clamp(10, 15);
let batch_size = translator.batch_size().clamp(3, 60);
let context_size = translator.context_size().min(5);
let mut all_segments: Vec<SubtitleSegment> = Vec::new();
let mut buffer: Vec<SubtitleSegment> = Vec::new();

View File

@ -91,7 +91,7 @@ impl Translator {
PF: FnMut(f32),
SF: FnMut(SubtitleSegment),
{
let batch_size = self.config.batch_size.clamp(10, 15);
let batch_size = self.config.batch_size.clamp(3, 350);
let context_size = self.config.context_size.min(5);
let mut translated = segments.to_vec();
let target_language_name = match target_language {

View File

@ -1,7 +1,22 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, nextTick, watch, onBeforeUpdate } from 'vue'
import type { SubtitleSegment, SubtitleTask } from '../lib/types'
const LOG_ROW_HEIGHT = 20
const LOG_OVERSCAN = 10
const SEGMENT_TIMESTAMP_HEIGHT = 20
const SEGMENT_TEXTAREA_HEIGHT = 60
const SEGMENT_PADDING_GAP = 34
const SEGMENT_CHARS_PER_LINE = 40
const SEGMENT_LINE_HEIGHT = 20
const SEGMENT_OVERSCAN = 5
function estimateSegmentHeight(seg: SubtitleSegment): number {
const textLines = Math.max(1, Math.ceil((seg.sourceText?.length || 1) / SEGMENT_CHARS_PER_LINE))
return SEGMENT_TIMESTAMP_HEIGHT + textLines * SEGMENT_LINE_HEIGHT + SEGMENT_TEXTAREA_HEIGHT + SEGMENT_PADDING_GAP
}
const props = defineProps<{
task: SubtitleTask | null
logs: string[]
@ -30,15 +45,145 @@ const emit = defineEmits<{
translate: []
}>()
const segments = computed(() =>
const sortedSegments = computed(() =>
[...(props.task?.segments ?? [])].sort((left, right) => {
if (left.start !== right.start) return left.start - right.start
if (left.end !== right.end) return left.end - right.end
return left.id.localeCompare(right.id)
}),
)
const logsExpanded = ref(false)
const logContainerRef = ref<HTMLElement | null>(null)
const logScrollTop = ref(0)
const isNearBottom = ref(true)
const visibleLogs = computed(() => {
const total = props.logs.length
if (total === 0) return { items: [] as string[], start: 0, paddingTop: 0, paddingBottom: 0 }
const containerHeight = logContainerRef.value?.clientHeight ?? 220
const startIndex = Math.max(0, Math.floor(logScrollTop.value / LOG_ROW_HEIGHT) - LOG_OVERSCAN)
const visibleCount = Math.ceil(containerHeight / LOG_ROW_HEIGHT) + LOG_OVERSCAN * 2
const endIndex = Math.min(total, startIndex + visibleCount)
return {
items: props.logs.slice(startIndex, endIndex),
start: startIndex,
paddingTop: startIndex * LOG_ROW_HEIGHT,
paddingBottom: Math.max(0, total * LOG_ROW_HEIGHT - endIndex * LOG_ROW_HEIGHT),
}
})
function handleLogScroll(event: Event) {
const el = event.target as HTMLElement
logScrollTop.value = el.scrollTop
isNearBottom.value = el.scrollTop + el.clientHeight >= el.scrollHeight - 40
}
watch(() => props.logs.length, () => {
if (isNearBottom.value) {
nextTick(() => {
if (logContainerRef.value) {
logContainerRef.value.scrollTop = logContainerRef.value.scrollHeight
logScrollTop.value = logContainerRef.value.scrollTop
}
})
}
})
const segContainerRef = ref<HTMLElement | null>(null)
const segScrollTop = ref(0)
const segHeights = ref(new Map<string, number>())
const segObservers = new Map<string, ResizeObserver>()
const segOffsetCache = computed(() => {
const segments = sortedSegments.value
const cache = new Map<string, { height: number; offset: number }>()
let offset = 0
for (const seg of segments) {
const height = segHeights.value.get(seg.id) ?? estimateSegmentHeight(seg)
cache.set(seg.id, { height, offset })
offset += height + 8
}
return { cache, totalHeight: Math.max(0, offset - 8) }
})
const visibleSegments = computed(() => {
const segments = sortedSegments.value
if (segments.length === 0) return { items: [] as SubtitleSegment[], paddingTop: 0, paddingBottom: 0 }
const containerHeight = segContainerRef.value?.clientHeight ?? 600
const { cache, totalHeight } = segOffsetCache.value
let startIndex = segments.length - 1
for (let i = 0; i < segments.length; i++) {
const info = cache.get(segments[i].id)!
if (info.offset + info.height > segScrollTop.value) {
startIndex = Math.max(0, i - SEGMENT_OVERSCAN)
break
}
}
let endIndex = segments.length
for (let i = startIndex; i < segments.length; i++) {
const info = cache.get(segments[i].id)!
if (info.offset > segScrollTop.value + containerHeight) {
endIndex = Math.min(segments.length, i + SEGMENT_OVERSCAN)
break
}
}
const startOffset = cache.get(segments[startIndex].id)?.offset ?? 0
const endInfo = cache.get(segments[Math.min(endIndex - 1, segments.length - 1)].id)
const endOffset = endInfo ? endInfo.offset + endInfo.height : totalHeight
const paddingBottom = Math.max(0, totalHeight - endOffset)
return {
items: segments.slice(startIndex, endIndex),
paddingTop: startOffset,
paddingBottom,
}
})
function handleSegScroll(event: Event) {
const el = event.target as HTMLElement
segScrollTop.value = el.scrollTop
}
function onSegMount(el: HTMLElement, segmentId: string) {
const existing = segObservers.get(segmentId)
if (existing) existing.disconnect()
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const actualHeight = entry.contentBoxSize
? entry.contentBoxSize[0].blockSize
: entry.borderBoxSize
? entry.borderBoxSize[0].blockSize
: (entry.target as HTMLElement).offsetHeight
if (actualHeight > 0) {
segHeights.value = new Map(segHeights.value).set(segmentId, actualHeight)
}
}
})
ro.observe(el)
segObservers.set(segmentId, ro)
}
function onSegUnmount(segmentId: string) {
const ro = segObservers.get(segmentId)
if (ro) {
ro.disconnect()
segObservers.delete(segmentId)
}
}
onBeforeUpdate(() => {
segScrollTop.value = segContainerRef.value?.scrollTop ?? 0
})
function formatTime(seconds: number) {
const ms = Math.round(seconds * 1000)
const h = Math.floor(ms / 3600000)
@ -59,7 +204,7 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
<div>
<strong>{{ task?.fileName ?? $t('editor.title') }}</strong>
<p class="panel-subtitle">
{{ task ? $t('editor.segments', { count: segments.length }) : $t('editor.selectTask') }}
{{ task ? $t('editor.segments', { count: sortedSegments.length }) : $t('editor.selectTask') }}
</p>
</div>
<div v-if="task" class="export-actions">
@ -80,31 +225,34 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
<p style="margin-top: 6px; font-size: 11px;">{{ $t('editor.emptyHint') }}</p>
</div>
<div v-else-if="segments.length === 0" class="empty-state">
<div v-else-if="sortedSegments.length === 0" class="empty-state">
<template v-if="isProcessing">{{ $t('editor.processing') }}</template>
<template v-else-if="task?.status === 'failed'">{{ $t('editor.failed') }}</template>
<template v-else>{{ $t('editor.noSegments') }}</template>
</div>
<div v-else class="segment-list">
<article
v-for="segment in segments"
:key="segment.id"
class="segment-item"
>
<div class="task-row subtle">
<span>{{ formatTime(segment.start) }} - {{ formatTime(segment.end) }}</span>
<span class="segment-id">{{ segment.id }}</span>
</div>
<p class="source-text">{{ segment.sourceText || $t('editor.waiting') }}</p>
<textarea
class="editor-input"
:value="segment.translatedText ?? ''"
:placeholder="task.outputMode === 'translate' ? $t('editor.translation') : $t('editor.source')"
:disabled="task.outputMode === 'source'"
@change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)"
/>
</article>
<div v-else ref="segContainerRef" class="segment-list" @scroll="handleSegScroll">
<div class="seg-spacer" :style="{ paddingTop: `${visibleSegments.paddingTop}px`, paddingBottom: `${visibleSegments.paddingBottom}px` }">
<article
v-for="segment in visibleSegments.items"
:key="segment.id"
:ref="(el: any) => { if (el) onSegMount(el as HTMLElement, segment.id); else onSegUnmount(segment.id) }"
class="segment-item"
>
<div class="task-row subtle">
<span>{{ formatTime(segment.start) }} - {{ formatTime(segment.end) }}</span>
<span class="segment-id">{{ segment.id }}</span>
</div>
<p class="source-text">{{ segment.sourceText || $t('editor.waiting') }}</p>
<textarea
class="editor-input"
:value="segment.translatedText ?? ''"
:placeholder="task.outputMode === 'translate' ? $t('editor.translation') : $t('editor.source')"
:disabled="task.outputMode === 'source'"
@change="updateTranslatedText(segment, ($event.target as HTMLTextAreaElement).value)"
/>
</article>
</div>
</div>
<div class="log-drawer" :class="{ expanded: logsExpanded }">
@ -117,8 +265,10 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
<div v-if="logs.length === 0" class="empty-state">
{{ $t('editor.noLogs') }}
</div>
<div v-else class="log-list">
<pre v-for="(line, index) in logs" :key="`${index}-${line}`" class="log-line">{{ line }}</pre>
<div v-else ref="logContainerRef" class="log-list" @scroll="handleLogScroll">
<div class="log-spacer" :style="{ paddingTop: `${visibleLogs.paddingTop}px`, paddingBottom: `${visibleLogs.paddingBottom}px` }">
<pre v-for="(line, i) in visibleLogs.items" :key="visibleLogs.start + i" class="log-line">{{ line }}</pre>
</div>
</div>
</div>
</div>

View File

@ -395,6 +395,11 @@ textarea {
flex: 1 1 auto;
overflow: auto;
padding-right: 2px;
will-change: scroll-position;
}
.seg-spacer {
min-width: 100%;
}
.task-item,
@ -617,6 +622,11 @@ textarea {
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
background: var(--c-log-bg);
will-change: scroll-position;
}
.log-spacer {
min-width: 100%;
}
.log-line {
@ -627,6 +637,7 @@ textarea {
line-height: 1.6;
color: var(--c-log-text);
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
content-visibility: auto;
}
.log-line + .log-line {
@ -679,6 +690,7 @@ textarea {
border-left: 3px solid transparent;
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
position: relative;
content-visibility: auto;
}
.segment-item:hover {