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