feat(dub): show translation output and live logs in shared agent footer

This commit is contained in:
Palash Debnath
2026-09-15 19:38:20 +05:30
parent 730c7f377c
commit 36ccec5e61
55 changed files with 635 additions and 14 deletions
+27 -5
View File
@@ -9,6 +9,7 @@ import {
writeFileSync,
} from 'node:fs';
import { dirname, extname, join, resolve } from 'node:path';
import { StringDecoder } from 'node:string_decoder';
import { randomUUID } from 'node:crypto';
import { app, BrowserWindow, dialog, ipcMain, type IpcMainInvokeEvent } from 'electron';
import type { BackendSupervisor } from './backend';
@@ -35,6 +36,7 @@ export const REPAIR_CHANNELS = {
translate: 'repair:translate',
stopTranslation: 'repair:stopTranslation',
event: 'repair:event',
translationEvent: 'repair:translationEvent',
} as const;
const DEFINITIONS: Array<{ id: RepairAgentId; label: string; command: string }> = [
@@ -355,6 +357,8 @@ function validateDubTranslationRequest(
): asserts value is DubAgentTranslationRequest {
if (!value || typeof value !== 'object') throw new Error('Invalid agent translation request');
const request = value as DubAgentTranslationRequest;
if (request.requestId !== undefined && (typeof request.requestId !== 'string' || request.requestId.length > 100))
throw new Error('Invalid translation request id');
if (!DEFINITIONS.some((item) => item.id === request.agent)) throw new Error('Unknown agent');
if (request.purpose !== 'translate' && request.purpose !== 'fit')
throw new Error('Invalid agent translation purpose');
@@ -831,8 +835,22 @@ export function registerRepairAgents(
);
}
let output = '';
const append = (value: Buffer) => {
output = (output + value.toString('utf8')).slice(-MAX_TRANSLATION_OUTPUT);
const stdoutDecoder = new StringDecoder('utf8');
const stderrDecoder = new StringDecoder('utf8');
let pendingLog = '';
let logTimer: ReturnType<typeof setTimeout> | undefined;
const flushLog = () => {
if (logTimer) clearTimeout(logTimer);
logTimer = undefined;
if (pendingLog && request.requestId)
sendToLiveWindow(getMainWindow(), REPAIR_CHANNELS.translationEvent,
{ requestId: request.requestId, text: pendingLog });
pendingLog = '';
};
const append = (text: string, stdout = true) => {
if (stdout) output = (output + text).slice(-MAX_TRANSLATION_OUTPUT);
pendingLog = (pendingLog + text).slice(-250_000);
if (!logTimer) logTimer = setTimeout(flushLog, 100);
};
try {
return await new Promise<DubAgentTranslationResult>((resolvePromise, rejectPromise) => {
@@ -841,6 +859,9 @@ export function registerRepairAgents(
if (settled) return;
settled = true;
clearTimeout(timeout);
append(stdoutDecoder.end());
append(stderrDecoder.end(), false);
flushLog();
translationChild = null;
if (translationTemp) rmSync(translationTemp, { recursive: true, force: true });
translationTemp = null;
@@ -866,8 +887,8 @@ export function registerRepairAgents(
stdio: ['pipe', 'pipe', 'pipe'],
});
guardAgentProcessStreams(translationChild, (error) => finish(() => rejectPromise(error)));
translationChild.stdout.on('data', append);
translationChild.stderr.on('data', append);
translationChild.stdout.on('data', (value: Buffer) => append(stdoutDecoder.write(value)));
translationChild.stderr.on('data', (value: Buffer) => append(stderrDecoder.write(value), false));
translationChild.on('error', (error) =>
finish(() => rejectPromise(new Error(`Agent could not start: ${error.message}`))),
);
@@ -890,6 +911,7 @@ export function registerRepairAgents(
else translationChild.stdin.end(prompt);
});
} catch (error) {
flushLog();
if (translationTemp) rmSync(translationTemp, { recursive: true, force: true });
translationTemp = null;
translationChild = null;
@@ -914,7 +936,7 @@ export function registerRepairAgents(
translationChild = null;
translationTemp = null;
Object.values(REPAIR_CHANNELS)
.filter((channel) => channel !== REPAIR_CHANNELS.event)
.filter((channel) => channel !== REPAIR_CHANNELS.event && channel !== REPAIR_CHANNELS.translationEvent)
.forEach((channel) => ipcMain.removeHandler(channel));
};
}
+2
View File
@@ -164,6 +164,7 @@ export interface DubAgentTranslationSegment {
measuredSeconds?: number;
}
export interface DubAgentTranslationRequest {
requestId?: string;
agent: RepairAgentId;
purpose: 'translate' | 'fit';
sourceLanguage?: string;
@@ -279,6 +280,7 @@ export interface VoiceStudioBridge {
stop(): Promise<RepairAgentState>;
translate(request: DubAgentTranslationRequest): Promise<DubAgentTranslationResult>;
stopTranslation(): Promise<void>;
onTranslationEvent(callback: (event: { requestId: string; text: string }) => void): () => void;
onEvent(callback: (event: RepairAgentEvent) => void): () => void;
};
permissions: {
+1
View File
@@ -27,6 +27,7 @@ const bridge: VoiceStudioBridge = {
stop: () => ipcRenderer.invoke('repair:stop'),
translate: (request) => ipcRenderer.invoke('repair:translate', request),
stopTranslation: () => ipcRenderer.invoke('repair:stopTranslation'),
onTranslationEvent: (callback) => subscribe('repair:translationEvent', callback),
onEvent: (callback) => subscribe<RepairAgentEvent>('repair:event', callback),
},
permissions: {
@@ -0,0 +1,11 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
export function AgentDockFrame({ label, expanded = true, children }: {
label: string; expanded?: boolean; children: ReactNode;
}) {
return <section aria-label={label} className={cn(
'relative z-40 flex min-h-0 shrink-0 flex-col border-t border-sidebar-border bg-sidebar text-sidebar-foreground shadow-[0_-8px_24px_rgb(0_0_0/12%)]',
expanded && 'h-[clamp(20rem,42vh,28rem)]',
)}>{children}</section>;
}
@@ -1,3 +1,7 @@
import { useStore } from '@tanstack/react-store';
import { translationActivity } from '@/features/dub/translation-activity';
import { TranslationAgentDock } from './translation-agent-dock';
import { AgentDockFrame } from './agent-dock-frame';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useRouterState } from '@tanstack/react-router';
@@ -72,6 +76,7 @@ function RepairGlyph({ className }: { className?: string }) {
export function RepairAgentDock() {
const { t } = useTranslation();
const translation = useStore(translationActivity);
const pathname = useRouterState({ select: (state) => state.location.pathname });
const backend = useBackendStatus();
const bridge = getBridge();
@@ -90,6 +95,10 @@ export function RepairAgentDock() {
const [autoFixReport, setAutoFixReport] = useState('');
const [chooseDefault, setChooseDefault] = useState(false);
const terminal = useRef<HTMLPreElement>(null);
const translationRunId = translation.runs.at(-1)?.id;
useEffect(() => {
if (translationRunId) setOpen(false);
}, [translationRunId]);
useEffect(() => {
if (!bridge) return;
@@ -249,6 +258,8 @@ export function RepairAgentDock() {
}
};
if (!open && status !== 'running' && translation.runs.length) return <TranslationAgentDock />;
if (!open) {
return createPortal(
<Button
@@ -270,10 +281,7 @@ export function RepairAgentDock() {
}
return (
<section
aria-label={t('repairAgent.title')}
className="relative z-40 flex h-[clamp(20rem,42vh,28rem)] min-h-0 shrink-0 flex-col border-t border-sidebar-border bg-sidebar text-sidebar-foreground shadow-[0_-8px_24px_rgb(0_0_0/12%)]"
>
<AgentDockFrame label={t('repairAgent.title')}>
<header className="flex min-h-12 shrink-0 items-center gap-2 border-b border-sidebar-border px-3">
<RepairGlyph className="text-foreground" />
<div className="w-72 min-w-0 shrink-0">
@@ -474,6 +482,6 @@ export function RepairAgentDock() {
)}
</div>
</div>
</section>
</AgentDockFrame>
);
}
@@ -0,0 +1,41 @@
import { beforeEach, afterEach, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { TranslationAgentDock } from './translation-agent-dock';
import { translationActivity, startTranslationRun, finishTranslationRun, updateTranslationRun } from '@/features/dub/translation-activity';
import { cancelDub } from '@/features/dub/dub-session';
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
vi.mock('@/features/dub/dub-session', () => ({
cancelDub: vi.fn(),
useDubSession: () => ({ jobId: 'job', phase: 'editing', recovery: null }),
}));
beforeEach(() => { translationActivity.setState(() => ({ runs: [], expanded: true, tab: 'output' })); vi.clearAllMocks(); });
afterEach(cleanup);
const request = { jobId: 'job', agent: 'codex', target: 'Bengali', purpose: 'translate' as const, rows: [{ id: 'a', source: 'Hello' }] };
it('collapses running work without hiding cancel or allowing dismissal', () => {
startTranslationRun(request);
render(<TranslationAgentDock />);
fireEvent.click(screen.getByRole('button', { name: 'common.details' }));
expect(screen.queryByRole('tabpanel')).toBeNull();
expect(screen.queryByRole('button', { name: 'common.close' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }));
expect(cancelDub).toHaveBeenCalledOnce();
});
it('shows original and validated translation after completion until dismissed', () => {
const id = startTranslationRun(request);
updateTranslationRun(id, { rows: [{ id: 'a', source: 'Hello', text: 'হ্যালো' }] });
finishTranslationRun(id, 'complete');
render(<TranslationAgentDock />);
fireEvent.click(screen.getByRole('tab', { name: 'dubActivity.output' }));
expect(screen.getByText('Hello')).toBeVisible();
expect(screen.getByText('হ্যালো')).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: 'common.close' }));
expect(translationActivity.state.runs).toHaveLength(0);
});
it('retries failed work only in its original project', () => {
const retry = vi.fn().mockResolvedValue(true);
const id = startTranslationRun({ ...request, retry });
finishTranslationRun(id, 'failed', 'Agent failed');
render(<TranslationAgentDock />);
fireEvent.click(screen.getByRole('button', { name: /common.retry/ }));
expect(retry).toHaveBeenCalledOnce();
});
@@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from 'react';
import { useStore } from '@tanstack/react-store';
import { useTranslation } from 'react-i18next';
import { BotIcon, ChevronDownIcon, ChevronUpIcon, XIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cancelDub, useDubSession } from '@/features/dub/dub-session';
import { translationActivity } from '@/features/dub/translation-activity';
import { AgentDockFrame } from './agent-dock-frame';
export function TranslationAgentDock() {
const { t } = useTranslation();
const activity = useStore(translationActivity);
const session = useDubSession();
const [now, setNow] = useState(Date.now());
const log = useRef<HTMLDivElement>(null);
const following = useRef(true);
const latest = activity.runs.at(-1);
const running = activity.runs.some((run) => run.status === 'running');
useEffect(() => {
if (!running) return;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [running]);
useEffect(() => {
if (log.current && following.current) log.current.scrollTop = log.current.scrollHeight;
}, [activity.runs, activity.tab, activity.expanded]);
if (!latest) return null;
const elapsed = Math.max(0, Math.floor(((latest.endedAt || now) - latest.startedAt) / 1000));
const completed = latest.rows.filter((row) => row.text && !row.error).length;
const status = latest.status === 'running' ? t('dub.translating')
: latest.status === 'complete' ? t('repairAgent.complete')
: latest.status === 'cancelled' ? t('dubActivity.cancelled') : t('common.error');
const busy = ['translating', 'generating', 'transcribing', 'preparing'].includes(session.phase);
return <AgentDockFrame label={t('dub.translate_with_agent')} expanded={activity.expanded}>
<header className="flex min-h-12 shrink-0 items-center gap-2 border-b border-sidebar-border px-3">
<BotIcon className="size-4 shrink-0" />
<div className="min-w-0 flex-1 text-xs" role="status">
<p className="truncate font-medium">{status} · {latest.target} · {latest.agent}</p>
<p className="text-muted-foreground">{t('dubActivity.progress', { done: completed, total: latest.rows.length })} · {Math.floor(elapsed / 60)}:{String(elapsed % 60).padStart(2, '0')}</p>
</div>
{running && <Button size="sm" variant="ghost" onClick={() => void cancelDub()}>{t('common.cancel')}</Button>}
<Button size="sm" variant="ghost" aria-expanded={activity.expanded} aria-controls="translation-agent-details"
onClick={() => translationActivity.setState((s) => ({ ...s, expanded: !s.expanded }))}>
{activity.expanded ? <ChevronDownIcon /> : <ChevronUpIcon />}{t('common.details')}
</Button>
{!running && <Button size="icon-sm" variant="ghost" aria-label={t('common.close')}
onClick={() => translationActivity.setState((s) => ({ ...s, runs: [] }))}><XIcon /></Button>}
</header>
{activity.expanded && <div id="translation-agent-details" className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 gap-1 border-b border-sidebar-border px-3 py-1" role="tablist" aria-label={t('common.details')}>
{(['output', 'logs'] as const).map((tab) => <Button key={tab} size="sm" variant={activity.tab === tab ? 'secondary' : 'ghost'} role="tab"
tabIndex={activity.tab === tab ? 0 : -1}
onKeyDown={(event) => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const next = event.key === 'Home' ? 'output' : event.key === 'End' ? 'logs' : tab === 'logs' ? 'output' : 'logs';
translationActivity.setState((s) => ({ ...s, tab: next }));
document.getElementById(`translation-${next}-tab`)?.focus();
}}
aria-selected={activity.tab === tab} aria-controls={`translation-${tab}-panel`} id={`translation-${tab}-tab`}
onClick={() => translationActivity.setState((s) => ({ ...s, tab }))}>
{t(tab === 'output' ? 'dubActivity.output' : 'logs.title')}
</Button>)}
</div>
<div ref={log} role="tabpanel" id={`translation-${activity.tab}-panel`} aria-labelledby={`translation-${activity.tab}-tab`}
onScroll={() => { const el = log.current; if (el) following.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40; }}
className="studio-scrollbar min-h-0 flex-1 overflow-auto p-3">
{activity.runs.map((run) => <div key={run.id} className="mb-4 space-y-2">
<p className="text-xs font-medium">{run.target} · {run.agent} · {run.purpose === 'fit' ? t('dubActivity.fitting') : t('dub.translate')}</p>
{activity.tab === 'logs' ? <pre className="whitespace-pre-wrap break-words font-mono text-xs leading-5">{run.logs || t('dubActivity.waiting')}</pre>
: run.rows.some((row) => row.text || row.error) ? run.rows.filter((row) => row.text || row.error).map((row) =>
<div key={row.id} className="grid gap-2 rounded-md border border-sidebar-border p-2 text-sm @xl:grid-cols-2 [content-visibility:auto]">
<p className="whitespace-pre-wrap break-words text-muted-foreground">{row.source}</p>
<p className="whitespace-pre-wrap break-words">{row.error || row.text}</p>
</div>) : <p className="text-xs text-muted-foreground">{t('dubActivity.waiting')}</p>}
{run.error && <p role="alert" className="text-xs text-destructive">{run.error}</p>}
{run.status === 'failed' && run.retry && activity.runs.filter((r) => r.target === run.target && r.purpose === run.purpose).at(-1)?.id === run.id && <Button size="sm" variant="outline"
disabled={busy || running || session.jobId !== run.jobId || Boolean(session.recovery)}
onClick={() => void run.retry?.()}>{t('common.retry')} · {run.target}</Button>}
</div>)}
</div>
</div>}
</AgentDockFrame>;
}
@@ -1,3 +1,4 @@
import { translationActivity } from './translation-activity';
import { ingestDubUrl, isDubUrl } from './dub-session';
import { expect, it, vi } from 'vitest';
import { apiJson } from '@/lib/api/client';
@@ -32,17 +33,25 @@ vi.mock('@/lib/api/event-stream', async (load) => ({
}));
it('translates the current dubbing segments with an installed local CLI agent', async () => {
const translate = vi.fn().mockResolvedValue({
let logListener: ((event: { requestId: string; text: string }) => void) | undefined;
const unsubscribe = vi.fn();
const translate = vi.fn().mockImplementation(async (request) => {
logListener?.({ requestId: 'another-request', text: 'must not appear' });
logListener?.({ requestId: request.requestId, text: 'Translating two segments' });
return {
agent: 'codex',
translations: [
{ id: 'a', text: 'Hola' },
{ id: 'b', text: 'Adiós' },
],
});
}; });
Object.defineProperty(window, 'voicestudio', {
configurable: true,
value: {
repair: { translate, stopTranslation: vi.fn().mockResolvedValue(undefined) },
repair: { translate, stopTranslation: vi.fn().mockResolvedValue(undefined),
onTranslationEvent: (callback: typeof logListener) => { logListener = callback; return unsubscribe; },
},
} as unknown as Window['voicestudio'],
});
vi.mocked(apiJson).mockReset();
@@ -64,6 +73,10 @@ it('translates the current dubbing segments with an installed local CLI agent',
}));
await expect(translateDubWithAgent('es', 'codex')).resolves.toBe(true);
expect(unsubscribe).toHaveBeenCalledOnce();
expect(translationActivity.state.runs.at(-1)).toMatchObject({
status: 'complete', logs: 'Translating two segments',
});
expect(translate).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1,3 +1,4 @@
import { startTranslationRun, appendTranslationLog, updateTranslationRun, finishTranslationRun } from './translation-activity';
import type { DubExportPreferences } from './dub-export';
import {
MAX_COOKIE_EXPORT_BYTES,
@@ -1053,6 +1054,7 @@ export async function ingestDubUrl(value: string, cookieFile?: File, fetchSubs =
async function runLocalTranslationAgent(
request: DubAgentTranslationRequest,
signal: AbortSignal,
retry?: () => Promise<unknown>,
): Promise<DubAgentTranslationResult> {
const bridge = window.voicestudio?.repair;
if (!bridge?.translate) throw new Error('LOCAL_TRANSLATION_AGENT_UNAVAILABLE');
@@ -1061,10 +1063,30 @@ async function runLocalTranslationAgent(
stop();
throw new DOMException('Cancelled', 'AbortError');
}
const id = startTranslationRun({
jobId: dubSession.state.jobId || '', agent: request.agent,
target: request.targetLanguage, purpose: request.purpose, retry,
rows: request.segments.map((segment) => ({ id: segment.id, source: segment.sourceText })),
});
const unsubscribe = bridge.onTranslationEvent?.((event) => {
if (event.requestId === id) appendTranslationLog(id, event.text);
});
signal.addEventListener('abort', stop, { once: true });
try {
return await bridge.translate(request);
const result = await bridge.translate({ ...request, requestId: id });
if (signal.aborted) throw new DOMException('Cancelled', 'AbortError');
const texts = new Map(result.translations.map((row) => [row.id, row.text]));
updateTranslationRun(id, {
rows: request.segments.map((segment) => ({ id: segment.id, source: segment.sourceText, text: texts.get(segment.id) })),
});
finishTranslationRun(id, 'complete');
return result;
} catch (error) {
finishTranslationRun(id, signal.aborted ? 'cancelled' : 'failed',
signal.aborted ? undefined : (error instanceof Error ? error.message : String(error)));
throw error;
} finally {
unsubscribe?.();
signal.removeEventListener('abort', stop);
}
}
@@ -1100,6 +1122,7 @@ export async function translateDubWithAgent(
})),
},
signal,
() => translateDubWithAgent(target, agent, targetLabel),
);
const rows = new Map(translated.translations.map((row) => [row.id, row.text]));
clearDubEditHistory();
@@ -1155,8 +1178,17 @@ export async function translateDub(
if (!requestedSegments.length) return false;
const finishActivity = beginAppActivity('translation');
let agentFallback = false;
let activityId: string | undefined;
let activityAborted = false;
try {
const completed = await run('translating', async (signal) => {
activityId = startTranslationRun({
jobId: snapshot.jobId!, agent: provider, target, purpose: 'translate',
rows: requestedSegments.map((segment) => ({ id: segment.id, source: segment.text_original || segment.text })),
retry: () => translateDub(target, provider, { retryFailed: Boolean(dubSession.state.segments.some((s) => s.translate_errors?.[target])) }),
});
signal.addEventListener('abort', () => { activityAborted = true; }, { once: true });
const glossary = await apiJson<Array<{ source: string; target: string; note?: string }>>(
`/glossary/${encodeURIComponent(snapshot.jobId!)}`,
{ signal },
@@ -1211,6 +1243,13 @@ export async function translateDub(
})),
}),
});
if (signal.aborted) throw new DOMException('Cancelled', 'AbortError');
updateTranslationRun(activityId!, {
rows: requestedSegments.map((segment) => {
const row = translated.translated.find((r) => String(r.id) === segment.id);
return { id: segment.id, source: segment.text_original || segment.text, text: row?.error ? undefined : row?.text, error: row?.error };
}),
});
const fallback = translated.cinematic_skipped === 'no-llm-configured';
agentFallback = fallback && snapshot.quality === 'agent';
const rows = new Map(translated.translated.map((row) => [String(row.id), row]));
@@ -1254,6 +1293,9 @@ export async function translateDub(
if (translated.translated.some((row) => row.error))
throw new Error('Some translation segments failed');
});
if (activityId) finishTranslationRun(activityId,
activityAborted ? 'cancelled' : completed && !agentFallback ? 'complete' : 'failed',
completed && !agentFallback ? undefined : dubSession.state.error || undefined);
return completed && !agentFallback;
} finally {
finishActivity();
@@ -0,0 +1,32 @@
import { beforeEach, expect, it } from 'vitest';
import { appendTranslationLog, finishTranslationRun, startTranslationRun, translationActivity, updateTranslationRun } from './translation-activity';
beforeEach(() => translationActivity.setState(() => ({ runs: [], expanded: false, tab: 'output' })));
const request = { jobId: 'job', agent: 'codex', target: 'Bengali', purpose: 'translate' as const, rows: [{ id: 'a', source: 'Hello' }] };
it('opens logs without inventing completed segments, then retains validated output', () => {
const id = startTranslationRun(request);
appendTranslationLog(id, 'Working…');
expect(translationActivity.state.expanded).toBe(true);
expect(translationActivity.state.tab).toBe('logs');
expect(translationActivity.state.runs[0].rows[0].text).toBeUndefined();
updateTranslationRun(id, { rows: [{ id: 'a', source: 'Hello', text: 'হ্যালো' }] });
finishTranslationRun(id, 'complete');
expect(translationActivity.state.runs[0].rows[0].text).toBe('হ্যালো');
expect(translationActivity.state.runs[0].endedAt).toBeDefined();
});
it('isolates run logs and ignores late output after cancellation', () => {
const first = startTranslationRun(request);
finishTranslationRun(first, 'cancelled');
const second = startTranslationRun({ ...request, target: 'Spanish' });
appendTranslationLog(first, 'late output');
appendTranslationLog(second, 'current output');
expect(translationActivity.state.runs.map((r) => r.logs)).toEqual(['', 'current output']);
});
it('bounds log memory while preserving separate batch language results', () => {
const first = startTranslationRun(request);
appendTranslationLog(first, 'x'.repeat(300_000));
finishTranslationRun(first, 'complete');
startTranslationRun({ ...request, target: 'Spanish' });
expect(translationActivity.state.runs).toHaveLength(2);
expect(translationActivity.state.runs[0].logs).toHaveLength(250_000);
});
@@ -0,0 +1,52 @@
import { Store } from '@tanstack/store';
export interface TranslationRun {
id: string;
jobId: string;
agent: string;
target: string;
purpose: 'translate' | 'fit';
status: 'running' | 'complete' | 'failed' | 'cancelled';
startedAt: number;
endedAt?: number;
logs: string;
error?: string;
rows: Array<{ id: string; source: string; text?: string; error?: string }>;
retry?: () => Promise<unknown>;
}
export const translationActivity = new Store<{
runs: TranslationRun[];
expanded: boolean;
tab: 'output' | 'logs';
}>({ runs: [], expanded: true, tab: 'output' });
export function startTranslationRun(run: Omit<TranslationRun, 'id' | 'status' | 'startedAt' | 'logs'>): string {
const id = crypto.randomUUID();
translationActivity.setState((state) => ({
...state,
expanded: true,
tab: state.runs.length ? state.tab : 'logs',
runs: [...state.runs.filter((r) => r.jobId === run.jobId), { ...run, id, status: 'running', startedAt: Date.now(), logs: '' }],
}));
return id;
}
export function updateTranslationRun(id: string, update: Partial<TranslationRun>) {
translationActivity.setState((state) => ({
...state,
runs: state.runs.map((run) => run.id === id ? { ...run, ...update } : run),
}));
}
export function appendTranslationLog(id: string, text: string) {
translationActivity.setState((state) => ({
...state,
runs: state.runs.map((run) => run.id === id && run.status === 'running'
? { ...run, logs: (run.logs + text).slice(-250_000) } : run),
}));
}
export function finishTranslationRun(id: string, status: TranslationRun['status'], error?: string) {
updateTranslationRun(id, { status, error, endedAt: Date.now() });
}
@@ -2587,5 +2587,12 @@
"dubStyle": {
"label": "تعليمات أسلوب الترجمة",
"help": "حدّد النبرة والجمهور وأسلوب التكييف، مثل الحفاظ على النكات وتكييف التعابير طبيعيًا. تُحفظ مع المشروع وتُستخدم للترجمة وإعادة الصياغة الزمنية. اتركها فارغة للأسلوب الافتراضي."
},
"dubActivity": {
"output": "الترجمات",
"waiting": "لم يصل أي ناتج بعد.",
"progress": "تم التحقق من {{done}} / {{total}} مقطع",
"cancelled": "أُلغي",
"fitting": "ضبط التوقيت"
}
}
@@ -2579,5 +2579,12 @@
"dubStyle": {
"label": "Vorgabe für den Übersetzungsstil",
"help": "Beschreibe Ton, Zielgruppe und Anpassung, etwa Witze erhalten und Redewendungen natürlich übertragen. Wird im Projekt gespeichert und für Übersetzung und Zeitanpassung verwendet. Leer lassen für den Standardstil."
},
"dubActivity": {
"output": "Übersetzungen",
"waiting": "Noch keine Ausgabe empfangen.",
"progress": "{{done}} / {{total}} Segmente geprüft",
"cancelled": "Abgebrochen",
"fitting": "Timing anpassen"
}
}
@@ -2579,5 +2579,12 @@
"dubStyle": {
"label": "Translation style prompt",
"help": "Describe tone, audience and adaptation style—for example: conversational Bengali, preserve jokes, adapt idioms naturally. Saved with this project; used for translation and timing rewrites. Leave blank for the default style."
},
"dubActivity": {
"output": "Translations",
"waiting": "No output received yet.",
"progress": "{{done}} / {{total}} segments validated",
"cancelled": "Cancelled",
"fitting": "Adjusting timing"
}
}
@@ -2581,5 +2581,12 @@
"dubStyle": {
"label": "Instrucciones de estilo de traducción",
"help": "Describe el tono, el público y la adaptación, por ejemplo conservar los chistes y adaptar los modismos con naturalidad. Se guarda en el proyecto y se aplica a la traducción y los ajustes de duración. Déjalo vacío para el estilo predeterminado."
},
"dubActivity": {
"output": "Traducciones",
"waiting": "Aún no se ha recibido ninguna salida.",
"progress": "{{done}} / {{total}} segmentos validados",
"cancelled": "Cancelado",
"fitting": "Ajustando tiempos"
}
}
@@ -2581,5 +2581,12 @@
"dubStyle": {
"label": "Consignes de style de traduction",
"help": "Décrivez le ton, le public et ladaptation, par exemple préserver les blagues et adapter naturellement les expressions. Enregistré dans le projet et utilisé pour la traduction et les ajustements de durée. Laissez vide pour le style par défaut."
},
"dubActivity": {
"output": "Traductions",
"waiting": "Aucune sortie reçue pour le moment.",
"progress": "{{done}} / {{total}} segments validés",
"cancelled": "Annulé",
"fitting": "Ajustement du minutage"
}
}
@@ -2579,5 +2579,12 @@
"dubStyle": {
"label": "अनुवाद शैली के निर्देश",
"help": "लहजा, दर्शक और रूपांतरण शैली बताएँ, जैसे चुटकुले बनाए रखना और मुहावरों को स्वाभाविक बनाना। परियोजना में सहेजा जाता है और अनुवाद व समय के अनुसार पुनर्लेखन में उपयोग होता है। डिफ़ॉल्ट शैली के लिए खाली छोड़ें।"
},
"dubActivity": {
"output": "अनुवाद",
"waiting": "अभी कोई आउटपुट नहीं मिला।",
"progress": "{{done}} / {{total}} खंड सत्यापित",
"cancelled": "रद्द किया गया",
"fitting": "समय समायोजित हो रहा है"
}
}
@@ -2579,5 +2579,12 @@
"dubStyle": {
"label": "Instruksi gaya terjemahan",
"help": "Jelaskan nada, audiens, dan gaya adaptasi, misalnya pertahankan lelucon dan sesuaikan idiom secara alami. Disimpan dalam proyek untuk terjemahan dan penyesuaian durasi. Kosongkan untuk gaya bawaan."
},
"dubActivity": {
"output": "Terjemahan",
"waiting": "Belum ada keluaran yang diterima.",
"progress": "{{done}} / {{total}} segmen divalidasi",
"cancelled": "Dibatalkan",
"fitting": "Menyesuaikan waktu"
}
}
@@ -2581,5 +2581,12 @@
"dubStyle": {
"label": "Istruzioni sullo stile di traduzione",
"help": "Descrivi tono, pubblico e adattamento, ad esempio mantenere le battute e adattare gli idiomi in modo naturale. Salvate nel progetto e usate per traduzione e riscritture temporali. Lascia vuoto per lo stile predefinito."
},
"dubActivity": {
"output": "Traduzioni",
"waiting": "Nessun risultato ricevuto finora.",
"progress": "{{done}} / {{total}} segmenti convalidati",
"cancelled": "Annullato",
"fitting": "Regolazione dei tempi"
}
}
@@ -2579,5 +2579,12 @@
"dubStyle": {
"label": "翻訳スタイルの指示",
"help": "口調、対象者、翻案方針を指定します。例:冗談を残し、慣用句を自然に訳す。プロジェクトに保存され、翻訳と尺調整の書き直しに使われます。空欄なら既定のスタイルを使います。"
},
"dubActivity": {
"output": "翻訳結果",
"waiting": "まだ出力を受信していません。",
"progress": "{{done}} / {{total}} セグメントを検証済み",
"cancelled": "キャンセル済み",
"fitting": "タイミングを調整中"
}
}
@@ -2579,5 +2579,12 @@
"dubStyle": {
"label": "번역 스타일 지침",
"help": "어조, 대상 독자, 각색 방식을 설명하세요. 예: 농담을 살리고 관용구를 자연스럽게 번역하기. 프로젝트에 저장되며 번역과 길이 조정에 사용됩니다. 비워 두면 기본 스타일을 사용합니다."
},
"dubActivity": {
"output": "번역 결과",
"waiting": "아직 수신된 출력이 없습니다.",
"progress": "{{done}} / {{total}}개 구간 검증됨",
"cancelled": "취소됨",
"fitting": "타이밍 조정 중"
}
}
@@ -2579,5 +2579,12 @@
"dubStyle": {
"label": "Instructies voor vertaalstijl",
"help": "Beschrijf toon, doelgroep en aanpassing, zoals grappen behouden en uitdrukkingen natuurlijk vertalen. Opgeslagen in dit project voor vertaling en herschrijven op tijdsduur. Laat leeg voor de standaardstijl."
},
"dubActivity": {
"output": "Vertalingen",
"waiting": "Nog geen uitvoer ontvangen.",
"progress": "{{done}} / {{total}} segmenten gevalideerd",
"cancelled": "Geannuleerd",
"fitting": "Timing aanpassen"
}
}
@@ -2583,5 +2583,12 @@
"dubStyle": {
"label": "Instrukcje stylu tłumaczenia",
"help": "Opisz ton, odbiorców i sposób adaptacji, np. zachowanie żartów i naturalne tłumaczenie idiomów. Zapisywane w projekcie i stosowane przy tłumaczeniu oraz dopasowaniu czasu. Pozostaw puste dla stylu domyślnego."
},
"dubActivity": {
"output": "Tłumaczenia",
"waiting": "Nie otrzymano jeszcze żadnych wyników.",
"progress": "Zweryfikowano {{done}} / {{total}} segmentów",
"cancelled": "Anulowano",
"fitting": "Dostosowywanie czasu"
}
}
@@ -2581,5 +2581,12 @@
"dubStyle": {
"label": "Instruções de estilo de tradução",
"help": "Descreva o tom, o público e a adaptação, como preservar piadas e adaptar expressões naturalmente. Salvas no projeto para tradução e ajustes de duração. Deixe em branco para usar o estilo padrão."
},
"dubActivity": {
"output": "Traduções",
"waiting": "Nenhum resultado recebido ainda.",
"progress": "{{done}} / {{total}} segmentos validados",
"cancelled": "Cancelado",
"fitting": "Ajustando o tempo"
}
}
@@ -2583,5 +2583,12 @@
"dubStyle": {
"label": "Инструкции по стилю перевода",
"help": "Опишите тон, аудиторию и подход к адаптации, например сохранять шутки и естественно передавать идиомы. Сохраняется в проекте для перевода и подгонки длительности. Оставьте пустым для стиля по умолчанию."
},
"dubActivity": {
"output": "Переводы",
"waiting": "Вывод пока не получен.",
"progress": "Проверено сегментов: {{done}} / {{total}}",
"cancelled": "Отменено",
"fitting": "Настройка времени"
}
}
@@ -2579,5 +2579,12 @@
"dubStyle": {
"label": "Instruktioner för översättningsstil",
"help": "Beskriv ton, målgrupp och anpassning, till exempel bevara skämt och översätta idiom naturligt. Sparas i projektet för översättning och tidsanpassning. Lämna tomt för standardstilen."
},
"dubActivity": {
"output": "Översättningar",
"waiting": "Ingen utdata har tagits emot ännu.",
"progress": "{{done}} / {{total}} segment validerade",
"cancelled": "Avbruten",
"fitting": "Justerar tidsplacering"
}
}
@@ -2579,5 +2579,12 @@
"dubStyle": {
"label": "คำสั่งรูปแบบการแปล",
"help": "ระบุน้ำเสียง กลุ่มผู้ฟัง และแนวทางดัดแปลง เช่น รักษามุกตลกและปรับสำนวนให้เป็นธรรมชาติ บันทึกในโครงการและใช้ทั้งการแปลและปรับความยาวบท เว้นว่างเพื่อใช้รูปแบบเริ่มต้น"
},
"dubActivity": {
"output": "คำแปล",
"waiting": "ยังไม่ได้รับผลลัพธ์",
"progress": "ตรวจสอบแล้ว {{done}} / {{total}} ช่วง",
"cancelled": "ยกเลิกแล้ว",
"fitting": "กำลังปรับเวลา"
}
}
@@ -2579,5 +2579,12 @@
"dubStyle": {
"label": "Çeviri üslubu talimatları",
"help": "Tonu, hedef kitleyi ve uyarlamayı açıklayın; örneğin şakaları koruyun ve deyimleri doğal biçimde aktarın. Projeye kaydedilir, çeviri ve süre düzenlemelerinde kullanılır. Varsayılan üslup için boş bırakın."
},
"dubActivity": {
"output": "Çeviriler",
"waiting": "Henüz çıktı alınmadı.",
"progress": "{{done}} / {{total}} bölüm doğrulandı",
"cancelled": "İptal edildi",
"fitting": "Zamanlama ayarlanıyor"
}
}
@@ -2583,5 +2583,12 @@
"dubStyle": {
"label": "Інструкції щодо стилю перекладу",
"help": "Опишіть тон, аудиторію та підхід до адаптації, наприклад збереження жартів і природний переклад ідіом. Зберігається в проєкті для перекладу й підгонки тривалості. Залиште порожнім для типового стилю."
},
"dubActivity": {
"output": "Переклади",
"waiting": "Вивід ще не отримано.",
"progress": "Перевірено сегментів: {{done}} / {{total}}",
"cancelled": "Скасовано",
"fitting": "Налаштування часу"
}
}
@@ -2579,5 +2579,12 @@
"dubStyle": {
"label": "Hướng dẫn phong cách dịch",
"help": "Mô tả giọng điệu, đối tượng và cách chuyển thể, ví dụ giữ câu đùa và dịch thành ngữ tự nhiên. Được lưu trong dự án, dùng cho dịch và chỉnh thời lượng. Để trống để dùng phong cách mặc định."
},
"dubActivity": {
"output": "Bản dịch",
"waiting": "Chưa nhận được đầu ra.",
"progress": "Đã xác thực {{done}} / {{total}} đoạn",
"cancelled": "Đã hủy",
"fitting": "Đang điều chỉnh thời gian"
}
}
@@ -2583,5 +2583,12 @@
"dubStyle": {
"label": "翻译风格提示词",
"help": "描述语气、受众和改编方式,例如保留笑点、自然转换习语。随项目保存,用于翻译和时长调整。留空使用默认风格。"
},
"dubActivity": {
"output": "翻译结果",
"waiting": "尚未收到输出。",
"progress": "已验证 {{done}} / {{total}} 个片段",
"cancelled": "已取消",
"fitting": "正在调整时间"
}
}
@@ -2579,5 +2579,12 @@
"dubStyle": {
"label": "翻譯風格提示詞",
"help": "描述語氣、受眾和改編方式,例如保留笑點、自然轉換慣用語。隨專案儲存,用於翻譯和時長調整。留空使用預設風格。"
},
"dubActivity": {
"output": "翻譯結果",
"waiting": "尚未收到輸出。",
"progress": "已驗證 {{done}} / {{total}} 個片段",
"cancelled": "已取消",
"fitting": "正在調整時間"
}
}