新增实时流式翻译 避免等待
This commit is contained in:
parent
61d23c7d30
commit
47356a5ea9
@ -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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user