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

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| { |segment| {
if let Some(ref tx) = seg_tx_for_callback { 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) { if let Ok(mut current_task) = app_state_for_segment.get_task(&task_id_for_segment) {
upsert_segment(&mut current_task.segments, segment.clone()); upsert_segment(&mut current_task.segments, segment.clone());
@ -310,7 +316,7 @@ async fn incremental_translate(
task_id: &str, task_id: &str,
target_lang: &TargetLanguage, target_lang: &TargetLanguage,
) -> Result<()> { ) -> 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 context_size = translator.context_size().min(5);
let mut all_segments: Vec<SubtitleSegment> = Vec::new(); let mut all_segments: Vec<SubtitleSegment> = Vec::new();
let mut buffer: Vec<SubtitleSegment> = Vec::new(); let mut buffer: Vec<SubtitleSegment> = Vec::new();

View File

@ -91,7 +91,7 @@ impl Translator {
PF: FnMut(f32), PF: FnMut(f32),
SF: FnMut(SubtitleSegment), 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 context_size = self.config.context_size.min(5);
let mut translated = segments.to_vec(); let mut translated = segments.to_vec();
let target_language_name = match target_language { let target_language_name = match target_language {

View File

@ -1,7 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, nextTick, watch, onBeforeUpdate } from 'vue'
import type { SubtitleSegment, SubtitleTask } from '../lib/types' 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<{ const props = defineProps<{
task: SubtitleTask | null task: SubtitleTask | null
logs: string[] logs: string[]
@ -30,15 +45,145 @@ const emit = defineEmits<{
translate: [] translate: []
}>() }>()
const segments = computed(() => const sortedSegments = computed(() =>
[...(props.task?.segments ?? [])].sort((left, right) => { [...(props.task?.segments ?? [])].sort((left, right) => {
if (left.start !== right.start) return left.start - right.start if (left.start !== right.start) return left.start - right.start
if (left.end !== right.end) return left.end - right.end if (left.end !== right.end) return left.end - right.end
return left.id.localeCompare(right.id) return left.id.localeCompare(right.id)
}), }),
) )
const logsExpanded = ref(false) 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) { function formatTime(seconds: number) {
const ms = Math.round(seconds * 1000) const ms = Math.round(seconds * 1000)
const h = Math.floor(ms / 3600000) const h = Math.floor(ms / 3600000)
@ -59,7 +204,7 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
<div> <div>
<strong>{{ task?.fileName ?? $t('editor.title') }}</strong> <strong>{{ task?.fileName ?? $t('editor.title') }}</strong>
<p class="panel-subtitle"> <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> </p>
</div> </div>
<div v-if="task" class="export-actions"> <div v-if="task" class="export-actions">
@ -80,16 +225,18 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
<p style="margin-top: 6px; font-size: 11px;">{{ $t('editor.emptyHint') }}</p> <p style="margin-top: 6px; font-size: 11px;">{{ $t('editor.emptyHint') }}</p>
</div> </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-if="isProcessing">{{ $t('editor.processing') }}</template>
<template v-else-if="task?.status === 'failed'">{{ $t('editor.failed') }}</template> <template v-else-if="task?.status === 'failed'">{{ $t('editor.failed') }}</template>
<template v-else>{{ $t('editor.noSegments') }}</template> <template v-else>{{ $t('editor.noSegments') }}</template>
</div> </div>
<div v-else class="segment-list"> <div v-else ref="segContainerRef" class="segment-list" @scroll="handleSegScroll">
<div class="seg-spacer" :style="{ paddingTop: `${visibleSegments.paddingTop}px`, paddingBottom: `${visibleSegments.paddingBottom}px` }">
<article <article
v-for="segment in segments" v-for="segment in visibleSegments.items"
:key="segment.id" :key="segment.id"
:ref="(el: any) => { if (el) onSegMount(el as HTMLElement, segment.id); else onSegUnmount(segment.id) }"
class="segment-item" class="segment-item"
> >
<div class="task-row subtle"> <div class="task-row subtle">
@ -106,6 +253,7 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
/> />
</article> </article>
</div> </div>
</div>
<div class="log-drawer" :class="{ expanded: logsExpanded }"> <div class="log-drawer" :class="{ expanded: logsExpanded }">
<button class="log-toggle" type="button" @click="logsExpanded = !logsExpanded"> <button class="log-toggle" type="button" @click="logsExpanded = !logsExpanded">
@ -117,8 +265,10 @@ function updateTranslatedText(segment: SubtitleSegment, value: string) {
<div v-if="logs.length === 0" class="empty-state"> <div v-if="logs.length === 0" class="empty-state">
{{ $t('editor.noLogs') }} {{ $t('editor.noLogs') }}
</div> </div>
<div v-else class="log-list"> <div v-else ref="logContainerRef" class="log-list" @scroll="handleLogScroll">
<pre v-for="(line, index) in logs" :key="`${index}-${line}`" class="log-line">{{ line }}</pre> <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> </div>
</div> </div>

View File

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