From 319a797d8fb4db25f24a2a7d768a17fd78bf39a2 Mon Sep 17 00:00:00 2001 From: shelld Date: Tue, 30 Jun 2026 22:27:05 +0800 Subject: [PATCH] feat: add Chinese i18n support with ~680 translation keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - i18n infrastructure: React Context + useT hook with dynamic JSON loading - Chinese language pack covering all UI: Home, Project, GenSpace, Video Editor, Settings - Keyboard shortcuts modal fully translated with template interpolation - Tools panel, assets panel, timeline, program monitor, export dialog translated - All context menus (clip, asset, take, track) translated - Keyboard shortcut presets descriptions translated - Auto-detect language: localStorage → navigator.language → fallback English - Date format follows current locale - Fix 8 mismatched ACTION_I18N_MAP keys in keyboard-shortcuts.ts --- .gitignore | 1 + frontend/App.tsx | 62 +- frontend/components/ApiGatewayModal.tsx | 12 +- frontend/components/ExportModal.tsx | 52 +- frontend/components/FirstRunSetup.tsx | 109 ++- frontend/components/GenerationErrorDialog.tsx | 16 +- .../components/KeyboardShortcutsModal.tsx | 70 +- frontend/components/LogViewer.tsx | 18 +- frontend/components/LtxUpgradePrompt.tsx | 16 +- frontend/components/SettingsModal.tsx | 184 +++-- frontend/components/SettingsPanel.tsx | 48 +- frontend/lib/i18n.tsx | 103 +++ frontend/lib/keyboard-shortcuts.ts | 80 ++ frontend/lib/locales/en.json | 693 ++++++++++++++++++ frontend/lib/locales/zh.json | 693 ++++++++++++++++++ frontend/types/project-model.ts | 2 +- frontend/types/project.ts | 6 + frontend/views/GenSpace.tsx | 105 +-- frontend/views/Home.tsx | 48 +- frontend/views/Project.tsx | 12 +- frontend/views/editor/AssetContextMenu.tsx | 18 +- frontend/views/editor/ClipContextMenu.tsx | 126 ++-- frontend/views/editor/ClipPropertiesPanel.tsx | 16 +- frontend/views/editor/ProgramMonitor.tsx | 38 +- frontend/views/editor/TakeContextMenu.tsx | 4 +- frontend/views/editor/TimelineToolbar.tsx | 32 +- frontend/views/editor/ToolsPanel.tsx | 28 +- .../views/editor/VideoEditorAssetsPanel.tsx | 87 +-- .../views/editor/VideoEditorLayoutMenu.tsx | 6 +- .../VideoEditorTimelineControlPanel.tsx | 18 +- .../VideoEditorTimelineEditingPanel.tsx | 136 ++-- frontend/views/editor/buildMenuDefinitions.ts | 129 ++-- frontend/views/editor/editor-actions.ts | 5 +- frontend/views/editor/video-editor-utils.ts | 18 + start-dev.bat | 8 + 35 files changed, 2327 insertions(+), 672 deletions(-) create mode 100644 frontend/lib/i18n.tsx create mode 100644 frontend/lib/locales/en.json create mode 100644 frontend/lib/locales/zh.json create mode 100644 start-dev.bat diff --git a/.gitignore b/.gitignore index 4f9ea8e73..1dae643d9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ release/ *.tsbuildinfo # Python +backend/python/ backend/.venv/ backend/__pycache__/ backend/**/__pycache__/ diff --git a/frontend/App.tsx b/frontend/App.tsx index 23e4de263..89ef70ff8 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Loader2, AlertCircle, Settings, FileText } from 'lucide-react' +import { I18nProvider, useT } from './lib/i18n' import { ApiClient, type ApiSuccessOf } from './lib/api-client' import { ProjectProvider } from './contexts/ProjectContext' import { ViewProvider, useView } from './contexts/ViewContext' @@ -24,6 +25,7 @@ type LtxRecommendation = ApiSuccessOf<'getLtxRecommendation'> type LtxUpgradeRecommendation = Extract function AppContent() { + const { t } = useT() const { currentView } = useView() const { connected, processStatus, isLoading: backendLoading } = useBackend() const { settings, saveLtxApiKey, saveFalApiKey, forceApiGenerations, isLoaded, runtimePolicyLoaded } = useAppSettings() @@ -73,8 +75,8 @@ function AppContent() { const requiredKeys = Array.isArray(detail.requiredKeys) ? detail.requiredKeys : ['ltx'] setApiGatewayRequest({ requiredKeys, - title: detail.title ?? 'Connect API Keys', - description: detail.description ?? 'Add the required API keys to continue.', + title: detail.title ?? t('app.connectApiKeys'), + description: detail.description ?? t('app.connectApiKeysDesc'), blocking: detail.blocking ?? false, includeOptionalMissing: detail.includeOptionalMissing ?? false, }) @@ -335,9 +337,9 @@ function AppContent() {
- Reconnecting... + {t('app.reconnecting')}
-

The backend process stopped unexpectedly. Attempting to restart...

+

{t('app.reconnectingDesc')}

) : null @@ -352,8 +354,8 @@ function AppContent() { if (shouldBlockForLtxKey && apiGatewayRequest === null) { setApiGatewayRequest({ requiredKeys: ['ltx'], - title: 'Connect API Keys', - description: 'This app is configured for API-only generation. Add your API key to continue.', + title: t('app.connectApiKeys'), + description: t('app.apiOnlyNotice'), blocking: true, includeOptionalMissing: true, }) @@ -433,14 +435,14 @@ function AppContent() {
-

The backend process crashed and could not be restarted

-

Review the logs below and restart the application.

+

{t('app.crashed')}

+

{t('app.crashedDesc')}

{}} embedded={true} />
- +
@@ -460,8 +462,8 @@ function AppContent() {
-

Starting LTX Desktop...

-

Initializing the inference engine

+

{t('app.loading')}

+

{t('app.loadingSub')}

{restartingOverlay} @@ -518,14 +520,14 @@ function AppContent() { @@ -545,8 +547,8 @@ function AppContent() { isOpen={shouldShowGateway} blocking={apiGatewayRequest?.blocking} onClose={() => setApiGatewayRequest(null)} - title={apiGatewayRequest?.title ?? 'Connect API Keys'} - description={apiGatewayRequest?.description ?? 'Add the required API keys to continue.'} + title={apiGatewayRequest?.title ?? t('app.connectApiKeys')} + description={apiGatewayRequest?.description ?? t('app.connectApiKeysDesc')} sections={gatewaySections} /> {ltxUpgradeRecommendation && ( @@ -561,7 +563,7 @@ function AppContent() {
- Loading settings... + {t('app.loadingSettings')}
)} @@ -570,7 +572,7 @@ function AppContent() {
- Finalizing setup... + {t('app.finalizingSetup')}
)} @@ -578,7 +580,7 @@ function AppContent() { {isForcedFirstRun && firstRunFinalizeError && (
-

Setup finalization failed

+

{t('app.setupFailed')}

{firstRunFinalizeError}

@@ -602,15 +604,17 @@ function AppContent() { export default function App() { return ( - - - - - - - - - - + + + + + + + + + + + + ) } diff --git a/frontend/components/ApiGatewayModal.tsx b/frontend/components/ApiGatewayModal.tsx index 4713c917a..ee1bdd7f6 100644 --- a/frontend/components/ApiGatewayModal.tsx +++ b/frontend/components/ApiGatewayModal.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { KeyRound, X, Zap } from 'lucide-react' +import { useT } from '../lib/i18n' import { ApiKeyHelperRow, LtxApiKeyInput } from './LtxApiKeyInput' export type ApiKeyType = 'ltx' | 'fal' @@ -47,6 +48,7 @@ export function ApiGatewayModal({ sections, blocking = false, }: ApiGatewayModalProps) { + const { t } = useT() const [values, setValues] = useState>({ ltx: '', fal: '' }) const [isSaving, setIsSaving] = useState>({ ltx: false, fal: false }) const [errors, setErrors] = useState>({ ltx: null, fal: null }) @@ -153,13 +155,13 @@ export function ApiGatewayModal({

{section.title}

- {section.required ? 'Required' : 'Optional'} + {section.required ? t('apiGateway.required') : t('settings.optional')}

{section.description}

- {configured ? 'Configured' : 'Not set'} + {configured ? t('apiGateway.configured') : t('apiGateway.notSet')}
@@ -178,11 +180,11 @@ export function ApiGatewayModal({ disabled={!canSubmit} className="px-3 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-500 disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed transition-colors whitespace-nowrap" > - {saving ? 'Saving...' : 'Save Key'} + {saving ? t('apiGateway.saving') : t('settings.saveKey')} @@ -200,7 +202,7 @@ export function ApiGatewayModal({ {blocking && requiredMissing && (
- Required API keys are missing. Add them to continue. + {t('apiGateway.missing')}
)} diff --git a/frontend/components/ExportModal.tsx b/frontend/components/ExportModal.tsx index 28befde2b..dfc62f915 100644 --- a/frontend/components/ExportModal.tsx +++ b/frontend/components/ExportModal.tsx @@ -13,6 +13,7 @@ import { selectTracks, } from '../views/editor/editor-selectors' import { useEditorActions, useEditorStore } from '../views/editor/editor-store' +import { useT } from '../lib/i18n' interface ExportModalProps { projectName: string @@ -29,12 +30,6 @@ interface ExportSettings { quality: number // CRF for h264, profile for prores, bitrate(Mbps) for vp9 } -const CODEC_INFO: Record = { - h264: { label: 'H.264 / MP4', ext: 'mp4', description: 'Most compatible format', filterName: 'MP4 Video' }, - prores: { label: 'ProRes / MOV', ext: 'mov', description: 'Professional editing format', filterName: 'QuickTime Movie' }, - vp9: { label: 'VP9 / WebM', ext: 'webm', description: 'Web-optimized format', filterName: 'WebM Video' }, -} - const RESOLUTIONS = [ { label: '4K (3840 x 2160)', width: 3840, height: 2160 }, { label: '1080p (1920 x 1080)', width: 1920, height: 1080 }, @@ -160,6 +155,13 @@ export function ExportModal({ projectName }: ExportModalProps) { const clips = useEditorStore(selectClips) const tracks = useEditorStore(selectTracks) const subtitles = useEditorStore(selectSubtitles) + const { t } = useT() + + const CODEC_INFO: Record = useMemo(() => ({ + h264: { label: 'H.264 / MP4', ext: 'mp4', description: t('export.mostCompatible'), filterName: 'MP4 Video' }, + prores: { label: 'ProRes / MOV', ext: 'mov', description: t('export.professional'), filterName: 'QuickTime Movie' }, + vp9: { label: 'VP9 / WebM', ext: 'webm', description: t('export.webOptimized'), filterName: 'WebM Video' }, + }), [t]) const exportClips = useMemo(() => ( clips @@ -347,7 +349,7 @@ export function ExportModal({ projectName }: ExportModalProps) { setExportProgress(100) setExportPath(filePath) - setExportFrameInfo('Export complete') + setExportFrameInfo('') setExportStatus('done') } catch (err) { setExportError(String(err)) @@ -369,7 +371,7 @@ export function ExportModal({ projectName }: ExportModalProps) { > {/* Header */}
-

Export

+

{t('export.export')}

@@ -419,7 +421,7 @@ export function ExportModal({ projectName }: ExportModalProps) {
-

Export complete

+

{t('export.exportComplete')}

{exportPath}

{exportFrameInfo &&

{exportFrameInfo}

}
@@ -436,7 +438,7 @@ export function ExportModal({ projectName }: ExportModalProps) { }} > - Show in Folder + {t('export.showInFolder')} @@ -461,7 +463,7 @@ export function ExportModal({ projectName }: ExportModalProps) {
-

Export failed

+

{t('export.exportFailed')}

{exportError}

@@ -474,7 +476,7 @@ export function ExportModal({ projectName }: ExportModalProps) { setExportType(null) }} > - Try Again + {t('export.tryAgain')} )} @@ -491,8 +493,8 @@ export function ExportModal({ projectName }: ExportModalProps) {
-

Package (FCPXML)

-

For Premiere Pro & DaVinci Resolve

+

{t('export.package')}

+

{t('export.packageDesc')}

@@ -508,13 +510,13 @@ export function ExportModal({ projectName }: ExportModalProps) { {/* Divider */}
- Video Export + {t('export.videoExport')}
{/* Format selector */}
- +
{(Object.keys(CODEC_INFO) as ExportCodec[]).map(codec => ( {clips.length === 0 && ( -

Add clips to the timeline to export.

+

{t('export.addClips')}

)}
)} diff --git a/frontend/components/FirstRunSetup.tsx b/frontend/components/FirstRunSetup.tsx index 27b526b67..8679e8cc1 100644 --- a/frontend/components/FirstRunSetup.tsx +++ b/frontend/components/FirstRunSetup.tsx @@ -1,9 +1,10 @@ -import { useState, useEffect, useRef, useCallback } from 'react' +import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client' import { logger } from '../lib/logger' import { useHfAuth } from '../hooks/use-hf-auth' import { useHfModelAccess } from '../hooks/use-hf-model-access' import { useAppSettings } from '../contexts/AppSettingsContext' +import { useT } from '../lib/i18n' import './FirstRunSetup.css' interface LaunchGateProps { @@ -24,18 +25,6 @@ type DownloadStepSpec = { cpIds: ModelCheckpointID[] } -// Fun loading messages -const INSTALL_MESSAGES = [ - "Downloading model weights...", - "Teaching AI to dream in 4K...", - "Loading neural pathways...", - "Calibrating inference engine...", - "Almost there...", - "Unpacking the magic...", - "Configuring parameters...", - "Finalizing installation..." -] - function uniqueCpIds(cpIds: readonly ModelCheckpointID[]): ModelCheckpointID[] { return [...new Set(cpIds)] } @@ -78,6 +67,17 @@ export function LaunchGate({ onComplete, onAcceptLicense, }: LaunchGateProps) { + const { t } = useT() + const INSTALL_MESSAGES = useMemo(() => [ + t('firstRun.installMessages.0'), + t('firstRun.installMessages.1'), + t('firstRun.installMessages.2'), + t('firstRun.installMessages.3'), + t('firstRun.installMessages.4'), + t('firstRun.installMessages.5'), + t('firstRun.installMessages.6'), + t('firstRun.installMessages.7'), + ], [t]) const [currentStep, setCurrentStep] = useState(showLicenseStep ? 'license' : 'location') const [installPath, setInstallPath] = useState('') const [downloadProgress, setDownloadProgress] = useState(null) @@ -245,7 +245,7 @@ export function LaunchGate({ if (progress.status === 'error') { downloadQueueRef.current = [] - setDownloadError(progress.error || 'Download failed.') + setDownloadError(progress.error || t('firstRun.downloadFailed')) } else if (progress.status === 'complete') { const nextStep = downloadQueueRef.current.shift() ?? null if (nextStep) { @@ -297,7 +297,7 @@ export function LaunchGate({ await startDownloadStep(downloadSteps[0]) } catch (e) { logger.error(`Download start error: ${e}`) - setDownloadError(e instanceof Error ? e.message : 'Failed to start model download.') + setDownloadError(e instanceof Error ? e.message : t('firstRun.failedStart')) } } @@ -323,7 +323,7 @@ export function LaunchGate({ } setCurrentStep('location') } catch (e) { - setActionError(e instanceof Error ? e.message : 'Failed to accept license.') + setActionError(e instanceof Error ? e.message : t('firstRun.failedAccept')) } finally { setIsActionPending(false) } @@ -344,7 +344,7 @@ export function LaunchGate({ try { await onComplete() } catch (e) { - setActionError(e instanceof Error ? e.message : 'Failed to complete setup.') + setActionError(e instanceof Error ? e.message : t('firstRun.failedSetup')) } finally { setIsActionPending(false) } @@ -352,10 +352,10 @@ export function LaunchGate({ // Get button text const getNextButtonText = () => { - if (currentStep === 'license') return licenseOnly ? 'Accept' : 'Next' - if (currentStep === 'location') return 'Install' - if (currentStep === 'complete') return 'Finish' - return 'Continue' + if (currentStep === 'license') return licenseOnly ? t('firstRun.accept') : t('firstRun.next') + if (currentStep === 'location') return t('firstRun.install') + if (currentStep === 'complete') return t('firstRun.finish') + return t('firstRun.continue') } // Check if next button should be disabled @@ -428,10 +428,10 @@ export function LaunchGate({ fontWeight: 700, marginBottom: 6 }}> - LTX-2 Model License + {t('firstRun.modelLicense')}

- The LTX-2 model is subject to the following license agreement. Please review and accept before downloading. + {t('firstRun.licenseDesc')}

- Retry + {t('firstRun.retry')}
) : licenseText === null ? ( @@ -489,7 +489,7 @@ export function LaunchGate({ - Loading license... + {t('firstRun.loadingLicense')}
) : (
- I have read and agree to the LTX-2 Community License Agreement + {t('firstRun.acceptLicense')}
@@ -551,10 +551,10 @@ export function LaunchGate({ fontWeight: 700, marginBottom: 6 }}> - Choose Location + {t('firstRun.chooseLocation')}

- Select where to install the model files. + {t('firstRun.chooseLocationDesc')}

- Browse + {t('firstRun.browse')}
@@ -608,7 +608,7 @@ export function LaunchGate({ color: '#a0a0a0', marginTop: 10 }}> - Available: {availableSpace} + {t('firstRun.available')}: {availableSpace}
@@ -621,14 +621,14 @@ export function LaunchGate({ }}>
@@ -636,7 +636,7 @@ export function LaunchGate({ type="password" value={ltxApiKey} onChange={(e) => setLtxApiKey(e.target.value)} - placeholder="Enter API key to skip text encoder download..." + placeholder={t('firstRun.apiKeyPlaceholder')} style={{ width: '100%', background: '#1a1a1a', @@ -651,11 +651,10 @@ export function LaunchGate({

{ltxApiKey ? ( - ✓ Text encoder download will be skipped (using API instead) + {t('firstRun.apiKeySkipNotice')} ) : ( - 'If you have an LTX API key, entering it here skips the 25 GB text encoder download. ' + - 'The API provides faster text encoding (~1s vs 23s local).' + t('firstRun.apiKeyTip') )}

@@ -670,25 +669,25 @@ export function LaunchGate({ }}>
{hfAuthStatus === 'authenticated' ? (

- ✓ Authenticated — ready to download models. + {t('firstRun.authenticatedReady')}

) : ( <>

- Sign in to HuggingFace to download model files. + {t('firstRun.signInToDownload')}

)} @@ -723,14 +722,14 @@ export function LaunchGate({ }}>

- Some models require you to accept their license on HuggingFace before downloading. + {t('firstRun.modelAccessDesc')}

{Object.entries(accessMap) @@ -761,7 +760,7 @@ export function LaunchGate({ transition: 'all 0.2s ease', }} > - Request access + {t('settings.requestAccess')}
))} @@ -816,7 +815,7 @@ export function LaunchGate({ textShadow: '0 1px 4px rgba(0,0,0,0.9)', zIndex: 10 }}> - Generated by PongFlongo + {t('firstRun.generatedBy')} @@ -860,7 +859,7 @@ export function LaunchGate({ color: '#ffffff', }} > - Back + {t('firstRun.back')} @@ -889,7 +888,7 @@ export function LaunchGate({ marginBottom: 8 }}> - {totalProgress > 85 ? 'Installing...' : 'Downloading...'} + {totalProgress > 85 ? t('firstRun.installing') : t('firstRun.downloading')} {Math.round(totalProgress)}% @@ -942,7 +941,7 @@ export function LaunchGate({ )} {runningDownloadProgress && runningDownloadProgress.speed_bytes_per_sec > 0 && ( - ETA: {getTimeRemaining()} + {t('firstRun.eta')}: {getTimeRemaining()} )} @@ -955,7 +954,7 @@ export function LaunchGate({ fontSize: 11, color: '#666' }}> - File {runningDownloadProgress.completed_files.length + 1} of {runningDownloadProgress.all_files.length} + {t('firstRun.fileOf').replace('{{current}}', String(runningDownloadProgress.completed_files.length + 1)).replace('{{total}}', String(runningDownloadProgress.all_files.length))} )} @@ -997,10 +996,10 @@ export function LaunchGate({ fontWeight: 700, marginBottom: 8 }}> - Ready to Create + {t('firstRun.readyToCreate')}

- LTX Video is installed. Start generating. + {t('firstRun.readyDesc')}

{/* Install Summary */} @@ -1018,7 +1017,7 @@ export function LaunchGate({ padding: '8px 0', fontSize: 13 }}> - Location + {t('firstRun.location')} {installPath.split('\\').pop() || installPath} @@ -1036,7 +1035,7 @@ export function LaunchGate({ justifyContent: 'space-between', alignItems: 'center' }}> -
© 2026 Lightricks
+
{t('firstRun.copyright')}
{/* Next/Install/Finish Button */} diff --git a/frontend/components/GenerationErrorDialog.tsx b/frontend/components/GenerationErrorDialog.tsx index 81a052fda..d7f2a67a1 100644 --- a/frontend/components/GenerationErrorDialog.tsx +++ b/frontend/components/GenerationErrorDialog.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { AlertCircle, ChevronDown, ChevronRight, X } from 'lucide-react' +import { useT } from '../lib/i18n' import type { GenerationError } from '../lib/generation-errors' interface GenerationErrorDialogProps { @@ -34,7 +35,7 @@ function getGenericHumanMessage(message: string): string { return 'Something went wrong during generation. Please try again.' } -function getDialogModel(error: GenerationError): { +function getDialogModel(error: GenerationError, t: (key: string, vars?: Record, fallback?: string) => string): { humanMessage: string technicalDetails: string primaryAction?: { @@ -50,7 +51,7 @@ function getDialogModel(error: GenerationError): { humanMessage: 'Your LTX API credits are insufficient for this generation. Buy more credits in LTX and try again.', technicalDetails: JSON.stringify(error.error, null, 2), primaryAction: { - label: 'Buy Credits', + label: t('genError.buyCredits'), onClick: () => { void window.electronAPI.openLtxBillingPage() }, @@ -71,8 +72,9 @@ function getDialogModel(error: GenerationError): { } export function GenerationErrorDialog({ error, onDismiss }: GenerationErrorDialogProps) { + const { t } = useT() const [detailsExpanded, setDetailsExpanded] = useState(false) - const dialogModel = getDialogModel(error) + const dialogModel = getDialogModel(error, t) return (
@@ -80,7 +82,7 @@ export function GenerationErrorDialog({ error, onDismiss }: GenerationErrorDialo
-

Generation Failed

+

{t('genError.title')}

{detailsExpanded && (
@@ -117,7 +119,7 @@ export function GenerationErrorDialog({ error, onDismiss }: GenerationErrorDialo
               onClick={onDismiss}
               className="px-4 py-2 bg-zinc-800 text-zinc-100 text-sm font-medium rounded-lg hover:bg-zinc-700 transition-colors"
             >
-              Try Again
+              {t('genError.tryAgain')}
             
           ) : null}
           {dialogModel.primaryAction ? (
@@ -132,7 +134,7 @@ export function GenerationErrorDialog({ error, onDismiss }: GenerationErrorDialo
               onClick={onDismiss}
               className="px-4 py-2 bg-zinc-100 text-zinc-900 text-sm font-medium rounded-lg hover:bg-white transition-colors"
             >
-              Try Again
+              {t('genError.tryAgain')}
             
           )}
         
diff --git a/frontend/components/KeyboardShortcutsModal.tsx b/frontend/components/KeyboardShortcutsModal.tsx index ed2a6a9db..55aca3610 100644 --- a/frontend/components/KeyboardShortcutsModal.tsx +++ b/frontend/components/KeyboardShortcutsModal.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { X, Search, Keyboard, RotateCcw, Save, AlertTriangle, ChevronDown, GripVertical, Trash2 } from 'lucide-react' +import { useT } from '../lib/i18n' import { useKeyboardShortcuts } from '../contexts/KeyboardShortcutsContext' import { ACTION_REGISTRY, @@ -8,6 +9,8 @@ import { formatKeyCombo, findConflicts, ActionDefinition, + translateActionLabel, + translateActionDescription, } from '../lib/keyboard-shortcuts' // ── Visual keyboard layout (US QWERTY) ── @@ -117,6 +120,7 @@ const CATEGORY_COLORS: Record
-

Keyboard Shortcuts

-

Customize keybindings — drag actions onto keys or click Edit

+

{t('keyboard.title')}

+

{t('keyboard.customize')}

{/* Delete button — only for user-created (non-built-in) presets */} {!p.builtIn && ( @@ -418,7 +422,7 @@ export function KeyboardShortcutsModal() { setSearchQuery(e.target.value)} className="w-full pl-8 pr-3 py-1.5 bg-zinc-800 rounded-md text-[11px] text-white placeholder-zinc-600 outline-none border border-zinc-700/40 focus:border-blue-500/50 transition-colors" @@ -429,10 +433,10 @@ export function KeyboardShortcutsModal() { {/* Save as custom */} @@ -440,17 +444,17 @@ export function KeyboardShortcutsModal() { {showSaveDialog && (
-

Save as custom preset:

+

{t('keyboard.saveAsPrompt')}

setSavePresetName(e.target.value)} onKeyDown={(e) => { @@ -474,7 +478,7 @@ export function KeyboardShortcutsModal() { disabled={!savePresetName.trim()} className="w-full py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-zinc-700 disabled:text-zinc-500 text-white text-[11px] font-medium rounded transition-colors" > - Save Preset + {t('keyboard.savePreset')}
)} @@ -486,7 +490,7 @@ export function KeyboardShortcutsModal() {
- {conflicts.size} shortcut conflict{conflicts.size > 1 ? 's' : ''} detected — some keys are assigned to multiple actions + {t('keyboard.conflictsDetected', { count: conflicts.size })}
)} @@ -496,7 +500,7 @@ export function KeyboardShortcutsModal() {
{/* Modifier toggles */}
- Modifiers: + {t('keyboard.modifiers')} {([ { label: 'Ctrl', active: kbModCtrl, toggle: () => setKbModCtrl(v => !v) }, { label: 'Shift', active: kbModShift, toggle: () => setKbModShift(v => !v) }, @@ -522,7 +526,7 @@ export function KeyboardShortcutsModal() { return (
- {cat} + {t(`keyboard.${cat.toLowerCase()}`)}
) })} @@ -583,7 +587,7 @@ export function KeyboardShortcutsModal() { ? `${assigned.action.label} (${assigned.action.category})` : isModifier ? kbKey.label - : 'Unassigned — drag an action here' + : t('keyboard.unassignedHint') } > {/* Key label */} @@ -600,7 +604,7 @@ export function KeyboardShortcutsModal() { - {assigned.action.label.replace(/ Tool$/, '').replace(/ \(.*\)$/, '')} + {translateActionLabel(assigned.action.label, t).replace(/ Tool$/, '').replace(/ \(.*\)$/, '')} )} {/* Conflict indicator */} @@ -617,9 +621,9 @@ export function KeyboardShortcutsModal() { {/* Drag hint */} {draggedActionId && (
- Drop on a key to assign — current modifiers: { + {t('keyboard.dropHint', { modifiers: [kbModCtrl && 'Ctrl', kbModShift && 'Shift', kbModAlt && 'Alt'].filter(Boolean).join('+') || 'None' - } + })}
)}
@@ -633,7 +637,7 @@ export function KeyboardShortcutsModal() { !selectedCategory ? 'bg-blue-600/20 text-blue-300' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800' }`} > - All + {t('keyboard.all')} {categories.map(cat => { const c = CATEGORY_COLORS[cat] @@ -646,7 +650,7 @@ export function KeyboardShortcutsModal() { }`} >
- {cat} + {t(`keyboard.${cat.toLowerCase()}`)} ) })} @@ -663,7 +667,7 @@ export function KeyboardShortcutsModal() { {/* Category header */}
- {cat} + {t(`keyboard.${cat.toLowerCase()}`)}
{/* Action rows */} {actions.map(action => { @@ -700,10 +704,10 @@ export function KeyboardShortcutsModal() { {/* Action label */}
- {action.label} + {translateActionLabel(action.label, t)} {action.description && ( - {action.description} + {translateActionDescription(action.description, t)} )} {hasConflict && ( @@ -714,7 +718,7 @@ export function KeyboardShortcutsModal() {
{isRecording ? (
- Press a key... + {t('keyboard.pressKey')} {combos.length > 0 && ( )}
@@ -778,13 +782,13 @@ export function KeyboardShortcutsModal() { {/* Footer */}
- {ACTION_REGISTRY.length} actions · Drag actions onto keys or click "Edit" to record + {t('keyboard.footer', { count: ACTION_REGISTRY.length })}
diff --git a/frontend/components/LogViewer.tsx b/frontend/components/LogViewer.tsx index de5d677aa..fc4961b7f 100644 --- a/frontend/components/LogViewer.tsx +++ b/frontend/components/LogViewer.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { X, FolderOpen, RefreshCw, ChevronDown, ChevronUp, Download } from 'lucide-react' +import { useT } from '../lib/i18n' import { Button } from './ui/button' import { logger } from '../lib/logger' @@ -10,6 +11,7 @@ interface LogViewerProps { } export function LogViewer({ isOpen, onClose, embedded = false }: LogViewerProps) { + const { t } = useT() const [logs, setLogs] = useState([]) const [logPath, setLogPath] = useState('') const [isLoading, setIsLoading] = useState(false) @@ -70,7 +72,7 @@ export function LogViewer({ isOpen, onClose, embedded = false }: LogViewerProps) {/* Header */}
-

Logs

+

{t('logs.logs')}

{logPath} @@ -82,7 +84,7 @@ export function LogViewer({ isOpen, onClose, embedded = false }: LogViewerProps) onClick={handleDownload} disabled={logs.length === 0} className="text-zinc-400 hover:text-white" - title="Download logs" + title={t('logs.download')} > @@ -91,7 +93,7 @@ export function LogViewer({ isOpen, onClose, embedded = false }: LogViewerProps) size="sm" onClick={handleOpenFolder} className="text-zinc-400 hover:text-white" - title="Open log folder" + title={t('logs.openFolder')} > @@ -101,7 +103,7 @@ export function LogViewer({ isOpen, onClose, embedded = false }: LogViewerProps) onClick={fetchLogs} disabled={isLoading} className="text-zinc-400 hover:text-white" - title="Refresh logs" + title={t('logs.refresh')} > @@ -110,7 +112,7 @@ export function LogViewer({ isOpen, onClose, embedded = false }: LogViewerProps) size="sm" onClick={() => setAutoScroll(!autoScroll)} className={`${autoScroll ? 'text-blue-400' : 'text-zinc-400'} hover:text-white`} - title={autoScroll ? 'Auto-scroll enabled' : 'Auto-scroll disabled'} + title={autoScroll ? t('logs.autoScrollOn') : t('logs.autoScrollOff')} > {autoScroll ? : } @@ -134,7 +136,7 @@ export function LogViewer({ isOpen, onClose, embedded = false }: LogViewerProps) > {logs.length === 0 ? (
- No logs yet... + {t('logs.noLogs')}
) : (
@@ -163,8 +165,8 @@ export function LogViewer({ isOpen, onClose, embedded = false }: LogViewerProps) {/* Footer */}
- {logs.length} lines (last 200) - Auto-refreshing every 2s + {logs.length} lines {t('logs.lastLines')} + {t('logs.autoRefresh')}
) diff --git a/frontend/components/LtxUpgradePrompt.tsx b/frontend/components/LtxUpgradePrompt.tsx index a49ec3238..a46f4019b 100644 --- a/frontend/components/LtxUpgradePrompt.tsx +++ b/frontend/components/LtxUpgradePrompt.tsx @@ -4,6 +4,7 @@ import { ApiClient, type ApiSuccessOf } from '../lib/api-client' import { logger } from '../lib/logger' import { useHfAuth } from '../hooks/use-hf-auth' import { useHfModelAccess } from '../hooks/use-hf-model-access' +import { useT } from '../lib/i18n' import { Button } from './ui/button' import './LtxUpgradePrompt.css' @@ -27,6 +28,7 @@ export function LtxUpgradePrompt({ onClose, onComplete, }: LtxUpgradePromptProps) { + const { t } = useT() const [wantsUpgrade, setWantsUpgrade] = useState(false) const [phase, setPhase] = useState('idle') const [downloadSessionId, setDownloadSessionId] = useState(null) @@ -181,10 +183,10 @@ export function LtxUpgradePrompt({
- Optional Upgrade + {t('upgrade.title')}

- LTX Model Upgrade Detected! + {t('upgrade.detected')}

Upgrade target: {recommendation.ltx_model_id} @@ -220,7 +222,7 @@ export function LtxUpgradePrompt({ disabled={!canClose} className="h-4 w-4 rounded border-blue-300/40 bg-slate-950 text-blue-500 focus:ring-blue-400" /> - I want this! + {t('upgrade.wantIt')}

@@ -230,7 +232,7 @@ export function LtxUpgradePrompt({
-

Connect Hugging Face

+

{t('upgrade.connectHf')}

Sign in to download this checkpoint upgrade from Hugging Face.

@@ -274,7 +276,7 @@ export function LtxUpgradePrompt({
-

Accept model access

+

{t('upgrade.acceptAccess')}

Accept the Hugging Face license for these repos before starting the upgrade.

@@ -309,7 +311,7 @@ export function LtxUpgradePrompt({
-

Ready when you are

+

{t('upgrade.ready')}

This will download {recommendation.cps_to_download.length} checkpoint {recommendation.cps_to_download.length === 1 ? '' : 's'} and swap out the old primary model. @@ -322,7 +324,7 @@ export function LtxUpgradePrompt({ className="bg-blue-600 text-white hover:bg-blue-500" > - Upgrade now + {t('upgrade.upgradeNow')}

diff --git a/frontend/components/SettingsModal.tsx b/frontend/components/SettingsModal.tsx index 9c353d0e3..35abd85d7 100644 --- a/frontend/components/SettingsModal.tsx +++ b/frontend/components/SettingsModal.tsx @@ -4,6 +4,7 @@ import { Button } from './ui/button' import { useAppSettings, type AppSettings } from '../contexts/AppSettingsContext' import { ApiClient, type ApiSuccessOf } from '../lib/api-client' import { logger } from '../lib/logger' +import { useT } from '../lib/i18n' import { ApiKeyHelperRow, LtxApiKeyInput, LtxApiKeyHelperRow } from './LtxApiKeyInput' import { useHfAuth } from '../hooks/use-hf-auth' import { useHfModelAccess } from '../hooks/use-hf-model-access' @@ -17,6 +18,7 @@ interface SettingsModalProps { type TabId = 'general' | 'apiKeys' | 'promptEnhancer' | 'about' export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProps) { + const { t } = useT() const { settings, updateSettings, saveLtxApiKey, saveFalApiKey, saveGeminiApiKey, forceApiGenerations } = useAppSettings() const onSettingsChange = (next: AppSettings) => updateSettings(next) const [activeTab, setActiveTab] = useState('general') @@ -247,10 +249,10 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp } const tabs = [ - { id: 'general' as TabId, label: 'General', icon: Settings }, - { id: 'apiKeys' as TabId, label: 'API Keys', icon: KeyRound }, - { id: 'promptEnhancer' as TabId, label: 'Prompt Enhancer', icon: Sparkles }, - { id: 'about' as TabId, label: 'About', icon: Info }, + { id: 'general' as TabId, label: t('settings.general'), icon: Settings }, + { id: 'apiKeys' as TabId, label: t('settings.apiKeys'), icon: KeyRound }, + { id: 'promptEnhancer' as TabId, label: t('settings.promptEnhancer'), icon: Sparkles }, + { id: 'about' as TabId, label: t('settings.about'), icon: Info }, ] return ( @@ -267,7 +269,7 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp
-

Settings

+

{t('app.settings')}

@@ -389,11 +391,11 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp -

Text Encoding

+

{t('settings.textEncoding')}

- Text encoding converts your prompt into data the AI understands. Choose how to do this. + {t('settings.textEncodingDesc')}

{/* LTX API Option (Default) */} @@ -414,11 +416,11 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp
- LTX API - Recommended + {t('settings.ltxApi')} + {t('settings.recommended')}

- Fast cloud-based text encoding (~1 second). Requires an LTX API key configured in the API Keys tab. + {t('settings.ltxApiDesc')}

- API key required — configure it in the API Keys tab. + {t('settings.apiKeyRequiredTab')}
)} @@ -440,8 +442,8 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp {!settings.useLocalTextEncoder && settings.hasLtxApiKey && (
- -

Skip repeat encoding calls

+ +

{t('settings.promptCacheDesc')}

- Local Encoder + {t('settings.localEncoder')}

- Run on your computer (~23 seconds). Requires 25 GB download. + {t('settings.localEncoderDesc')}

- Downloaded ({textEncoderRecommendation?.expected_size_gb ?? 0} GB) + {t('settings.downloaded')} ({textEncoderRecommendation?.expected_size_gb ?? 0} GB)
) : isDownloading ? (
- Downloading text encoder... + {t('settings.downloadingTextEncoder')} {downloadProgress?.status === 'downloading' ? Math.round(downloadProgress.current_file_progress) : 0}%
@@ -505,7 +507,7 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp
- Not downloaded ({textEncoderRecommendation?.expected_size_gb || 0} GB required) + {t('settings.notDownloaded')} ({textEncoderRecommendation?.expected_size_gb || 0} GB required)
{hfAuthStatus === 'authenticated' && !teAllAuthorized && Object.keys(teAccessMap).length > 0 && (
@@ -518,7 +520,7 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp onClick={(e) => { e.stopPropagation(); window.electronAPI.openHuggingFaceRepo({ repoId }) }} className="text-[10px] text-indigo-400 hover:text-indigo-300 font-medium" > - Request access + {t('settings.requestAccess')}
))} @@ -534,7 +536,7 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp className="w-full bg-blue-600 hover:bg-blue-500 text-white text-xs" > - Download Text Encoder + {t('settings.downloadTextEncoder')} {downloadError && (

{downloadError}

@@ -556,13 +558,11 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp

- Compiles the model for optimized inference. Experimental: First - generation can take 5-10+ minutes for compilation. Subsequent generations may be - 20-40% faster. Requires app restart to take effect. + {t('settings.torchCompileDesc')}

@@ -590,7 +590,7 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp
- {settings.useTorchCompile ? 'Optimized inference (recommended)' : 'Standard inference'} + {settings.useTorchCompile ? t('settings.optimizedInference') : t('settings.standardInference')}
@@ -604,11 +604,11 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp

- Use the same seed for reproducible generations. When unlocked, a random seed is used each time. + {t('settings.lockSeedDesc')}

@@ -637,7 +637,7 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp value={settings.lockedSeed ?? 42} onChange={handleLockedSeedChange} className="flex-1 px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-sm text-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent" - placeholder="Enter seed..." + placeholder={t('settings.enterSeed')} />
@@ -677,12 +677,11 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp

- Share anonymous usage data to help improve LTX Desktop. - Only basic technical information is collected — never personal data or generated content. + {t('settings.anonymousAnalyticsDesc')}

@@ -711,12 +710,11 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp
-

LTX API

+

{t('settings.ltxApiSection')}

- Your LTX API key is used for cloud text encoding, prompt enhancement, and API video generation. - Add your key below to unlock these features. + {t('settings.ltxApiSectionDesc')}

@@ -725,7 +723,7 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp ref={ltxApiKeyInputRef} value={ltxApiKeyInput} onChange={(e) => setLtxApiKeyInput(e.target.value)} - placeholder={settings.hasLtxApiKey ? 'Enter new key to replace...' : 'Enter your LTX API key...'} + placeholder={settings.hasLtxApiKey ? t('settings.enterNewKey') : t('settings.enterLtxApiKey')} stopPropagation className="flex-1" /> @@ -739,7 +737,7 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp disabled={!ltxApiKeyInput.trim()} className="px-3 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-500 disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed transition-colors whitespace-nowrap" > - Save Key + {t('settings.saveKey')}
@@ -752,12 +750,12 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp {settings.hasLtxApiKey ? ( <> - Key configured + {t('settings.keyConfigured')} ) : ( <> - API key required + {t('settings.apiKeyRequired')} )}
@@ -769,12 +767,12 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp
-

FAL AI

- Optional +

{t('settings.falAiSection')}

+ {t('settings.optional')}

- Your FAL AI key is used for generating images with Z Image Turbo when API generations are enabled. + {t('settings.falAiSectionDesc')}

@@ -783,7 +781,7 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp ref={falApiKeyInputRef} value={falApiKeyInput} onChange={(e) => setFalApiKeyInput(e.target.value)} - placeholder={settings.hasFalApiKey ? 'Enter new key to replace...' : 'Enter your FAL AI API key...'} + placeholder={settings.hasFalApiKey ? t('settings.enterNewKey') : t('settings.enterFalApiKey')} stopPropagation className="flex-1" /> @@ -797,12 +795,12 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp disabled={!falApiKeyInput.trim()} className="px-3 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-500 disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed transition-colors whitespace-nowrap" > - Save Key + {t('settings.saveKey')}
window.electronAPI.openFalApiKeyPage()} />
@@ -814,12 +812,12 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp {settings.hasFalApiKey ? ( <> - Key configured + {t('settings.keyConfigured')} ) : ( <> - Optional + {t('settings.optional')} )}
@@ -831,11 +829,11 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp
-

Gemini API

+

{t('settings.geminiApiSection')}

- Your Gemini API key is used for AI-powered prompt suggestions when filling timeline gaps. + {t('settings.geminiApiSectionDesc')}

@@ -845,7 +843,7 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp type="password" value={geminiApiKeyInput} onChange={(e) => setGeminiApiKeyInput(e.target.value)} - placeholder={settings.hasGeminiApiKey ? 'Enter new key to replace...' : 'Enter your Gemini API key...'} + placeholder={settings.hasGeminiApiKey ? t('settings.enterNewKey') : t('settings.enterGeminiApiKey')} onKeyDown={(e) => e.stopPropagation()} className="flex-1 px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> @@ -859,7 +857,7 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp disabled={!geminiApiKeyInput.trim()} className="px-3 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-500 disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed transition-colors whitespace-nowrap" > - Save Key + {t('settings.saveKey')}
@@ -871,12 +869,12 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp {settings.hasGeminiApiKey ? ( <> - Key configured + {t('settings.keyConfigured')} ) : ( <> - API key required + {t('settings.apiKeyRequired')} )}
@@ -889,7 +887,7 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp className="text-blue-400 hover:text-blue-300 transition-colors underline underline-offset-2" onClick={(e) => e.stopPropagation()} > - Get Gemini API key → + {t('settings.getGeminiApiKey')}
@@ -900,11 +898,11 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp
-

HuggingFace

+

{t('settings.huggingFace')}

- Sign in to HuggingFace to download model files. + {t('settings.huggingFaceDesc')}

@@ -916,12 +914,12 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp {hfAuthStatus === 'authenticated' ? ( <> - Signed in + {t('settings.signedIn')} ) : ( <> - Not signed in + {t('settings.notSignedIn')} )}
@@ -931,7 +929,7 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp onClick={handleHuggingFaceLogout} className="px-3 py-2 bg-zinc-700 text-white text-sm rounded-lg hover:bg-zinc-600 transition-colors" > - Sign out + {t('settings.signOut')} ) : ( )}
@@ -953,12 +951,11 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp
-

Prompt Enhancer

+

{t('settings.promptEnhancer')}

- Automatically enhances your prompts via the LTX API with rich visual details, sound descriptions, - and motion cues to help generate higher quality videos. Control independently for each generation type. + {t('settings.promptEnhancerDesc')}

{!settings.hasLtxApiKey ? ( @@ -967,10 +964,9 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp
-

LTX API key required

+

{t('settings.ltxApiKeyRequired')}

- Prompt enhancement runs server-side on the LTX API. To use this feature, you need to configure - an API key in the API Keys tab. + {t('settings.ltxApiKeyRequiredDesc')}

@@ -978,7 +974,7 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp onClick={() => setActiveTab('apiKeys')} className="w-full mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition-colors" > - Set API Key + {t('settings.setApiKey')}
@@ -992,9 +988,9 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp
T2V
- Text-to-Video + {t('settings.t2v')}

- {settings.promptEnhancerEnabledT2V ? 'Prompts will be enhanced before T2V generation' : 'T2V prompts used as-is'} + {settings.promptEnhancerEnabledT2V ? t('settings.t2vEnhanced') : t('settings.t2vAsIs')}

@@ -1015,9 +1011,9 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp
I2V
- Image-to-Video + {t('settings.i2v')}

- {settings.promptEnhancerEnabledI2V ? 'Prompts will be enhanced before I2V generation' : 'I2V prompts used as-is'} + {settings.promptEnhancerEnabledI2V ? t('settings.i2vEnhanced') : t('settings.i2vAsIs')}

@@ -1040,14 +1036,14 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp {showModelLicense ? (
-

LTX-2 Model License

+

{t('settings.modelLicense')}

@@ -1057,14 +1053,14 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp
               ) : showNotices ? (
                 
-

Third-Party Notices

+

{t('settings.thirdPartyNotices')}

@@ -1075,19 +1071,19 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp
                 
{/* App Identity */}
-

LTX Desktop

+

{t('settings.aboutApp')}

Version {appVersion || '...'}

-

AI-Powered Video Editor

+

{t('settings.aboutTagline')}

{/* License */}
- License + {t('settings.license')}

- Licensed under the Apache License, Version 2.0 + {t('settings.apacheLicense')}

@@ -1097,10 +1093,10 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp - LTX-2 Model License + {t('settings.modelLicense')}

- The LTX-2 model is subject to the LTX-2 Community License Agreement, accepted during first-run setup. + {t('settings.modelLicenseDesc')}

@@ -1121,10 +1117,10 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp - Third-Party Notices + {t('settings.thirdPartyNotices')}

- This application uses open-source software and AI models subject to their own license terms. + {t('settings.thirdPartyNoticesDesc')}

{/* Copyright */}

- Copyright © 2026 Lightricks + {t('settings.copyright')}

)} @@ -1152,7 +1148,7 @@ export function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProp onClick={onClose} className="bg-zinc-700 hover:bg-zinc-600 text-white" > - Done + {t('settings.done')}
diff --git a/frontend/components/SettingsPanel.tsx b/frontend/components/SettingsPanel.tsx index dea6c78d2..5b7b015e7 100644 --- a/frontend/components/SettingsPanel.tsx +++ b/frontend/components/SettingsPanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo } from 'react' +import { useT } from '../lib/i18n' import { Select } from './ui/select' import { areVideoGenerationSettingsEquivalent, @@ -47,6 +48,7 @@ export function SettingsPanel({ hideDuration = false, videoSettingsMessage, }: SettingsPanelProps) { + const { t } = useT() const isImageMode = mode === 'text-to-image' const resolvedVideoOptions = useMemo(() => { if (isImageMode || !videoModelSpecs || videoModelSpecs.length === 0) return null @@ -97,7 +99,7 @@ export function SettingsPanel({ {/* Aspect Ratio and Quality side by side */}
@@ -128,7 +130,7 @@ export function SettingsPanel({ if (!videoModelSpecs || videoModelSpecs.length === 0 || !resolvedVideoOptions || !resolvedVideoOptions.hasCompatibleOptions) { return (
- {videoSettingsMessage || 'Loading generation settings...'} + {videoSettingsMessage || t('models.loadingSettings')}
) } @@ -142,7 +144,7 @@ export function SettingsPanel({ return (
handleChange('duration', parseInt(e.target.value))} disabled={disabled} > {resolvedVideoOptions.durationOptions.map((duration) => ( ))} )} handleChange('fps', parseInt(e.target.value))} disabled={disabled} @@ -202,7 +204,7 @@ export function SettingsPanel({ {/* Aspect Ratio */} handleChange('audio', e.target.value === 'on')} @@ -228,20 +230,20 @@ export function SettingsPanel({
diff --git a/frontend/lib/i18n.tsx b/frontend/lib/i18n.tsx new file mode 100644 index 000000000..e01304a52 --- /dev/null +++ b/frontend/lib/i18n.tsx @@ -0,0 +1,103 @@ +import React, { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react' + +// ============================================================ +// LTX Desktop i18n — lightweight React Context translation +// ============================================================ + +export type Language = 'en' | 'zh' + +// Dynamic loader: imported by the bundler, not embedded inline +type TranslationMap = Record + +const localeModules: Record Promise> = { + en: () => import('./locales/en.json').then(m => m.default || m), + zh: () => import('./locales/zh.json').then(m => m.default || m), +} + +interface I18nContextValue { + language: Language + setLanguage: (lang: Language) => void + t: (key: string, vars?: Record, fallback?: string) => string + loading: boolean +} + +const I18nContext = createContext({ + language: 'en', + setLanguage: () => {}, + t: (key: string, _vars?: Record, fallback?: string) => fallback ?? key, + loading: false, +}) + +const STORAGE_KEY = 'ltx-language' + +function getSavedLanguage(): Language { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored === 'zh' || stored === 'en') return stored + // Detect system language + const navLang = navigator.language?.toLowerCase() || '' + if (navLang.startsWith('zh')) return 'zh' + } catch {} + return 'en' +} + +function saveLanguage(lang: Language): void { + try { localStorage.setItem(STORAGE_KEY, lang) } catch {} +} + +export function I18nProvider({ children, defaultLanguage }: { + children: React.ReactNode + defaultLanguage?: Language +}) { + const [language, setLanguageState] = useState(defaultLanguage ?? getSavedLanguage) + const [messages, setMessages] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + setLoading(true) + localeModules[language]() + .then(module => { + if (!cancelled) { + setMessages(module) + setLoading(false) + } + }) + .catch(() => { + if (!cancelled) { + // Fallback: use empty dict (keys become display text) + setMessages({}) + setLoading(false) + } + }) + return () => { cancelled = true } + }, [language]) + + const setLanguage = useCallback((lang: Language) => { + setLanguageState(lang) + saveLanguage(lang) + }, []) + + const t = useCallback((key: string, vars?: Record, fallback?: string): string => { + if (!messages) return fallback ?? key + let value = messages[key] ?? fallback ?? key + if (vars) { + value = value.replace(/\{\{(\w+)\}\}/g, (_, v) => String(vars[v] ?? '')) + } + return value + }, [messages]) + + const value = useMemo(() => ({ language, setLanguage, t, loading }), [language, setLanguage, t, loading]) + + return React.createElement(I18nContext.Provider, { value }, children) +} + +export function useI18n(): I18nContextValue { + return useContext(I18nContext) +} + +// Convenience shorthand +export function useT() { + const { t, language, setLanguage } = useI18n() + return { t, language, setLanguage } +} diff --git a/frontend/lib/keyboard-shortcuts.ts b/frontend/lib/keyboard-shortcuts.ts index 47e114fae..6d0a19571 100644 --- a/frontend/lib/keyboard-shortcuts.ts +++ b/frontend/lib/keyboard-shortcuts.ts @@ -488,3 +488,83 @@ export function cloneLayout(layout: KeyboardLayout): KeyboardLayout { } return result } + +// ── I18n ── +// Maps each ACTION_REGISTRY English label to its i18n key. +// Components that render action labels should use `translateActionLabel()` below. + +export const ACTION_I18N_MAP: Record = { + 'Selection Tool': 'editor.selectionTool', + 'Blade / Razor Tool': 'editor.bladeTool', + 'Ripple Edit Tool': 'editor.rippleTool', + 'Roll Edit Tool': 'editor.rollTool', + 'Slide Tool': 'editor.slideTool', + 'Slip Tool': 'editor.slipTool', + 'Track Select Forward': 'editor.trackSelect', + 'Play / Pause': 'editor.playPause', + 'Stop': 'editor.stop', + 'Shuttle Reverse (J)': 'editor.shuttleReverse', + 'Shuttle Stop (K)': 'editor.shuttleStop', + 'Shuttle Forward (L)': 'editor.shuttleForward', + 'Step Backward (1 frame)': 'editor.stepBackward', + 'Step Forward (1 frame)': 'editor.stepForward', + 'Jump Backward (1 sec)': 'editor.jumpBackward', + 'Jump Forward (1 sec)': 'editor.jumpForward', + 'Go to Start': 'editor.goToStart', + 'Go to End': 'editor.goToEnd', + 'Go to In Point': 'editor.goToIn', + 'Go to Out Point': 'editor.goToOut', + 'Undo': 'editor.undo', + 'Redo': 'editor.redo', + 'Cut': 'editor.cut', + 'Copy': 'editor.copy', + 'Paste': 'editor.paste', + 'Delete': 'editor.delete', + 'Select All': 'editor.selectAll', + 'Deselect All': 'editor.deselectAll', + 'Insert Edit': 'editor.insertEdit', + 'Overwrite Edit': 'editor.overwriteEdit', + 'Match Frame': 'editor.matchFrame', + 'Set In Point': 'editor.setIn', + 'Set Out Point': 'editor.setOut', + 'Clear In Point': 'editor.clearIn', + 'Clear Out Point': 'editor.clearOut', + 'Clear In / Out': 'editor.clearInOut', + 'Zoom In': 'editor.zoomIn', + 'Zoom Out': 'editor.zoomOut', + 'Fit Timeline to View': 'editor.fitTimeline', + 'Toggle Snap': 'editor.toggleSnap', + 'Go to Previous Edit Point': 'editor.prevEdit', + 'Go to Next Edit Point': 'editor.nextEdit', + 'Fullscreen Preview': 'editor.fullscreen', +} + +export const ACTION_DESC_I18N_MAP: Record = { + 'Load the clip under the playhead into the source monitor at the matching frame': 'editor.matchFrameDesc', + 'Jump playhead to previous cut on timeline': 'editor.prevEditDesc', + 'Jump playhead to next cut on timeline': 'editor.nextEditDesc', +} + +/** Translate an action label from English to the current locale using the given `t` function. + * Falls back to the English label if no i18n key is mapped. */ +export function translateActionLabel(label: string, t: (key: string) => string): string { + const i18nKey = ACTION_I18N_MAP[label] + if (i18nKey) { + const translated = t(i18nKey) + // Only return translation if it actually differs (i.e. the key was found); + // otherwise stick with the English default. + if (translated && translated !== i18nKey) return translated + } + return label +} + +/** Translate an action description from English to the current locale using the given `t` function. + * Falls back to the English description if no i18n key is mapped. */ +export function translateActionDescription(description: string, t: (key: string) => string): string { + const i18nKey = ACTION_DESC_I18N_MAP[description] + if (i18nKey) { + const translated = t(i18nKey) + if (translated && translated !== i18nKey) return translated + } + return description +} diff --git a/frontend/lib/locales/en.json b/frontend/lib/locales/en.json new file mode 100644 index 000000000..fc8409bb7 --- /dev/null +++ b/frontend/lib/locales/en.json @@ -0,0 +1,693 @@ +{ + "_comment": "LTX Desktop — English strings (baseline)", + + "app.title": "LTX Desktop", + "app.loading": "Starting LTX Desktop...", + "app.loadingSub": "Initializing the inference engine", + "app.reconnecting": "Reconnecting...", + "app.reconnectingDesc": "The backend process stopped unexpectedly. Attempting to restart...", + "app.crashed": "The backend process crashed and could not be restarted", + "app.crashedDesc": "Review the logs below and restart the application.", + "app.restart": "Restart Application", + "app.loadingSettings": "Loading settings...", + "app.finalizingSetup": "Finalizing setup...", + "app.setupFailed": "Setup finalization failed", + "app.retry": "Retry", + "app.viewLogs": "View Backend Logs", + "app.settings": "Settings", + "app.connectApiKeys": "Connect API Keys", + "app.connectApiKeysDesc": "Add the required API keys to continue.", + "app.apiOnlyNotice": "This app is configured for API-only generation. Add your API key to continue.", + + "home.home": "Home", + "home.recentProjects": "Recent Projects", + "home.projects": "Projects", + "home.newProject": "New Project", + "home.createProject": "Create Project", + "home.createNewProject": "Create New Project", + "home.renameProject": "Rename Project", + "home.projectName": "Project name", + "home.noProjects": "No projects yet", + "home.noProjectsDesc": "Create your first project to get started", + "home.createAndManage": "Create and manage your video projects", + "home.rename": "Rename", + "home.delete": "Delete", + "home.cancel": "Cancel", + "home.save": "Save", + "home.create": "Create", + "home.migrating": "Migrating project references...", + "home.deleteConfirm": "Delete \"{{name}}\"?", + + "project.notFound": "Project not found", + "project.goHome": "Go Home", + "project.preparing": "Preparing your project assets...", + "project.genSpace": "Gen Space", + "project.videoEditor": "Video Editor", + + "genspace.generateImages": "Generate Images", + "genspace.generateVideos": "Generate Videos", + "genspace.retake": "Retake", + "genspace.icLora": "IC-LoRA", + "genspace.favorites": "Favorites", + "genspace.noFavorites": "No favorites yet", + "genspace.startCreating": "Start Creating", + "genspace.small": "Small", + "genspace.medium": "Medium", + "genspace.large": "Large", + "genspace.copyPrompt": "Copy prompt", + "genspace.mute": "Mute", + "genspace.unmute": "Unmute", + "genspace.createVideo": "Create video", + "genspace.download": "Download", + + "settings.general": "General", + "settings.apiKeys": "API Keys", + "settings.promptEnhancer": "Prompt Enhancer", + "settings.about": "About", + + "settings.projectAssetsPath": "Project Assets Path", + "settings.projectAssetsDesc": "Where generated video and image assets are saved. Each project gets a subfolder.", + "settings.notSet": "Not set", + "settings.videosGeneration": "Videos Generation", + "settings.generateWithApi": "Generate With API", + "settings.generateWithApiDesc": "Use LTX API for video generation when an LTX API key is configured.", + "settings.apiKeyRequiredTab": "API key required — configure it in the API Keys tab.", + + "settings.textEncoding": "Text Encoding", + "settings.textEncodingDesc": "Text encoding converts your prompt into data the AI understands. Choose how to do this.", + "settings.ltxApi": "LTX API", + "settings.recommended": "Recommended", + "settings.ltxApiDesc": "Fast cloud-based text encoding (~1 second). Requires an LTX API key configured in the API Keys tab.", + "settings.promptCache": "Prompt Cache", + "settings.promptCacheDesc": "Skip repeat encoding calls", + "settings.localEncoder": "Local Encoder", + "settings.localEncoderDesc": "Run on your computer (~23 seconds). Requires 25 GB download.", + "settings.downloaded": "Downloaded", + "settings.notDownloaded": "Not downloaded", + "settings.gbRequired": "{{gb}} GB required", + "settings.gbSize": "{{gb}} GB", + "settings.downloadingTextEncoder": "Downloading text encoder...", + "settings.downloadTextEncoder": "Download Text Encoder", + "settings.requestAccess": "Request access", + + "settings.torchCompile": "Torch Compile", + "settings.torchCompileDesc": "Compiles the model for optimized inference. Experimental: First generation can take 5-10+ minutes for compilation. Subsequent generations may be 20-40% faster. Requires app restart to take effect.", + "settings.optimizedInference": "Optimized inference (recommended)", + "settings.standardInference": "Standard inference", + "settings.experimental": "Experimental", + + "settings.lockSeed": "Lock Seed", + "settings.lockSeedDesc": "Use the same seed for reproducible generations. When unlocked, a random seed is used each time.", + "settings.seedLocked": "Seed locked: {{seed}}", + "settings.randomSeed": "Random seed each generation", + "settings.enterSeed": "Enter seed...", + "settings.randomizeSeed": "Generate random seed", + + "settings.anonymousAnalytics": "Anonymous Analytics", + "settings.anonymousAnalyticsDesc": "Share anonymous usage data to help improve LTX Desktop. Only basic technical information is collected — never personal data or generated content.", + + "settings.ltxApiSection": "LTX API", + "settings.ltxApiSectionDesc": "Your LTX API key is used for cloud text encoding, prompt enhancement, and API video generation. Add your key below to unlock these features.", + "settings.saveKey": "Save Key", + "settings.keyConfigured": "Key configured", + "settings.apiKeyRequired": "API key required", + "settings.enterNewKey": "Enter new key to replace...", + "settings.enterLtxApiKey": "Enter your LTX API key...", + "settings.getLtxApiKey": "Get LTX API key", + + "settings.falAiSection": "FAL AI", + "settings.falAiSectionDesc": "Your FAL AI key is used for generating images with Z Image Turbo when API generations are enabled.", + "settings.enterFalApiKey": "Enter your FAL AI API key...", + "settings.getFalApiKey": "Get FAL API key", + + "settings.geminiApiSection": "Gemini API", + "settings.geminiApiSectionDesc": "Your Gemini API key is used for AI-powered prompt suggestions when filling timeline gaps.", + "settings.enterGeminiApiKey": "Enter your Gemini API key...", + "settings.getGeminiApiKey": "Get Gemini API key →", + + "settings.huggingFace": "HuggingFace", + "settings.huggingFaceDesc": "Sign in to HuggingFace to download model files.", + "settings.signedIn": "Signed in", + "settings.notSignedIn": "Not signed in", + "settings.signOut": "Sign out", + "settings.signInHf": "Sign in with HuggingFace", + "settings.waitingSignIn": "Waiting for sign in...", + "settings.optional": "Optional", + + "settings.promptEnhancerDesc": "Automatically enhances your prompts via the LTX API with rich visual details, sound descriptions, and motion cues to help generate higher quality videos. Control independently for each generation type.", + "settings.ltxApiKeyRequired": "LTX API key required", + "settings.ltxApiKeyRequiredDesc": "Prompt enhancement runs server-side on the LTX API. To use this feature, you need to configure an API key in the API Keys tab.", + "settings.setApiKey": "Set API Key", + "settings.t2v": "Text-to-Video", + "settings.i2v": "Image-to-Video", + "settings.t2vEnhanced": "Prompts will be enhanced before T2V generation", + "settings.t2vAsIs": "T2V prompts used as-is", + "settings.i2vEnhanced": "Prompts will be enhanced before I2V generation", + "settings.i2vAsIs": "I2V prompts used as-is", + "settings.done": "Done", + + "settings.aboutApp": "LTX Desktop", + "settings.aboutTagline": "AI-Powered Video Editor", + "settings.license": "License", + "settings.apacheLicense": "Licensed under the Apache License, Version 2.0", + "settings.modelLicense": "LTX-2 Model License", + "settings.modelLicenseDesc": "The LTX-2 model is subject to the LTX-2 Community License Agreement, accepted during first-run setup.", + "settings.viewModelLicense": "View Model License", + "settings.thirdPartyNotices": "Third-Party Notices", + "settings.thirdPartyNoticesDesc": "This application uses open-source software and AI models subject to their own license terms.", + "settings.viewThirdPartyNotices": "View Third-Party Notices", + "settings.loading": "Loading...", + "settings.back": "Back", + "settings.copyright": "Copyright © 2026 Lightricks", + + "firstRun.modelLicense": "LTX-2 Model License", + "firstRun.licenseDesc": "The LTX-2 model is subject to the following license agreement. Please review and accept before downloading.", + "firstRun.loadingLicense": "Loading license...", + "firstRun.acceptLicense": "I have read and agree to the LTX-2 Community License Agreement", + "firstRun.retry": "Retry", + + "firstRun.chooseLocation": "Choose Location", + "firstRun.chooseLocationDesc": "Select where to install the model files.", + "firstRun.browse": "Browse", + "firstRun.available": "Available", + "firstRun.apiKeyOptional": "Optional - Saves ~25 GB download", + "firstRun.ltxApiKey": "LTX API Key", + "firstRun.apiKeyPlaceholder": "Enter API key to skip text encoder download...", + "firstRun.apiKeySkipNotice": "Text encoder download will be skipped (using API instead)", + "firstRun.apiKeyTip": "If you have an LTX API key, entering it here skips the 25 GB text encoder download. The API provides faster text encoding (~1s vs 23s local).", + + "firstRun.hfAccount": "HuggingFace Account", + "firstRun.required": "Required", + "firstRun.authenticatedReady": "Authenticated — ready to download models.", + "firstRun.signInToDownload": "Sign in to HuggingFace to download model files.", + "firstRun.modelAccess": "Model Access", + "firstRun.actionRequired": "Action required", + "firstRun.modelAccessDesc": "Some models require you to accept their license on HuggingFace before downloading.", + + "firstRun.downloading": "Downloading...", + "firstRun.installing": "Installing...", + "firstRun.fileOf": "File {{current}} of {{total}}", + "firstRun.eta": "ETA", + "firstRun.readyToCreate": "Ready to Create", + "firstRun.readyDesc": "LTX Video is installed. Start generating.", + "firstRun.location": "Location", + "firstRun.accept": "Accept", + "firstRun.next": "Next", + "firstRun.install": "Install", + "firstRun.finish": "Finish", + "firstRun.continue": "Continue", + "firstRun.back": "Back", + "firstRun.failedAccept": "Failed to accept license.", + "firstRun.failedSetup": "Failed to complete setup.", + "firstRun.failedStart": "Failed to start model download.", + "firstRun.downloadFailed": "Download failed.", + "firstRun.copyright": "© 2026 Lightricks", + "firstRun.generatedBy": "Generated by PongFlongo", + "firstRun.installMessages.0": "Downloading model weights...", + "firstRun.installMessages.1": "Teaching AI to dream in 4K...", + "firstRun.installMessages.2": "Loading neural pathways...", + "firstRun.installMessages.3": "Calibrating inference engine...", + "firstRun.installMessages.4": "Almost there...", + "firstRun.installMessages.5": "Unpacking the magic...", + "firstRun.installMessages.6": "Configuring parameters...", + "firstRun.installMessages.7": "Finalizing installation...", + + "pythonSetup.downloading": "Downloading Python environment...", + "pythonSetup.extracting": "Extracting...", + "pythonSetup.settingUp": "Setting up Python environment...", + "pythonSetup.firstTime": "First-time setup -- this only happens once", + + "export.export": "Export", + "export.package": "Package (FCPXML)", + "export.packageDesc": "For Premiere Pro & DaVinci Resolve", + "export.videoExport": "Video Export", + "export.format": "Format", + "export.resolution": "Resolution", + "export.frameRate": "Frame Rate", + "export.quality": "Quality", + "export.options": "Options", + "export.mostCompatible": "Most compatible format", + "export.professional": "Professional editing format", + "export.webOptimized": "Web-optimized format", + "export.exportComplete": "Export complete", + "export.exportFailed": "Export failed", + "export.showInFolder": "Show in Folder", + "export.exportAnother": "Export Another", + "export.tryAgain": "Try Again", + "export.rendering": "Rendering video...", + "export.generatingFcpxml": "Generating FCPXML...", + "export.burnSubtitles": "Burn-in subtitles", + "export.addClips": "Add clips to the timeline to export.", + "export.exportVideo": "Export Video", + + "keyboard.title": "Keyboard Shortcuts", + "keyboard.customize": "Customize keybindings -- drag actions onto keys or click Edit", + "keyboard.hideKeyboard": "Hide Keyboard", + "keyboard.showKeyboard": "Show Keyboard", + "keyboard.searchActions": "Search actions or keys...", + "keyboard.reset": "Reset", + "keyboard.saveAs": "Save As", + "keyboard.savePreset": "Save Preset", + "keyboard.modifiers": "Modifiers:", + "keyboard.pressKey": "Press a key...", + "keyboard.unassigned": "Unassigned", + "keyboard.edit": "Edit", + "keyboard.clear": "Clear", + "keyboard.done": "Done", + "keyboard.conflict": "Conflict", + "keyboard.all": "All", + "keyboard.tools": "Tools", + "keyboard.transport": "Transport", + "keyboard.editing": "Editing", + "keyboard.marking": "Marking", + "keyboard.timeline": "Timeline", + + "editor.selectionTool": "Selection Tool", + "editor.bladeTool": "Blade / Razor Tool", + "editor.rippleTool": "Ripple Edit Tool", + "editor.rollTool": "Roll Edit Tool", + "editor.slideTool": "Slide Tool", + "editor.slipTool": "Slip Tool", + "editor.trackSelect": "Track Select Forward", + "editor.playPause": "Play / Pause", + "editor.stop": "Stop", + "editor.shuttleReverse": "Shuttle Reverse (J)", + "editor.shuttleStop": "Shuttle Stop (K)", + "editor.shuttleForward": "Shuttle Forward (L)", + "editor.stepBackward": "Step Backward (1 frame)", + "editor.stepForward": "Step Forward (1 frame)", + "editor.jumpBackward": "Jump Backward (1 sec)", + "editor.jumpForward": "Jump Forward (1 sec)", + "editor.goToStart": "Go to Start", + "editor.goToEnd": "Go to End", + "editor.goToIn": "Go to In Point", + "editor.goToOut": "Go to Out Point", + "editor.undo": "Undo", + "editor.redo": "Redo", + "editor.cut": "Cut", + "editor.copy": "Copy", + "editor.paste": "Paste", + "editor.delete": "Delete", + "editor.selectAll": "Select All", + "editor.deselectAll": "Deselect All", + "editor.insertEdit": "Insert Edit", + "editor.overwriteEdit": "Overwrite Edit", + "editor.matchFrame": "Match Frame", + "editor.matchFrameDesc": "Load the clip under the playhead into the source monitor at the matching frame", + "editor.setIn": "Set In Point", + "editor.setOut": "Set Out Point", + "editor.clearIn": "Clear In Point", + "editor.clearOut": "Clear Out Point", + "editor.clearInOut": "Clear In / Out", + "editor.zoomIn": "Zoom In", + "editor.zoomOut": "Zoom Out", + "editor.fitTimeline": "Fit Timeline to View", + "editor.toggleSnap": "Toggle Snap", + "editor.prevEdit": "Go to Previous Edit Point", + "editor.prevEditDesc": "Jump playhead to previous cut on timeline", + "editor.nextEdit": "Go to Next Edit Point", + "editor.nextEditDesc": "Jump playhead to next cut on timeline", + "editor.fullscreen": "Fullscreen Preview", + + "timeline.addClip": "Add Clip", + "timeline.export": "Export", + "timeline.layout": "Layout", + "timeline.icLora": "IC-LoRA", + "timeline.importSrt": "Import SRT", + "timeline.exportSrt": "Export SRT", + "timeline.zoomHint": "Zoom out (-), Zoom in (+), Fit to view (Ctrl+0)", + + "upgrade.title": "Optional Upgrade", + "upgrade.detected": "LTX Model Upgrade Detected!", + "upgrade.wantIt": "I want this!", + "upgrade.connectHf": "Connect Hugging Face", + "upgrade.acceptAccess": "Accept model access", + "upgrade.ready": "Ready when you are", + "upgrade.upgradeNow": "Upgrade now", + + "apiGateway.required": "Required", + "apiGateway.configured": "Configured", + "apiGateway.notSet": "Not set", + "apiGateway.saving": "Saving...", + "apiGateway.getKey": "Get API key", + "apiGateway.missing": "Required API keys are missing. Add them to continue.", + + "genError.title": "Generation Failed", + "genError.techDetails": "Technical Details", + "genError.tryAgain": "Try Again", + "genError.buyCredits": "Buy Credits", + + "logs.logs": "Logs", + "logs.noLogs": "No logs yet...", + "logs.lastLines": "(last 200)", + "logs.autoRefresh": "Auto-refreshing every 2s", + + "models.model": "Model", + "models.duration": "Duration", + "models.resolution": "Resolution", + "models.fps": "FPS", + "models.aspectRatio": "Aspect Ratio", + "models.audio": "Audio", + "models.cameraMotion": "Camera Motion", + "models.quality": "Quality", + "models.loadingSettings": "Loading generation settings...", + "models.none": "None", + "models.static": "Static", + "models.focusShift": "Focus Shift", + "models.dollyIn": "Dolly In", + "models.dollyOut": "Dolly Out", + "models.dollyLeft": "Dolly Left", + "models.dollyRight": "Dolly Right", + "models.jibUp": "Jib Up", + "models.jibDown": "Jib Down", + + "menu.file": "File", + "menu.edit": "Edit", + "menu.clip": "Clip", + "menu.sequence": "Sequence", + "menu.tools": "Tools", + "menu.view": "View", + "menu.help": "Help", + "menu.newTimeline": "New Timeline", + "menu.duplicateTimeline": "Duplicate Active Timeline", + "menu.importMedia": "Import Media...", + "menu.importTimeline": "Import Timeline (XML)...", + "menu.importSubtitles": "Import Subtitles (SRT)...", + "menu.exportTimeline": "Export Timeline...", + "menu.exportFcp7Xml": "Export FCP7 XML...", + "menu.exportSubtitles": "Export Subtitles (SRT)...", + "menu.splitAtPlayhead": "Split at Playhead", + "menu.duplicateClip": "Duplicate Clip", + "menu.flipHorizontal": "Flip Horizontal", + "menu.flipVertical": "Flip Vertical", + "menu.reverse": "Reverse", + "menu.muteClip": "Mute Clip", + "menu.unmuteClip": "Unmute Clip", + "menu.linkAudio": "Link Audio", + "menu.unlinkAudio": "Unlink Audio", + "menu.speed025": "Speed: 0.25x", + "menu.speed050": "Speed: 0.5x", + "menu.speed100": "Speed: 1x (Normal)", + "menu.speed150": "Speed: 1.5x", + "menu.speed200": "Speed: 2x", + "menu.speed400": "Speed: 4x", + "menu.addVideoTrack": "Add Video Track", + "menu.addAudioTrack": "Add Audio Track", + "menu.addSubtitleTrack": "Add Subtitle Track", + "menu.addAdjustmentLayer": "Add Adjustment Layer", + "menu.addTextOverlay": "Add Text Overlay", + "menu.addLowerThird": "Add Lower Third", + "menu.addCaption": "Add Caption", + "menu.enableSnapping": "Enable Snapping", + "menu.disableSnapping": "Disable Snapping", + "menu.selectionTool": "Selection Tool", + "menu.bladeTool": "Blade Tool", + "menu.rippleTrim": "Ripple Trim", + "menu.rollTrim": "Roll Trim", + "menu.slipTool": "Slip Tool", + "menu.slideTool": "Slide Tool", + "menu.icLoraStyle": "IC-LoRA Style Transfer...", + "menu.hideClipViewer": "Hide Clip Viewer", + "menu.showClipViewer": "Show Clip Viewer", + "menu.hideProperties": "Hide Properties Panel", + "menu.showProperties": "Show Properties Panel", + "menu.zoomToFit": "Zoom to Fit", + "menu.resetLayout": "Reset Layout", + "menu.aboutLtx": "About LTX Desktop", + + "toolbar.addClip": "Add Clip", + "toolbar.export": "Export", + "toolbar.layout": "Layout", + "toolbar.resetLayout": "Reset panel sizes to default", + "toolbar.icLora": "IC-LoRA", + "toolbar.icLoraHint": "Open IC-LoRA style transfer panel", + "toolbar.importSrt": "Import SRT", + "toolbar.importSrtHint": "Import SRT subtitles", + "toolbar.exportSrt": "Export SRT", + "toolbar.exportSrtHint": "Export SRT subtitles", + "toolbar.zoomOut": "Zoom out (-)", + "toolbar.zoomIn": "Zoom in (+)", + "toolbar.fitToView": "Fit to view (Ctrl+0)", + "toolbar.zoom": "Zoom: {{pct}}%", + + "tools.snappingOn": "Snapping On", + "tools.snappingOff": "Snapping Off", + "tools.addText": "Add Text Overlay", + "tools.trackSelectHint": "Track Select Forward (Shift: single track)", + "tools.trimFlyout": " — right-click or hold for more", + + "editor.rippleTrim": "Ripple Trim", + "editor.rollTrim": "Roll Trim (A/B)", + "editor.slipTool": "Slip Tool", + "editor.slideTool": "Slide Tool", + + "ctx.duplicate": "Duplicate", + "ctx.paste": "Paste", + "ctx.addText": "Add Text", + "ctx.reverse": "Reverse", + "ctx.flipHorizontal": "Flip Horizontal", + "ctx.flipVertical": "Flip Vertical", + "ctx.unmute": "Unmute", + "ctx.mute": "Mute", + "ctx.unlinkAudio": "Unlink Audio", + "ctx.linkAudio": "Link Audio", + "ctx.unlink": "Unlink", + "ctx.link": "Link", + "ctx.regenerateShot": "Regenerate Shot", + "ctx.cancelRegeneration": "Cancel Regeneration", + "ctx.upscale": "Upscale (2x)", + "ctx.comingSoon": "Coming Soon!", + "ctx.imageToVideo": "Image to Video (I2V)", + "ctx.retakeSection": "Retake Section", + "ctx.icLoraStyle": "IC-LoRA / Style Transfer", + "ctx.createVideoA2V": "Create Video (A2V)", + "ctx.generateInGenSpace": "Generate Video in Gen Space", + "ctx.revealInAssets": "Reveal in Assets", + "ctx.revealInFinder": "Reveal in Finder", + "ctx.revealInExplorer": "Reveal in Explorer", + "ctx.deleteTake": "Delete this take", + "ctx.noLabel": "No label", + "ctx.muteAll": "Mute All", + "ctx.unmuteAll": "Unmute All", + "ctx.reverseAll": "Reverse All", + "ctx.unreverseAll": "Un-reverse All", + "ctx.flipAllHorizontal": "Flip All Horizontal", + "ctx.unflipAllHorizontal": "Un-flip All Horizontal", + "ctx.flipAllVertical": "Flip All Vertical", + "ctx.unflipAllVertical": "Un-flip All Vertical", + + "assets.title": "Assets", + "assets.createBin": "Create bin", + "assets.importMedia": "Import media", + "assets.all": "All", + "assets.video": "Video", + "assets.image": "Image", + "assets.audio": "Audio", + "assets.gridView": "Grid view", + "assets.listView": "List view", + "assets.binName": "Bin name...", + "assets.takes": "Takes", + "assets.backToAssets": "Back to assets", + "assets.asset": "Asset", + "assets.takesCount": "{{n}} takes", + "assets.newTake": "New Take", + "assets.takeLabel": "Take {{n}}", + "assets.active": "Active", + "assets.deleteTake": "Delete take", + "assets.deleteTakeConfirm": "Delete take {{n}}?", + "assets.cancel": "Cancel", + "assets.noAssets": "No assets yet", + "assets.noAssetsHint": "Generate in Gen Space or import", + "assets.importMediaBtn": "Import Media", + "assets.adjustmentLayer": "Adjustment Layer", + "assets.regenerate": "Regenerate", + "assets.deleteAsset": "Delete asset", + "assets.previousTake": "Previous take", + "assets.nextTake": "Next take", + "assets.viewAllTakes": "View all takes", + "assets.renameBin": "Rename Bin", + "assets.deleteBin": "Delete Bin", + "assets.listName": "Name", + "assets.listType": "Type", + "assets.listDuration": "Duration", + "assets.listRes": "Res", + "assets.listDate": "Date", + "assets.listColor": "Color", + + "timeline.title": "Timelines", + "timeline.addTimeline": "Add timeline", + "timeline.newTimeline": "New Timeline", + "timeline.importXml": "Import from XML", + "timeline.active": "Active", + "timeline.openInTabs": "Open in tabs", + "timeline.deleteTimeline": "Delete timeline", + "timeline.closeTab": "Close tab", + "timeline.clips": "{{n}} clip(s)", + "timeline.tab": "Tab", + "timeline.rename": "Rename", + "timeline.duplicate": "Duplicate", + "timeline.upscaleTimeline": "Upscale Timeline", + "timeline.importXmlTimeline": "Import XML Timeline", + "timeline.exportTimelineDots": "Export Timeline...", + "timeline.exportFcp7Xml": "Export as FCP 7 XML", + "timeline.export": "Export", + "timeline.closeTimeline": "Close Timeline", + "timeline.deleteTimelineMenu": "Delete Timeline", + "timeline.dropClipsHere": "Drop clips here", + "timeline.clickOrDrag": "Click assets or drag them to the timeline", + "timeline.defaultNamePrefix": "Timeline", + "layout.saveCurrent": "Save Current Layout...", + "layout.resetDefault": "Reset to Default", + "layout.layout": "Layout", + + "logs.download": "Download logs", + "logs.openFolder": "Open log folder", + "logs.refresh": "Refresh logs", + "logs.autoScrollOn": "Auto-scroll enabled", + "logs.autoScrollOff": "Auto-scroll disabled", + + "genspace.mode": "MODE", + "genspace.conditioningType": "CONDITIONING TYPE", + "genspace.strength": "STRENGTH", + "genspace.imageResolution": "IMAGE RESOLUTION", + "genspace.ratio": "RATIO", + "genspace.model": "MODEL", + "genspace.duration": "DURATION", + "genspace.resolution": "RESOLUTION", + "genspace.fpsLabel": "FPS", + "genspace.aspectRatio": "ASPECT RATIO", + "genspace.str": "STR", + "genspace.sec": "{{n}} Sec", + "genspace.zit": "Z-Image Turbo", + "genspace.cannyEdges": "Canny Edges", + "genspace.attachAudio": "Attach audio for A2V", + "genspace.audioAttached": "Audio attached — click to change", + "genspace.trimHint": "Trim in the panel above, then retake", + "genspace.generate": "Generate", + "genspace.retakeBtn": "Retake", + "genspace.generating": "Generating...", + "genspace.imageLabel": "Image", + "genspace.emptyHint": "Use the prompt bar below to generate images and videos. Drag assets into the input box to use them as references.", + "genspace.favHint": "Click the heart icon on any asset to add it to your favorites.", + "genspace.placeholderRetake": "Describe what should happen in the selected section...", + "genspace.placeholderLora": "Describe the style or transformation to apply...", + "genspace.placeholderT2V": "A close-up of a woman talking on the phone...", + "genspace.placeholderI2V": "The woman sips from a cup of coffee...", + + "settings.fast": "Fast", + "settings.balanced": "Balanced", + "settings.high": "High", + "settings.sec": "{{n}} sec", + + "clip.adjustmentLayer": "Adjustment Layer", + "clip.text": "Text", + "clip.clip": "Clip", + "clip.rev": "REV", + "clip.mutedBadge": "M", + "clip.flipBadge": "FLIP", + "clip.ccBadge": "CC", + "clip.lbBadge": "LB", + "clip.remove": "Remove", + "clip.dissolve": "Dissolve", + "clip.regenerating": "Regenerating...", + "clip.vaDivider": "V | A", + + "monitor.setIn": "Set In point", + "monitor.goToIn": "Go to In Point", + "monitor.stepBack": "Step Back", + "monitor.play": "Play", + "monitor.pause": "Pause", + "monitor.stepForward": "Step Forward", + "monitor.goToOut": "Go to Out Point", + "monitor.setOut": "Set Out point", + "monitor.loopInOut": "Loop In/Out", + "monitor.fullscreen": "Fullscreen", + "monitor.exitFullscreen": "Exit fullscreen", + "monitor.playbackRes": "Playback resolution", + "monitor.noClip": "No clip at playhead", + "monitor.noClipHint": "Move playhead over a clip to preview", + "monitor.fit": "Fit", + "monitor.fullRes": "Full (1:1)", + "monitor.fullResDesc": "Highest quality", + "monitor.halfRes": "Half (1/2)", + "monitor.halfResDesc": "Balanced", + "monitor.quarterRes": "Quarter (1/4)", + "monitor.quarterResDesc": "Smoothest", + + "menu.speed": "Speed", + "menu.on": "ON", + "menu.muted": "MUTED", + "menu.allOn": "ALL ON", + "menu.allMuted": "ALL MUTED", + "menu.label": "Label", + "menu.aiTools": "AI Tools", + "menu.take": "Take:", + "menu.useFrameAs": "Use Frame As...", + "menu.showInFiles": "Show in Files", + "menu.nClipsSelected": "{{n}} Clips Selected", + "menu.gapTooLong": "This gap is too long to fill with a single video generation.", + + "track.addVideo": "Add video track", + "track.addAudio": "Add audio track", + "track.addSubtitle": "Add subtitle track", + "track.createAdjustment": "Create adjustment layer asset", + "track.styleSettings": "Track style settings", + "track.addSubtitleBtn": "Add subtitle", + "track.lock": "Lock", + "track.unlock": "Unlock", + "track.showSubtitles": "Show subtitles", + "track.hideSubtitles": "Hide subtitles", + "track.deleteTrack": "Delete track", + "track.sourcePatched": "Source patched (click to unpatch)", + "track.sourceUnpatched": "Source unpatched (click to patch)", + "track.enableOutput": "Enable track output", + "track.disableOutput": "Disable track output", + "track.mute": "Mute", + "track.unmute": "Unmute", + "track.muteTrack": "Mute track", + "track.unmuteTrack": "Unmute track", + "track.solo": "Solo track", + "track.unsolo": "Unsolo track", + "track.hideProperties": "Hide Properties Panel", + "track.showProperties": "Show Properties Panel", + "track.icLoraTransfer": "IC-LoRA Style Transfer", + "track.cancelGeneration": "Cancel generation", + "track.effectsBrowser": "Effects Browser", + + "text.applyPreset": "Apply Preset", + "text.speed": "Speed", + "text.track": "Track", + "text.trackN": "Track {{n}}", + "text.preset.Centered Title": "Centered Title", + "text.preset.Big & Bold": "Big & Bold", + "text.preset.Subtitle": "Subtitle", + "text.preset.Lower Third": "Lower Third", + "text.preset.Accent Lower Third": "Accent Lower Third", + "text.preset.End Card": "End Card", + "text.preset.Corner Tag": "Corner Tag" +, "assets.addToTimeline": "Add to Timeline", + "assets.showInExplorer": "Show in Explorer", + "assets.deleteAssets": "Delete {{n}} Assets", + "assets.label": "Label", + "assets.moveToBin": "Move to Bin", + "assets.removeFromBin": "Remove from Bin", + "assets.newBin": "New Bin...", + "keyboard.ltxDefault": "LTX Default", + "keyboard.ltxDefaultDesc": "Default keyboard layout for LTX Desktop", + "keyboard.premiere": "Adobe Premiere Pro", + "keyboard.premiereDesc": "Keyboard layout matching Premiere Pro defaults", + "keyboard.davinci": "DaVinci Resolve", + "keyboard.davinciDesc": "Keyboard layout matching DaVinci Resolve defaults", + "keyboard.avid": "Avid Media Composer", + "keyboard.avidDesc": "Keyboard layout matching Avid Media Composer defaults", + "keyboard.footer": "{{count}} actions · Drag actions onto keys or click \"Edit\" to record", + "keyboard.conflictsDetected": "{{count}} shortcut conflict(s) detected — some keys are assigned to multiple actions", + "keyboard.saveAsPrompt": "Save as custom preset:", + "keyboard.deletePresetConfirm": "Delete preset \"{{name}}\"?", + "keyboard.deletePresetTooltip": "Delete \"{{name}}\"", + "keyboard.resetTooltip": "Reset all shortcuts to the selected preset", + "keyboard.saveAsTooltip": "Save current layout as a custom preset", + "keyboard.unassignedHint": "Unassigned — drag an action here", + "keyboard.dropHint": "Drop on a key to assign — current modifiers: {{modifiers}}", + "keyboard.presetNamePlaceholder": "Preset name..." +} diff --git a/frontend/lib/locales/zh.json b/frontend/lib/locales/zh.json new file mode 100644 index 000000000..c76dbb0bc --- /dev/null +++ b/frontend/lib/locales/zh.json @@ -0,0 +1,693 @@ +{ + "_comment": "LTX Desktop — 中文翻译", + + "app.title": "LTX Desktop", + "app.loading": "正在启动 LTX Desktop...", + "app.loadingSub": "正在初始化推理引擎", + "app.reconnecting": "正在重新连接...", + "app.reconnectingDesc": "后端进程意外停止,正在尝试重新启动...", + "app.crashed": "后端进程崩溃,无法自动恢复", + "app.crashedDesc": "请查看下方日志并重新启动应用。", + "app.restart": "重新启动", + "app.loadingSettings": "正在加载设置...", + "app.finalizingSetup": "正在完成安装...", + "app.setupFailed": "安装完成失败", + "app.retry": "重试", + "app.viewLogs": "查看后端日志", + "app.settings": "设置", + "app.connectApiKeys": "连接 API 密钥", + "app.connectApiKeysDesc": "请添加所需的 API 密钥以继续。", + "app.apiOnlyNotice": "此应用仅支持 API 生成模式。请添加 API 密钥以继续。", + + "home.home": "首页", + "home.recentProjects": "最近项目", + "home.projects": "项目", + "home.newProject": "新建项目", + "home.createProject": "创建项目", + "home.createNewProject": "新建项目", + "home.renameProject": "重命名项目", + "home.projectName": "项目名称", + "home.noProjects": "暂无项目", + "home.noProjectsDesc": "创建你的第一个项目开始创作", + "home.createAndManage": "创建和管理你的视频项目", + "home.rename": "重命名", + "home.delete": "删除", + "home.cancel": "取消", + "home.save": "保存", + "home.create": "创建", + "home.migrating": "正在迁移项目引用...", + "home.deleteConfirm": "确定要删除 \"{{name}}\" 吗?", + + "project.notFound": "未找到项目", + "project.goHome": "返回首页", + "project.preparing": "正在准备项目资源...", + "project.genSpace": "创意空间", + "project.videoEditor": "视频编辑器", + + "genspace.generateImages": "生成图片", + "genspace.generateVideos": "生成视频", + "genspace.retake": "重新生成", + "genspace.icLora": "IC-LoRA", + "genspace.favorites": "收藏", + "genspace.noFavorites": "暂无收藏", + "genspace.startCreating": "开始创作", + "genspace.small": "小", + "genspace.medium": "中", + "genspace.large": "大", + "genspace.copyPrompt": "复制提示词", + "genspace.mute": "静音", + "genspace.unmute": "取消静音", + "genspace.createVideo": "生成视频", + "genspace.download": "下载", + + "settings.general": "通用", + "settings.apiKeys": "API 密钥", + "settings.promptEnhancer": "提示词增强", + "settings.about": "关于", + + "settings.projectAssetsPath": "项目资源路径", + "settings.projectAssetsDesc": "生成的视频和图片资源保存位置。每个项目会创建独立子文件夹。", + "settings.notSet": "未设置", + "settings.videosGeneration": "视频生成", + "settings.generateWithApi": "使用 API 生成", + "settings.generateWithApiDesc": "配置 LTX API 密钥后,使用 LTX API 进行视频生成。", + "settings.apiKeyRequiredTab": "需要 API 密钥 — 请在 API 密钥标签页中配置。", + + "settings.textEncoding": "文本编码", + "settings.textEncodingDesc": "文本编码将你的提示词转换为 AI 可理解的数据。请选择编码方式。", + "settings.ltxApi": "LTX API", + "settings.recommended": "推荐", + "settings.ltxApiDesc": "云端快速文本编码(约 1 秒)。需在 API 密钥标签页配置 LTX API 密钥。", + "settings.promptCache": "提示词缓存", + "settings.promptCacheDesc": "跳过重复的编码调用", + "settings.localEncoder": "本地编码器", + "settings.localEncoderDesc": "在本地计算机运行(约 23 秒)。需下载 25 GB。", + "settings.downloaded": "已下载", + "settings.notDownloaded": "未下载", + "settings.gbRequired": "需要 {{gb}} GB", + "settings.gbSize": "{{gb}} GB", + "settings.downloadingTextEncoder": "正在下载文本编码器...", + "settings.downloadTextEncoder": "下载文本编码器", + "settings.requestAccess": "申请访问", + + "settings.torchCompile": "Torch 编译", + "settings.torchCompileDesc": "编译模型以优化推理速度。实验性功能:首次生成可能需要 5-10 分钟编译时间。后续生成速度可提升 20-40%。需重启应用生效。", + "settings.optimizedInference": "优化推理(推荐)", + "settings.standardInference": "标准推理", + "settings.experimental": "实验性", + + "settings.lockSeed": "锁定种子", + "settings.lockSeedDesc": "使用固定种子以实现可复现的生成结果。解锁后每次将使用随机种子。", + "settings.seedLocked": "种子已锁定:{{seed}}", + "settings.randomSeed": "每次生成使用随机种子", + "settings.enterSeed": "输入种子值...", + "settings.randomizeSeed": "随机生成种子", + + "settings.anonymousAnalytics": "匿名数据分析", + "settings.anonymousAnalyticsDesc": "分享匿名使用数据以帮助改进 LTX Desktop。仅收集基本技术信息——绝不涉及个人数据或生成内容。", + + "settings.ltxApiSection": "LTX API", + "settings.ltxApiSectionDesc": "LTX API 密钥用于云端文本编码、提示词增强和 API 视频生成。添加密钥以解锁这些功能。", + "settings.saveKey": "保存密钥", + "settings.keyConfigured": "密钥已配置", + "settings.apiKeyRequired": "需要 API 密钥", + "settings.enterNewKey": "输入新密钥以替换...", + "settings.enterLtxApiKey": "输入 LTX API 密钥...", + "settings.getLtxApiKey": "获取 LTX API 密钥", + + "settings.falAiSection": "FAL AI", + "settings.falAiSectionDesc": "FAL AI 密钥用于在启用 API 生成时使用 Z Image Turbo 生成图片。", + "settings.enterFalApiKey": "输入 FAL AI API 密钥...", + "settings.getFalApiKey": "获取 FAL API 密钥", + + "settings.geminiApiSection": "Gemini API", + "settings.geminiApiSectionDesc": "Gemini API 密钥用于在填充时间线间隙时提供 AI 驱动的提示词建议。", + "settings.enterGeminiApiKey": "输入 Gemini API 密钥...", + "settings.getGeminiApiKey": "获取 Gemini API 密钥 →", + + "settings.huggingFace": "HuggingFace", + "settings.huggingFaceDesc": "登录 HuggingFace 以下载模型文件。", + "settings.signedIn": "已登录", + "settings.notSignedIn": "未登录", + "settings.signOut": "退出登录", + "settings.signInHf": "使用 HuggingFace 登录", + "settings.waitingSignIn": "等待登录...", + "settings.optional": "可选", + + "settings.promptEnhancerDesc": "通过 LTX API 自动增强提示词,添加丰富的视觉细节、声音描述和运动提示,帮助生成更高质量的视频。可分别控制每种生成类型。", + "settings.ltxApiKeyRequired": "需要 LTX API 密钥", + "settings.ltxApiKeyRequiredDesc": "提示词增强功能在 LTX API 服务器端运行。要使用此功能,需在 API 密钥标签页中配置密钥。", + "settings.setApiKey": "设置 API 密钥", + "settings.t2v": "文生视频", + "settings.i2v": "图生视频", + "settings.t2vEnhanced": "提示词将在文生视频生成前增强", + "settings.t2vAsIs": "文生视频提示词保持原样", + "settings.i2vEnhanced": "提示词将在图生视频生成前增强", + "settings.i2vAsIs": "图生视频提示词保持原样", + "settings.done": "完成", + + "settings.aboutApp": "LTX Desktop", + "settings.aboutTagline": "AI 驱动视频编辑器", + "settings.license": "许可证", + "settings.apacheLicense": "基于 Apache License, Version 2.0 许可", + "settings.modelLicense": "LTX-2 模型许可证", + "settings.modelLicenseDesc": "LTX-2 模型受 LTX-2 社区许可协议约束,已在首次安装时接受。", + "settings.viewModelLicense": "查看模型许可证", + "settings.thirdPartyNotices": "第三方声明", + "settings.thirdPartyNoticesDesc": "本应用使用开源软件和 AI 模型,各受其自身许可条款约束。", + "settings.viewThirdPartyNotices": "查看第三方声明", + "settings.loading": "加载中...", + "settings.back": "返回", + "settings.copyright": "Copyright © 2026 Lightricks", + + "firstRun.modelLicense": "LTX-2 模型许可证", + "firstRun.licenseDesc": "LTX-2 模型受以下许可协议约束。请在下载前仔细阅读并接受。", + "firstRun.loadingLicense": "正在加载许可证...", + "firstRun.acceptLicense": "我已阅读并同意 LTX-2 社区许可协议", + "firstRun.retry": "重试", + + "firstRun.chooseLocation": "选择安装位置", + "firstRun.chooseLocationDesc": "选择模型文件的安装位置。", + "firstRun.browse": "浏览", + "firstRun.available": "可用空间", + "firstRun.apiKeyOptional": "可选 — 可节省约 25 GB 下载", + "firstRun.ltxApiKey": "LTX API 密钥", + "firstRun.apiKeyPlaceholder": "输入 API 密钥以跳过文本编码器下载...", + "firstRun.apiKeySkipNotice": "✓ 将跳过文本编码器下载(改用 API)", + "firstRun.apiKeyTip": "如果你有 LTX API 密钥,在此输入可跳过 25 GB 的文本编码器下载。API 提供更快的文本编码(约 1 秒 vs 本地 23 秒)。", + + "firstRun.hfAccount": "HuggingFace 账号", + "firstRun.required": "必需", + "firstRun.authenticatedReady": "✓ 已认证 — 可以下载模型。", + "firstRun.signInToDownload": "登录 HuggingFace 以下载模型文件。", + "firstRun.modelAccess": "模型访问权限", + "firstRun.actionRequired": "需要操作", + "firstRun.modelAccessDesc": "部分模型需要先在 HuggingFace 上接受其许可协议才能下载。", + + "firstRun.downloading": "正在下载...", + "firstRun.installing": "正在安装...", + "firstRun.fileOf": "第 {{current}} / {{total}} 个文件", + "firstRun.eta": "预计剩余时间", + "firstRun.readyToCreate": "准备就绪", + "firstRun.readyDesc": "LTX Video 已安装完成。开始生成吧。", + "firstRun.location": "安装位置", + "firstRun.accept": "接受", + "firstRun.next": "下一步", + "firstRun.install": "安装", + "firstRun.finish": "完成", + "firstRun.continue": "继续", + "firstRun.back": "返回", + "firstRun.failedAccept": "接受许可协议失败。", + "firstRun.failedSetup": "完成安装失败。", + "firstRun.failedStart": "启动模型下载失败。", + "firstRun.downloadFailed": "下载失败。", + "firstRun.copyright": "© 2026 Lightricks", + "firstRun.generatedBy": "由 PongFlongo 生成", + "firstRun.installMessages.0": "正在下载模型权重...", + "firstRun.installMessages.1": "正在教会 AI 做 4K 的梦...", + "firstRun.installMessages.2": "正在加载神经通路...", + "firstRun.installMessages.3": "正在校准推理引擎...", + "firstRun.installMessages.4": "快了...", + "firstRun.installMessages.5": "正在解压魔法...", + "firstRun.installMessages.6": "正在配置参数...", + "firstRun.installMessages.7": "正在完成安装...", + + "pythonSetup.downloading": "正在下载 Python 运行环境...", + "pythonSetup.extracting": "正在解压...", + "pythonSetup.settingUp": "正在配置 Python 运行环境...", + "pythonSetup.firstTime": "首次设置 — 仅此一次", + + "export.export": "导出", + "export.package": "打包 (FCPXML)", + "export.packageDesc": "适用于 Premiere Pro 和 DaVinci Resolve", + "export.videoExport": "视频导出", + "export.format": "格式", + "export.resolution": "分辨率", + "export.frameRate": "帧率", + "export.quality": "质量", + "export.options": "选项", + "export.mostCompatible": "兼容性最好的格式", + "export.professional": "专业剪辑格式", + "export.webOptimized": "网页优化格式", + "export.exportComplete": "导出完成", + "export.exportFailed": "导出失败", + "export.showInFolder": "在文件夹中显示", + "export.exportAnother": "继续导出", + "export.tryAgain": "重试", + "export.rendering": "正在渲染视频...", + "export.generatingFcpxml": "正在生成 FCPXML...", + "export.burnSubtitles": "烧录字幕", + "export.addClips": "请将素材添加到时间线以导出。", + "export.exportVideo": "导出视频", + + "keyboard.title": "键盘快捷键", + "keyboard.customize": "自定义快捷键 — 将操作拖放到按键上或点击编辑", + "keyboard.hideKeyboard": "隐藏键盘", + "keyboard.showKeyboard": "显示键盘", + "keyboard.searchActions": "搜索操作或按键...", + "keyboard.reset": "重置", + "keyboard.saveAs": "另存为", + "keyboard.savePreset": "保存预设", + "keyboard.modifiers": "修饰键:", + "keyboard.pressKey": "按下按键...", + "keyboard.unassigned": "未分配", + "keyboard.edit": "编辑", + "keyboard.clear": "清除", + "keyboard.done": "完成", + "keyboard.conflict": "冲突", + "keyboard.all": "全部", + "keyboard.tools": "工具", + "keyboard.transport": "播放控制", + "keyboard.editing": "编辑", + "keyboard.marking": "标记", + "keyboard.timeline": "时间线", + + "editor.selectionTool": "选择工具", + "editor.bladeTool": "剃刀工具", + "editor.rippleTool": "波纹编辑工具", + "editor.rollTool": "滚动编辑工具", + "editor.slideTool": "滑动工具", + "editor.slipTool": "滑移工具", + "editor.trackSelect": "向前轨道选择", + "editor.playPause": "播放 / 暂停", + "editor.stop": "停止", + "editor.shuttleReverse": "反向穿梭 (J)", + "editor.shuttleStop": "穿梭停止 (K)", + "editor.shuttleForward": "正向穿梭 (L)", + "editor.stepBackward": "后退一帧", + "editor.stepForward": "前进一帧", + "editor.jumpBackward": "后退一秒", + "editor.jumpForward": "前进一秒", + "editor.goToStart": "转到开头", + "editor.goToEnd": "转到结尾", + "editor.goToIn": "转到入点", + "editor.goToOut": "转到出点", + "editor.undo": "撤销", + "editor.redo": "重做", + "editor.cut": "剪切", + "editor.copy": "复制", + "editor.paste": "粘贴", + "editor.delete": "删除", + "editor.selectAll": "全选", + "editor.deselectAll": "取消全选", + "editor.insertEdit": "插入编辑", + "editor.overwriteEdit": "覆盖编辑", + "editor.matchFrame": "匹配帧", + "editor.matchFrameDesc": "将播放头下的素材加载到源监视器的匹配帧", + "editor.setIn": "标记入点", + "editor.setOut": "标记出点", + "editor.clearIn": "清除入点", + "editor.clearOut": "清除出点", + "editor.clearInOut": "清除入/出点", + "editor.zoomIn": "放大", + "editor.zoomOut": "缩小", + "editor.fitTimeline": "时间线适配视图", + "editor.toggleSnap": "切换吸附", + "editor.prevEdit": "转到上一个编辑点", + "editor.prevEditDesc": "将播放头跳转到时间线上一刀", + "editor.nextEdit": "转到下一个编辑点", + "editor.nextEditDesc": "将播放头跳转到时间线下一刀", + "editor.fullscreen": "全屏预览", + + "timeline.addClip": "添加素材", + "timeline.export": "导出", + "timeline.layout": "布局", + "timeline.icLora": "IC-LoRA", + "timeline.importSrt": "导入字幕", + "timeline.exportSrt": "导出字幕", + "timeline.zoomHint": "缩小 (-)、放大 (+)、适配视图 (Ctrl+0)", + + "upgrade.title": "可选升级", + "upgrade.detected": "检测到 LTX 模型升级!", + "upgrade.wantIt": "立即升级!", + "upgrade.connectHf": "连接 Hugging Face", + "upgrade.acceptAccess": "接受模型访问许可", + "upgrade.ready": "随时可以开始", + "upgrade.upgradeNow": "立即升级", + + "apiGateway.required": "必需", + "apiGateway.configured": "已配置", + "apiGateway.notSet": "未设置", + "apiGateway.saving": "保存中...", + "apiGateway.getKey": "获取 API 密钥", + "apiGateway.missing": "缺少必需的 API 密钥。请添加后继续。", + + "genError.title": "生成失败", + "genError.techDetails": "技术详情", + "genError.tryAgain": "重试", + "genError.buyCredits": "购买积分", + + "logs.logs": "日志", + "logs.noLogs": "暂无日志...", + "logs.lastLines": "(最近 200 行)", + "logs.autoRefresh": "每 2 秒自动刷新", + + "models.model": "模型", + "models.duration": "时长", + "models.resolution": "分辨率", + "models.fps": "帧率", + "models.aspectRatio": "宽高比", + "models.audio": "音频", + "models.cameraMotion": "镜头运动", + "models.quality": "质量", + "models.loadingSettings": "正在加载生成设置...", + "models.none": "无", + "models.static": "静止镜头", + "models.focusShift": "焦点转移", + "models.dollyIn": "前推", + "models.dollyOut": "后拉", + "models.dollyLeft": "左移", + "models.dollyRight": "右移", + "models.jibUp": "上升", + "models.jibDown": "下降", + + "menu.file": "文件", + "menu.edit": "编辑", + "menu.clip": "片段", + "menu.sequence": "序列", + "menu.tools": "工具", + "menu.view": "视图", + "menu.help": "帮助", + "menu.newTimeline": "新建时间线", + "menu.duplicateTimeline": "复制当前时间线", + "menu.importMedia": "导入媒体...", + "menu.importTimeline": "导入时间线 (XML)...", + "menu.importSubtitles": "导入字幕 (SRT)...", + "menu.exportTimeline": "导出时间线...", + "menu.exportFcp7Xml": "导出 FCP7 XML...", + "menu.exportSubtitles": "导出字幕 (SRT)...", + "menu.splitAtPlayhead": "在播放头处切割", + "menu.duplicateClip": "复制片段", + "menu.flipHorizontal": "水平翻转", + "menu.flipVertical": "垂直翻转", + "menu.reverse": "倒放", + "menu.muteClip": "静音片段", + "menu.unmuteClip": "取消静音", + "menu.linkAudio": "链接音频", + "menu.unlinkAudio": "取消链接音频", + "menu.speed025": "速度:0.25x", + "menu.speed050": "速度:0.5x", + "menu.speed100": "速度:1x(正常)", + "menu.speed150": "速度:1.5x", + "menu.speed200": "速度:2x", + "menu.speed400": "速度:4x", + "menu.addVideoTrack": "添加视频轨道", + "menu.addAudioTrack": "添加音频轨道", + "menu.addSubtitleTrack": "添加字幕轨道", + "menu.addAdjustmentLayer": "添加调整图层", + "menu.addTextOverlay": "添加文本叠加", + "menu.addLowerThird": "添加底部字幕条", + "menu.addCaption": "添加说明文字", + "menu.enableSnapping": "启用吸附", + "menu.disableSnapping": "禁用吸附", + "menu.selectionTool": "选择工具", + "menu.bladeTool": "剃刀工具", + "menu.rippleTrim": "波纹修剪", + "menu.rollTrim": "滚动修剪", + "menu.slipTool": "滑移工具", + "menu.slideTool": "滑动工具", + "menu.icLoraStyle": "IC-LoRA 风格迁移...", + "menu.hideClipViewer": "隐藏素材查看器", + "menu.showClipViewer": "显示素材查看器", + "menu.hideProperties": "隐藏属性面板", + "menu.showProperties": "显示属性面板", + "menu.zoomToFit": "缩放以适配", + "menu.resetLayout": "重置布局", + "menu.aboutLtx": "关于 LTX Desktop", + + "toolbar.addClip": "添加素材", + "toolbar.export": "导出", + "toolbar.layout": "布局", + "toolbar.resetLayout": "重置面板大小为默认值", + "toolbar.icLora": "IC-LoRA", + "toolbar.icLoraHint": "打开 IC-LoRA 风格迁移面板", + "toolbar.importSrt": "导入字幕", + "toolbar.importSrtHint": "导入 SRT 字幕", + "toolbar.exportSrt": "导出字幕", + "toolbar.exportSrtHint": "导出 SRT 字幕", + "toolbar.zoomOut": "缩小 (-)", + "toolbar.zoomIn": "放大 (+)", + "toolbar.fitToView": "适配视图 (Ctrl+0)", + "toolbar.zoom": "缩放:{{pct}}%", + + "tools.snappingOn": "吸附已开启", + "tools.snappingOff": "吸附已关闭", + "tools.addText": "添加文本叠加", + "tools.trackSelectHint": "向前轨道选择 (Shift: 单轨道)", + "tools.trimFlyout": " — 右键或长按查看更多", + + "editor.rippleTrim": "波纹修剪", + "editor.rollTrim": "滚动修剪 (A/B)", + "editor.slipTool": "滑移工具", + "editor.slideTool": "滑动工具", + + "ctx.duplicate": "复制", + "ctx.paste": "粘贴", + "ctx.addText": "添加文本", + "ctx.reverse": "倒放", + "ctx.flipHorizontal": "水平翻转", + "ctx.flipVertical": "垂直翻转", + "ctx.unmute": "取消静音", + "ctx.mute": "静音", + "ctx.unlinkAudio": "取消链接音频", + "ctx.linkAudio": "链接音频", + "ctx.unlink": "取消链接", + "ctx.link": "链接", + "ctx.regenerateShot": "重新生成镜头", + "ctx.cancelRegeneration": "取消重新生成", + "ctx.upscale": "放大 (2x)", + "ctx.comingSoon": "即将推出!", + "ctx.imageToVideo": "图片转视频 (I2V)", + "ctx.retakeSection": "重新生成片段", + "ctx.icLoraStyle": "IC-LoRA / 风格迁移", + "ctx.createVideoA2V": "生成视频 (A2V)", + "ctx.generateInGenSpace": "在创意空间生成视频", + "ctx.revealInAssets": "在资源中显示", + "ctx.revealInFinder": "在 Finder 中显示", + "ctx.revealInExplorer": "在资源管理器中显示", + "ctx.deleteTake": "删除此镜头", + "ctx.noLabel": "无标签", + "ctx.muteAll": "全部静音", + "ctx.unmuteAll": "全部取消静音", + "ctx.reverseAll": "全部倒放", + "ctx.unreverseAll": "全部取消倒放", + "ctx.flipAllHorizontal": "全部水平翻转", + "ctx.unflipAllHorizontal": "全部取消水平翻转", + "ctx.flipAllVertical": "全部垂直翻转", + "ctx.unflipAllVertical": "全部取消垂直翻转", + + "assets.title": "素材", + "assets.createBin": "创建素材箱", + "assets.importMedia": "导入媒体", + "assets.all": "全部", + "assets.video": "视频", + "assets.image": "图片", + "assets.audio": "音频", + "assets.gridView": "网格视图", + "assets.listView": "列表视图", + "assets.binName": "素材箱名称...", + "assets.takes": "镜头", + "assets.backToAssets": "返回素材", + "assets.asset": "素材", + "assets.takesCount": "{{n}} 个镜头", + "assets.newTake": "新镜头", + "assets.takeLabel": "镜头 {{n}}", + "assets.active": "当前", + "assets.deleteTake": "删除镜头", + "assets.deleteTakeConfirm": "删除镜头 {{n}}?", + "assets.cancel": "取消", + "assets.noAssets": "暂无素材", + "assets.noAssetsHint": "在创意空间中生成或导入文件", + "assets.importMediaBtn": "导入媒体", + "assets.adjustmentLayer": "调整图层", + "assets.regenerate": "重新生成", + "assets.deleteAsset": "删除素材", + "assets.previousTake": "上一个镜头", + "assets.nextTake": "下一个镜头", + "assets.viewAllTakes": "查看全部镜头", + "assets.renameBin": "重命名素材箱", + "assets.deleteBin": "删除素材箱", + "assets.listName": "名称", + "assets.listType": "类型", + "assets.listDuration": "时长", + "assets.listRes": "分辨率", + "assets.listDate": "日期", + "assets.listColor": "颜色", + + "timeline.title": "时间线", + "timeline.addTimeline": "添加时间线", + "timeline.newTimeline": "新建时间线", + "timeline.importXml": "从 XML 导入", + "timeline.active": "当前", + "timeline.openInTabs": "在标签页中打开", + "timeline.deleteTimeline": "删除时间线", + "timeline.closeTab": "关闭标签页", + "timeline.clips": "{{n}} 个片段", + "timeline.tab": "标签页", + "timeline.rename": "重命名", + "timeline.duplicate": "复制", + "timeline.upscaleTimeline": "放大时间线", + "timeline.importXmlTimeline": "导入 XML 时间线", + "timeline.exportTimelineDots": "导出时间线...", + "timeline.exportFcp7Xml": "导出为 FCP 7 XML", + "timeline.export": "导出", + "timeline.closeTimeline": "关闭时间线", + "timeline.deleteTimelineMenu": "删除时间线", + "timeline.dropClipsHere": "将素材拖放到此处", + "timeline.clickOrDrag": "点击素材或将其拖到时间线", + "timeline.defaultNamePrefix": "时间线", + "layout.saveCurrent": "保存当前布局...", + "layout.resetDefault": "重置为默认", + "layout.layout": "布局", + + "logs.download": "下载日志", + "logs.openFolder": "打开日志文件夹", + "logs.refresh": "刷新日志", + "logs.autoScrollOn": "自动滚动已开启", + "logs.autoScrollOff": "自动滚动已关闭", + + "genspace.mode": "模式", + "genspace.conditioningType": "条件类型", + "genspace.strength": "强度", + "genspace.imageResolution": "图片分辨率", + "genspace.ratio": "比例", + "genspace.model": "模型", + "genspace.duration": "时长", + "genspace.resolution": "分辨率", + "genspace.fpsLabel": "帧率", + "genspace.aspectRatio": "宽高比", + "genspace.str": "强度", + "genspace.sec": "{{n}} 秒", + "genspace.zit": "Z-Image Turbo", + "genspace.cannyEdges": "Canny 边缘", + "genspace.attachAudio": "附加音频用于 A2V", + "genspace.audioAttached": "音频已附加 — 点击更换", + "genspace.trimHint": "在上方面板中修剪,然后重新生成", + "genspace.generate": "生成", + "genspace.retakeBtn": "重新生成", + "genspace.generating": "正在生成...", + "genspace.imageLabel": "图片", + "genspace.emptyHint": "使用下方提示栏生成图片和视频。将素材拖入输入框作为参考。", + "genspace.favHint": "点击任意素材上的心形图标将其加入收藏。", + "genspace.placeholderRetake": "描述所选片段中应发生的变化...", + "genspace.placeholderLora": "描述要应用的风格或变换...", + "genspace.placeholderT2V": "一个女人正在打电话的特写镜头...", + "genspace.placeholderI2V": "女人端起一杯咖啡抿了一口...", + + "settings.fast": "快速", + "settings.balanced": "均衡", + "settings.high": "高", + "settings.sec": "{{n}} 秒", + + "clip.adjustmentLayer": "调整图层", + "clip.text": "文本", + "clip.clip": "片段", + "clip.rev": "倒放", + "clip.mutedBadge": "静音", + "clip.flipBadge": "翻转", + "clip.ccBadge": "调色", + "clip.lbBadge": "遮幅", + "clip.remove": "移除", + "clip.dissolve": "叠化", + "clip.regenerating": "正在重新生成...", + "clip.vaDivider": "视频 | 音频", + + "monitor.setIn": "设置入点", + "monitor.goToIn": "转到入点", + "monitor.stepBack": "后退", + "monitor.play": "播放", + "monitor.pause": "暂停", + "monitor.stepForward": "前进", + "monitor.goToOut": "转到出点", + "monitor.setOut": "设置出点", + "monitor.loopInOut": "循环入出点", + "monitor.fullscreen": "全屏", + "monitor.exitFullscreen": "退出全屏", + "monitor.playbackRes": "播放分辨率", + "monitor.noClip": "播放头处无片段", + "monitor.noClipHint": "将播放头移到片段上方以预览", + "monitor.fit": "适配", + "monitor.fullRes": "完整 (1:1)", + "monitor.fullResDesc": "最高质量", + "monitor.halfRes": "一半 (1/2)", + "monitor.halfResDesc": "均衡", + "monitor.quarterRes": "四分之一 (1/4)", + "monitor.quarterResDesc": "最流畅", + + "menu.speed": "速度", + "menu.on": "开", + "menu.muted": "已静音", + "menu.allOn": "全部开", + "menu.allMuted": "全部静音", + "menu.label": "标签", + "menu.aiTools": "AI 工具", + "menu.take": "镜头:", + "menu.useFrameAs": "使用此帧作为...", + "menu.showInFiles": "在文件中显示", + "menu.nClipsSelected": "已选 {{n}} 个片段", + "menu.gapTooLong": "此间隙太长,无法用单次视频生成填充。", + + "track.addVideo": "添加视频轨道", + "track.addAudio": "添加音频轨道", + "track.addSubtitle": "添加字幕轨道", + "track.createAdjustment": "创建调整图层资源", + "track.styleSettings": "轨道样式设置", + "track.addSubtitleBtn": "添加字幕", + "track.lock": "锁定", + "track.unlock": "解锁", + "track.showSubtitles": "显示字幕", + "track.hideSubtitles": "隐藏字幕", + "track.deleteTrack": "删除轨道", + "track.sourcePatched": "源已补丁(点击取消补丁)", + "track.sourceUnpatched": "源未补丁(点击补丁)", + "track.enableOutput": "启用轨道输出", + "track.disableOutput": "禁用轨道输出", + "track.mute": "静音", + "track.unmute": "取消静音", + "track.muteTrack": "静音轨道", + "track.unmuteTrack": "取消静音轨道", + "track.solo": "独奏轨道", + "track.unsolo": "取消独奏轨道", + "track.hideProperties": "隐藏属性面板", + "track.showProperties": "显示属性面板", + "track.icLoraTransfer": "IC-LoRA 风格迁移", + "track.cancelGeneration": "取消生成", + "track.effectsBrowser": "特效浏览器", + + "text.applyPreset": "应用预设", + "text.speed": "速度", + "text.track": "轨道", + "text.trackN": "轨道 {{n}}", + "text.preset.Centered Title": "居中标题", + "text.preset.Big & Bold": "大号粗体", + "text.preset.Subtitle": "字幕", + "text.preset.Lower Third": "底部字幕条", + "text.preset.Accent Lower Third": "强调底部字幕条", + "text.preset.End Card": "片尾", + "text.preset.Corner Tag": "角标" +, "assets.addToTimeline": "添加到时间线", + "assets.showInExplorer": "在资源管理器中显示", + "assets.deleteAssets": "删除 {{n}} 个素材", + "assets.label": "标签", + "assets.moveToBin": "移动到素材箱", + "assets.removeFromBin": "从素材箱中移除", + "assets.newBin": "新建素材箱...", + "keyboard.ltxDefault": "LTX 默认", + "keyboard.ltxDefaultDesc": "LTX Desktop 默认键盘布局", + "keyboard.premiere": "Adobe Premiere Pro", + "keyboard.premiereDesc": "匹配 Premiere Pro 默认的键盘布局", + "keyboard.davinci": "DaVinci Resolve", + "keyboard.davinciDesc": "匹配 DaVinci Resolve 默认的键盘布局", + "keyboard.avid": "Avid Media Composer", + "keyboard.avidDesc": "匹配 Avid Media Composer 默认的键盘布局", + "keyboard.footer": "{{count}} 个动作 · 将动作拖拽到按键上或点击「编辑」以录制", + "keyboard.conflictsDetected": "检测到 {{count}} 个快捷键冲突 — 部分按键被分配了多个动作", + "keyboard.saveAsPrompt": "另存为自定义预设:", + "keyboard.deletePresetConfirm": "确定要删除预设「{{name}}」吗?", + "keyboard.deletePresetTooltip": "删除「{{name}}」", + "keyboard.resetTooltip": "将所有快捷键重置为所选预设", + "keyboard.saveAsTooltip": "将当前布局另存为自定义预设", + "keyboard.unassignedHint": "未分配 — 将动作拖拽到此处", + "keyboard.dropHint": "放到按键上以分配 — 当前修饰键:{{modifiers}}", + "keyboard.presetNamePlaceholder": "预设名称..." +} diff --git a/frontend/types/project-model.ts b/frontend/types/project-model.ts index 457d19c78..66afe14b6 100644 --- a/frontend/types/project-model.ts +++ b/frontend/types/project-model.ts @@ -385,7 +385,7 @@ export function createAssetBinId(): string { return `bin-${Date.now()}-${Math.random().toString(36).slice(2, 11)}` } -export function createDefaultTimeline(name: string = 'Timeline 1'): Timeline { +export function createDefaultTimeline(name: string): Timeline { return { id: `timeline-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`, name, diff --git a/frontend/types/project.ts b/frontend/types/project.ts index 3fc78ba44..2ffe36f06 100644 --- a/frontend/types/project.ts +++ b/frontend/types/project.ts @@ -121,3 +121,9 @@ export const TEXT_PRESETS: TextPreset[] = [ { id: 'end-card', name: 'End Card', category: 'end-cards', style: { text: 'Thank You', fontSize: 80, fontWeight: '300', positionX: 50, positionY: 45, textAlign: 'center', letterSpacing: 8, color: '#E4E4E7' } }, { id: 'corner-tag', name: 'Corner Tag', category: 'captions', style: { text: 'LIVE', fontSize: 20, fontWeight: '700', positionX: 92, positionY: 8, textAlign: 'right', color: '#FFFFFF', backgroundColor: 'rgba(239,68,68,0.9)', padding: 6, borderRadius: 4 } }, ] + +export function translatePresetName(name: string, t: (key: string) => string): string { + const key = `text.preset.${name}` + const translated = t(key) + return translated && translated !== key ? translated : name +} diff --git a/frontend/views/GenSpace.tsx b/frontend/views/GenSpace.tsx index 21f973956..8f2ff434b 100644 --- a/frontend/views/GenSpace.tsx +++ b/frontend/views/GenSpace.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useCallback } from 'react' +import { useT } from '../lib/i18n' import { Trash2, Download, Image, Video, X, Heart, Film, Volume2, VolumeX, Sparkles, @@ -56,6 +57,7 @@ function AssetCard({ const [isMuted, setIsMuted] = useState(true) const [volume, setVolume] = useState(0.5) const isFavorite = asset.favorite || false + const { t } = useT() useEffect(() => { if (asset.type !== 'video') return @@ -164,7 +166,7 @@ function AssetCard({ className="px-2.5 py-1.5 rounded-lg bg-black/40 backdrop-blur-md text-white hover:bg-black/60 transition-colors flex items-center gap-1.5 text-xs font-medium whitespace-nowrap" > - Create video + {t('genspace.createVideo')} )} @@ -175,7 +177,7 @@ function AssetCard({ className="px-2.5 py-1.5 rounded-lg bg-black/40 backdrop-blur-md text-white hover:bg-black/60 transition-colors flex items-center gap-1.5 text-xs font-medium whitespace-nowrap" > - Retake + {t('genspace.retake')} {onIcLora && ( )} @@ -212,7 +214,7 @@ function AssetCard({ @@ -258,13 +260,13 @@ function AssetCard({ } // Dropdown component for settings -function SettingsDropdown({ - trigger, - options, - value, +function SettingsDropdown({ + trigger, + options, + value, onChange, - title -}: { + title +}: { trigger: React.ReactNode options: { value: string; label: string; disabled?: boolean; tooltip?: string; icon?: React.ReactNode }[] value: string @@ -422,6 +424,7 @@ function PromptBar({ icLoraStrength?: number onIcLoraStrengthChange?: (strength: number) => void }) { + const { t } = useT() const inputRef = useRef(null) const audioInputRef = useRef(null) const [isDragOver, setIsDragOver] = useState(false) @@ -557,7 +560,7 @@ function PromptBar({ onDragLeave={() => setIsAudioDragOver(false)} onDrop={handleAudioDrop} onClick={() => audioInputRef.current?.click()} - title={inputAudio ? 'Audio attached — click to change' : 'Attach audio for A2V'} + title={inputAudio ? t('genspace.audioAttached') : t('genspace.attachAudio')} > {inputAudio ? ( <> @@ -589,12 +592,12 @@ function PromptBar({ onChange={(e) => onPromptChange(e.target.value)} onKeyDown={handleKeyDown} placeholder={mode === 'retake' - ? "Describe what should happen in the selected section..." + ? t('genspace.placeholderRetake') : mode === 'ic-lora' - ? "Describe the style or transformation to apply..." + ? t('genspace.placeholderLora') : mode === 'image' - ? "A close-up of a woman talking on the phone..." - : "The woman sips from a cup of coffee..." + ? t('genspace.placeholderT2V') + : t('genspace.placeholderI2V') } className="w-full bg-transparent text-white text-sm placeholder:text-zinc-500 focus:outline-none px-2 py-2 resize-none overflow-y-auto h-[70px] leading-5" /> @@ -606,19 +609,19 @@ function PromptBar({
{/* Mode dropdown */} onModeChange(v as 'image' | 'video' | 'retake' | 'ic-lora')} options={[ - { value: 'image', label: 'Generate Images', icon: }, - { value: 'video', label: 'Generate Videos', icon:
diff --git a/frontend/views/Home.tsx b/frontend/views/Home.tsx index 14df30a52..6e6f1fe81 100644 --- a/frontend/views/Home.tsx +++ b/frontend/views/Home.tsx @@ -7,10 +7,11 @@ import { Button } from '../components/ui/button' import { pathToFileUrl } from '../lib/file-url' import type { Project } from '../types/project-model' import { useProjectReferencesMigration } from '../hooks/useProjectReferencesMigration' +import { useT } from '../lib/i18n' function formatDate(timestamp: number): string { const date = new Date(timestamp) - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', @@ -25,6 +26,7 @@ function ProjectCard({ project, onOpen, onDelete, onRename }: { onDelete: () => void onRename: () => void }) { + const { t } = useT() const [showMenu, setShowMenu] = useState(false) const [imgError, setImgError] = useState(false) @@ -107,14 +109,14 @@ function ProjectCard({ project, onOpen, onDelete, onRename }: { className="w-full px-3 py-2 text-left text-sm text-zinc-300 hover:bg-zinc-700 flex items-center gap-2" > - Rename + {t('home.rename')} )} @@ -123,6 +125,7 @@ function ProjectCard({ project, onOpen, onDelete, onRename }: { } export function Home() { + const { t } = useT() const { projectIds, getProject, createProject, deleteProject, renameProject } = useProjects() const { openProject } = useView() const { migrationStatus, migrateProjects } = useProjectReferencesMigration() @@ -175,7 +178,7 @@ export function Home() {

- Migrating project references... + {t('home.migrating')}

- + {projects.length > 0 && (

- Recent Projects + {t('home.recentProjects')}

{projects.slice(0, 5).map(project => (
@@ -248,27 +251,27 @@ export function Home() {

LTX Desktop

-

Create and manage your video projects

+

{t('home.createAndManage')}

{/* Projects Grid */}
-

Projects

+

{t('home.projects')}

{projects.length === 0 ? (
-

No projects yet

-

Create your first project to get started

+

{t('home.noProjects')}

+

{t('home.noProjectsDesc')}

) : ( @@ -279,7 +282,8 @@ export function Home() { project={project} onOpen={() => openProject(project.id)} onDelete={() => { - if (confirm(`Delete "${project.name}"?`)) { + const msg = t('home.deleteConfirm').replace('{{name}}', project.name) + if (confirm(msg)) { deleteProject(project.id) } }} @@ -295,12 +299,12 @@ export function Home() { {isCreating && (
-

Create New Project

+

{t('home.createNewProject')}

setNewProjectName(e.target.value)} - placeholder="Project name" + placeholder={t('home.projectName')} className="w-full px-4 py-3 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder:text-zinc-500 focus:outline-none focus:border-blue-500" autoFocus onKeyDown={(e) => e.key === 'Enter' && handleCreateProject()} @@ -311,14 +315,14 @@ export function Home() { onClick={() => { setIsCreating(false); setNewProjectName('') }} className="flex-1 border-zinc-700" > - Cancel + {t('home.cancel')}
@@ -329,12 +333,12 @@ export function Home() { {renamingId && (
-

Rename Project

+

{t('home.renameProject')}

setRenameValue(e.target.value)} - placeholder="Project name" + placeholder={t('home.projectName')} className="w-full px-4 py-3 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder:text-zinc-500 focus:outline-none focus:border-blue-500" autoFocus onKeyDown={(e) => e.key === 'Enter' && submitRename()} @@ -345,14 +349,14 @@ export function Home() { onClick={() => { setRenamingId(null); setRenameValue('') }} className="flex-1 border-zinc-700" > - Cancel + {t('home.cancel')}
diff --git a/frontend/views/Project.tsx b/frontend/views/Project.tsx index 348e86378..f065eeeb8 100644 --- a/frontend/views/Project.tsx +++ b/frontend/views/Project.tsx @@ -11,6 +11,7 @@ import { hasVisualAssetMetadataForMigration, runVisualAssetMetadataMigration, } from '../lib/project-asset-metadata-migration' +import { useT } from '../lib/i18n' export function Project() { const { @@ -25,6 +26,7 @@ export function Project() { setPendingIcLoraUpdate, } = useProjects() const { goHome } = useView() + const { t } = useT() const [assetMetadataMigrationProgress, setAssetMetadataMigrationProgress] = useState({ running: false, total: 0, completed: 0 }) const [upgradePassProjectId, setUpgradePassProjectId] = useState(null) const activeProjectId = activeProject?.id ?? null @@ -84,16 +86,16 @@ export function Project() { return (
-

Project not found

- +

{t('project.notFound')}

+
) } const tabs: { id: ProjectTab; label: string; icon: React.ReactNode }[] = [ - { id: 'gen-space', label: 'Gen Space', icon: }, - { id: 'video-editor', label: 'Video Editor', icon: }, + { id: 'gen-space', label: t('project.genSpace'), icon: }, + { id: 'video-editor', label: t('project.videoEditor'), icon: }, ] const shouldShowAssetMetadataMigrationProgressScreen = assetMetadataMigrationProgress.running || (upgradePassProjectId !== activeProjectId && needsAssetMetadataMigration) @@ -107,7 +109,7 @@ export function Project() {

- Preparing your project assets... + {t('project.preparing')}

- Add to Timeline + {t('assets.addToTimeline')} )} @@ -106,7 +108,7 @@ export function AssetContextMenu({ className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3" > - Show in Explorer + {t('assets.showInExplorer')} )} @@ -220,14 +222,14 @@ export function AssetContextMenu({
-
Label
+
{t('assets.label')}
@@ -246,14 +248,14 @@ export function AssetContextMenu({
-
Move to Bin
+
{t('assets.moveToBin')}
{bins.map(bin => ( @@ -275,7 +277,7 @@ export function AssetContextMenu({ className="w-full text-left px-3 py-1.5 text-zinc-300 hover:bg-zinc-700 flex items-center gap-3" > - New Bin... + {t('assets.newBin')} {isMulti && ( @@ -332,7 +334,7 @@ export function AssetContextMenu({ className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-zinc-700 flex items-center gap-3" > - {isMulti ? `Delete ${targetIds.length} Assets` : 'Delete Asset'} + {isMulti ? t('assets.deleteAssets').replace('{{n}}', String(targetIds.length)) : t('assets.deleteAsset')}
) diff --git a/frontend/views/editor/ClipContextMenu.tsx b/frontend/views/editor/ClipContextMenu.tsx index 63850896c..a2869bb3c 100644 --- a/frontend/views/editor/ClipContextMenu.tsx +++ b/frontend/views/editor/ClipContextMenu.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { useT } from '../../lib/i18n' import { Clipboard, Copy, Scissors, Trash2, Layers, Type, X, RefreshCw, ZoomIn, Film, Eye, FolderOpen, RotateCcw, Volume2, VolumeX, @@ -7,7 +8,7 @@ import { Video, Camera, } from 'lucide-react' import type { Asset, TimelineClip, Track, TextOverlayStyle } from '../../types/project-model' -import { TEXT_PRESETS } from '../../types/project' +import { TEXT_PRESETS, translatePresetName } from '../../types/project' import { COLOR_LABELS } from './video-editor-utils' export type ClipContextMenuState = @@ -122,6 +123,7 @@ export function ClipContextMenu({ onCaptureFrameForVideo, onCreateVideoFromAudio, }: ClipContextMenuProps) { + const { t } = useT() const close = () => setClipContextMenu(null) const isBackground = clipContextMenu.kind === 'background' @@ -160,10 +162,10 @@ export function ClipContextMenu({ ════════════════════════════════════════════════ */} {isBackground ? ( <> - { handlePaste(); close() }} /> - { addTextClip(undefined, currentTime); close() }} /> {TEXT_PRESETS.slice(0, 4).map(preset => ( ))} - { setSelectedClipIds(new Set(clips.map(c => c.id))); close() }} /> ) : multiSelected ? ( @@ -293,6 +295,7 @@ function SingleClipMenu({ onCreateVideoFromAudio: (clip: TimelineClip) => void close: () => void }) { + const { t } = useT() const liveAsset = getLiveAsset(contextClip) const isAdjustment = contextClip.type === 'adjustment' const isVideo = contextClip.type === 'video' @@ -302,20 +305,20 @@ function SingleClipMenu({ return ( <> {/* ── 1. Clipboard ── */} - { handleCut(); close() }} /> - { handleCopy(); close() }} /> - { handlePaste(); close() }} /> + { handleCut(); close() }} /> + { handleCopy(); close() }} /> + { handlePaste(); close() }} /> {/* ── 2. Edit ── */} - { duplicateClip(contextClip.id); close() }} /> - { splitClipAtPlayhead(contextClip.id); close() }} /> + { duplicateClip(contextClip.id); close() }} /> + { splitClipAtPlayhead(contextClip.id); close() }} /> {/* ── 3. Properties ── */} - Speed + {t('menu.speed')}
{[0.25, 0.5, 1, 1.5, 2, 4].map(speed => (
- { updateClip(contextClip.id, { reversed: !contextClip.reversed }); close() }} /> { updateClip(contextClip.id, { muted: !contextClip.muted }); close() }} /> {/* ── 4. Transform ── */} - { updateClip(contextClip.id, { flipH: !contextClip.flipH }); close() }} /> - { updateClip(contextClip.id, { flipV: !contextClip.flipV }); close() }} /> {/* ── 5. Structure (Link / Track) ── */} {contextClip.linkedClipIds?.length ? ( - { + { const allLinked = new Set(contextClip.linkedClipIds!) setClips(prev => prev.map(c => { if (c.id === contextClip.id) return { ...c, linkedClipIds: undefined } @@ -383,7 +386,7 @@ function SingleClipMenu({ ) if (!candidates.length) return null return ( - { + { const candidateIds = candidates.map(c => c.id) setClips(prev => prev.map(c => { if (c.id === contextClip.id) return { ...c, linkedClipIds: candidateIds } @@ -397,7 +400,7 @@ function SingleClipMenu({ {/* ── 6. Color Label ── */} - Label + {t('menu.label')}
@@ -443,19 +446,19 @@ function SingleClipMenu({ {hasAI && ( <> - AI Tools + {t('menu.aiTools')} {contextClip.isRegenerating ? ( - { handleCancelRegeneration(); close() }} /> + { handleCancelRegeneration(); close() }} /> ) : ( - { handleRegenerate(contextClip.assetId!, contextClip.id); close() }} /> )} {/* Take navigation */} {liveAsset!.takes && liveAsset!.takes.length > 1 && (
- Take: + {t('menu.take')} @@ -483,19 +486,19 @@ function SingleClipMenu({ )} {isVideo && contextClip.assetId && ( - {}} /> + {}} /> )} {isImage && ( - { onCreateVideoFromImage(contextClip); close() }} /> )} {isVideo && contextClip.assetId && ( <> - { onRetakeClip(contextClip); close() }} /> {canUseIcLora && ( - { onICLoraClip(contextClip); close() }} /> )} @@ -504,7 +507,7 @@ function SingleClipMenu({ { onCreateVideoFromAudio(contextClip); close() }} /> )} @@ -514,14 +517,14 @@ function SingleClipMenu({ className="w-full text-left px-3 py-1.5 flex items-center gap-3 transition-colors hover:bg-zinc-700 text-zinc-300" > - Use Frame As... + {t('menu.useFrameAs')}
- { onCaptureFrameForVideo(contextClip); close() }} /> {isImage && ( - { onCreateVideoFromImage(contextClip); close() }} /> )}
@@ -535,7 +538,7 @@ function SingleClipMenu({ <> {contextClip.assetId && ( - { + { onRevealAsset(contextClip.assetId!) close() }} /> @@ -548,9 +551,9 @@ function SingleClipMenu({ filePath = liveAsset.takes[Math.max(0, Math.min(takeIdx, liveAsset.takes.length - 1))].path } if (!filePath) return null - const label = window.electronAPI?.platform === 'darwin' ? 'Reveal in Finder' - : window.electronAPI?.platform === 'linux' ? 'Show in Files' - : 'Show in Explorer' + const label = window.electronAPI?.platform === 'darwin' ? t('ctx.revealInFinder') + : window.electronAPI?.platform === 'linux' ? t('menu.showInFiles') + : t('ctx.revealInExplorer') return { window.electronAPI?.showItemInFolder({ filePath }); close() }} /> })()} @@ -558,7 +561,7 @@ function SingleClipMenu({ {/* ── 8. Delete (always last, always red) ── */} - { removeClip(contextClip.id); close() }} /> + { removeClip(contextClip.id); close() }} /> ) } @@ -588,6 +591,7 @@ function MultiClipMenu({ getMaxClipDuration: (clip: TimelineClip) => number close: () => void }) { + const { t } = useT() const n = selectedClipIds.size const selectedClips = clips.filter(c => selectedClipIds.has(c.id)) const allMuted = selectedClips.every(c => c.muted) @@ -609,17 +613,17 @@ function MultiClipMenu({ return ( <> {/* ── Header ── */} - {n} Clips Selected + {t('menu.nClipsSelected').replace('{{n}}', String(n))} {/* ── 1. Clipboard ── */} - { handleCut(); close() }} /> - { handleCopy(); close() }} /> - { handlePaste(); close() }} /> + { handleCut(); close() }} /> + { handleCopy(); close() }} /> + { handlePaste(); close() }} /> {/* ── 2. Properties ── */} - Speed + {t('menu.speed')}
{[0.25, 0.5, 1, 1.5, 2, 4].map(speed => (
- batchUpdate({ muted: !allMuted })} /> - batchUpdate({ reversed: !allReversed })} /> {/* ── 3. Transform ── */} - batchUpdate({ flipH: !allFlipH })} /> - batchUpdate({ flipV: !allFlipV })} /> {/* ── 4. Structure ── */} {anyLinked && ( - { + { const selIds = new Set(selectedClipIds) setClips(prev => prev.map(c => { if (!selIds.has(c.id)) return c @@ -674,7 +678,7 @@ function MultiClipMenu({ }} /> )} {hasVideoAndAudio && !allFullyLinked && ( - { + { const selIds = [...selectedClipIds] setClips(prev => prev.map(c => { if (!selectedClipIds.has(c.id)) return c @@ -689,7 +693,7 @@ function MultiClipMenu({ {/* ── 5. Color Label ── */} - Label + {t('menu.label')}
@@ -737,7 +741,7 @@ function MultiClipMenu({ {/* ── 6. Delete ── */} - { + { setClips(prev => prev.filter(c => !selectedClipIds.has(c.id)).map(c => { if (!c.linkedClipIds) return c const remaining = c.linkedClipIds.filter(lid => !selectedClipIds.has(lid)) diff --git a/frontend/views/editor/ClipPropertiesPanel.tsx b/frontend/views/editor/ClipPropertiesPanel.tsx index d789fefdd..2390d04e3 100644 --- a/frontend/views/editor/ClipPropertiesPanel.tsx +++ b/frontend/views/editor/ClipPropertiesPanel.tsx @@ -9,9 +9,10 @@ import { } from 'lucide-react' import type { Asset, TimelineClip, LetterboxSettings, TextOverlayStyle, TransitionType } from '../../types/project-model' // EFFECTS HIDDEN: removed EffectMask import { DEFAULT_COLOR_CORRECTION, DEFAULT_LETTERBOX } from '../../types/project-model' // EFFECTS HIDDEN: removed EFFECT_DEFINITIONS, DEFAULT_EFFECT_MASK -import { TEXT_PRESETS } from '../../types/project' +import { TEXT_PRESETS, translatePresetName } from '../../types/project' import { formatTime } from './video-editor-utils' import { Tooltip } from '../../components/ui/tooltip' +import { useT } from '../../lib/i18n' import { selectAssets, selectSelectedClipAudioControls, @@ -28,6 +29,7 @@ export function ClipPropertiesPanel(props: ClipPropertiesPanelProps) { const { onCreateVideoFromImage, } = props + const { t } = useT() const { deleteClipDisplayedTake, setClipAudioLevel, @@ -220,12 +222,12 @@ export function ClipPropertiesPanel(props: ClipPropertiesPanelProps) {
)}
- Speed + {t('text.speed')} {selectedClip.speed}x
- Track - {tracks[selectedClip.trackIndex]?.name || `Track ${selectedClip.trackIndex + 1}`} + {t('text.track')} + {tracks[selectedClip.trackIndex]?.name || t('text.trackN').replace('{{n}}', String(selectedClip.trackIndex + 1))}
Start @@ -595,16 +597,16 @@ export function ClipPropertiesPanel(props: ClipPropertiesPanelProps) { {/* Presets */}
- Apply Preset + {t('text.applyPreset')}
{TEXT_PRESETS.map(preset => ( ))}
diff --git a/frontend/views/editor/ProgramMonitor.tsx b/frontend/views/editor/ProgramMonitor.tsx index 82c6b4290..f839b4c65 100644 --- a/frontend/views/editor/ProgramMonitor.tsx +++ b/frontend/views/editor/ProgramMonitor.tsx @@ -7,6 +7,7 @@ import { import { Button } from '../../components/ui/button' import { Tooltip } from '../../components/ui/tooltip' import { AudioWaveform } from '../../components/AudioWaveform' +import { useT } from '../../lib/i18n' import { pathToFileUrl } from '../../lib/file-url' import { DEFAULT_SUBTITLE_STYLE } from '../../types/project-model' import type { Asset, TimelineClip, Track, SubtitleClip } from '../../types/project-model' @@ -425,6 +426,7 @@ export const ProgramMonitor = React.forwardRef
-

Drop clips here

+

{t('timeline.dropClipsHere')}

-

Click assets or drag them to the timeline

+

{t('timeline.clickOrDrag')}

) : ( @@ -1314,8 +1316,8 @@ export const ProgramMonitor = React.forwardRef
-

No clip at playhead

-

Move playhead over a clip to preview

+

{t('monitor.noClip')}

+

{t('monitor.noClipHint')}

) : null })()} @@ -1668,7 +1670,7 @@ export const ProgramMonitor = React.forwardRef {[ - { label: 'Fit', value: 'fit' as const }, + { label: t('monitor.fit'), value: 'fit' as const }, { label: '10%', value: 10 }, { label: '25%', value: 25 }, { label: '50%', value: 50 }, @@ -1699,7 +1701,7 @@ export const ProgramMonitor = React.forwardRef {/* Set In */} - +
diff --git a/frontend/views/editor/TimelineToolbar.tsx b/frontend/views/editor/TimelineToolbar.tsx index 021d3a0c3..9477c92fc 100644 --- a/frontend/views/editor/TimelineToolbar.tsx +++ b/frontend/views/editor/TimelineToolbar.tsx @@ -2,6 +2,7 @@ import React from 'react' import { Plus, Gauge, Download, Maximize2, Sparkles, FileUp, FileDown, ZoomOut, ZoomIn } from 'lucide-react' import { Button } from '../../components/ui/button' import { Tooltip } from '../../components/ui/tooltip' +import { useT } from '../../lib/i18n' import type { TimelineClip, Track, SubtitleClip } from '../../types/project-model' interface TimelineToolbarProps { @@ -31,11 +32,12 @@ export function TimelineToolbar({ tracks, subtitleFileInputRef, handleImportSrt, handleExportSrt, subtitles, zoom, setZoom, getMinZoom, centerOnPlayheadRef, handleFitToView, }: TimelineToolbarProps) { + const { t } = useT() return (
{selectedClip && ( @@ -78,18 +80,18 @@ export function TimelineToolbar({ onClick={() => setShowExportModal(true)} > - Export + {t('toolbar.export')} - +
@@ -105,10 +107,10 @@ export function TimelineToolbar({ } }} disabled={selectedClip?.type !== 'video'} - title="Open IC-LoRA style transfer panel" + title={t('toolbar.icLoraHint')} > - IC-LoRA + {t('toolbar.icLora')} )} @@ -121,19 +123,19 @@ export function TimelineToolbar({
- + {Math.round(zoom * 100)}% - + ))}
@@ -138,7 +140,7 @@ export function ToolsPanel({
- + - + ))}
- + - + {bins.map(bin => (
@@ -561,7 +563,7 @@ export const VideoEditorAssetsPanel = forwardRef ) : (
-

Takes

+

{t('assets.takes')}

)}
@@ -574,7 +576,7 @@ export const VideoEditorAssetsPanel = forwardRef
- + ) : ( ) )} @@ -660,13 +662,13 @@ export const VideoEditorAssetsPanel = forwardRef - Take {idx + 1} + {t('assets.takeLabel').replace('{{n}}', String(idx + 1))}
{isActive && (
- Active + {t('assets.active')}
)} @@ -675,12 +677,12 @@ export const VideoEditorAssetsPanel = forwardRef {takes.length > 1 && ( - +
)} @@ -746,13 +748,13 @@ export const VideoEditorAssetsPanel = forwardRef -

No assets yet

-

Generate in Gen Space or import

+

{t('assets.noAssets')}

+

{t('assets.noAssetsHint')}

) : assetViewMode === 'grid' ? ( @@ -842,13 +844,13 @@ export const VideoEditorAssetsPanel = forwardRef

- {asset.path || 'Audio'} + {asset.path || t('assets.audio')}

) : asset.type === 'adjustment' ? (
-

Adjustment Layer

+

{t('assets.adjustmentLayer')}

) : ( asset.smallThumbnailPath ? ( @@ -863,7 +865,7 @@ export const VideoEditorAssetsPanel = forwardRef {asset.generationParams && ( - +
)} {asset.takes && asset.takes.length > 1 && (
- + - +
) @@ -971,12 +973,12 @@ export const VideoEditorAssetsPanel = forwardRef
{([ - { col: 'name' as const, label: 'Name', flex: 'flex-1 min-w-0' }, - { col: 'type' as const, label: 'Type', flex: 'w-14 flex-shrink-0 text-center' }, - { col: 'duration' as const, label: 'Duration', flex: 'w-16 flex-shrink-0 text-right' }, - { col: 'resolution' as const, label: 'Res', flex: 'w-14 flex-shrink-0 text-right' }, - { col: 'date' as const, label: 'Date', flex: 'w-16 flex-shrink-0 text-right' }, - { col: 'color' as const, label: 'Color', flex: 'w-10 flex-shrink-0 text-center' }, + { col: 'name' as const, label: t('assets.listName'), flex: 'flex-1 min-w-0' }, + { col: 'type' as const, label: t('assets.listType'), flex: 'w-14 flex-shrink-0 text-center' }, + { col: 'duration' as const, label: t('assets.listDuration'), flex: 'w-16 flex-shrink-0 text-right' }, + { col: 'resolution' as const, label: t('assets.listRes'), flex: 'w-14 flex-shrink-0 text-right' }, + { col: 'date' as const, label: t('assets.listDate'), flex: 'w-16 flex-shrink-0 text-right' }, + { col: 'color' as const, label: t('assets.listColor'), flex: 'w-10 flex-shrink-0 text-center' }, ]).map(({ col, label, flex }) => (
- +
)} diff --git a/frontend/views/editor/VideoEditorLayoutMenu.tsx b/frontend/views/editor/VideoEditorLayoutMenu.tsx index 7c7212326..e59989740 100644 --- a/frontend/views/editor/VideoEditorLayoutMenu.tsx +++ b/frontend/views/editor/VideoEditorLayoutMenu.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { LayoutGrid, RotateCcw, Save, Trash2 } from 'lucide-react' import { Tooltip } from '../../components/ui/tooltip' +import { useT } from '../../lib/i18n' import { type EditorLayout, type LayoutPreset, @@ -16,6 +17,7 @@ export interface VideoEditorLayoutMenuProps { export function VideoEditorLayoutMenu(props: VideoEditorLayoutMenuProps) { const { currentLayout, onApplyLayout, onResetLayout } = props + const { t } = useT() const [showLayoutMenu, setShowLayoutMenu] = useState(false) const [layoutPresets, setLayoutPresets] = useState(loadLayoutPresets) @@ -123,7 +125,7 @@ export function VideoEditorLayoutMenu(props: VideoEditorLayoutMenuProps) { className="w-full flex items-center gap-2.5 px-3 py-1.5 text-[13px] text-zinc-200 hover:bg-blue-600 hover:text-white transition-colors" > - Save Current Layout... + {t('layout.saveCurrent')} {layoutPresets.length > 0 && ( <> diff --git a/frontend/views/editor/VideoEditorTimelineControlPanel.tsx b/frontend/views/editor/VideoEditorTimelineControlPanel.tsx index 8713e6b1b..4b28583f1 100644 --- a/frontend/views/editor/VideoEditorTimelineControlPanel.tsx +++ b/frontend/views/editor/VideoEditorTimelineControlPanel.tsx @@ -9,6 +9,7 @@ import { selectTimelines, } from './editor-selectors' import { useEditorActions, useEditorStore } from './editor-store' +import { useT } from '../../lib/i18n' export interface VideoEditorTimelineControlPanelProps { handleTimelineTabContextMenu: (e: React.MouseEvent, timelineId: string) => void @@ -28,6 +29,7 @@ export function VideoEditorTimelineControlPanel(props: VideoEditorTimelineContro startTimelineRename, switchActiveTimeline, } = useEditorActions() + const { t } = useT() const timelines = useEditorStore(selectTimelines) const activeTimelineId = useEditorStore(selectActiveTimelineId) const openTimelineIds = useEditorStore(selectOpenTimelineIds) @@ -55,7 +57,7 @@ export function VideoEditorTimelineControlPanel(props: VideoEditorTimelineContro } const handleAddTimeline = () => { - createTimeline() + createTimeline(undefined, t('timeline.defaultNamePrefix')) } const handleSwitchTimeline = (timelineId: string) => { @@ -92,9 +94,9 @@ export function VideoEditorTimelineControlPanel(props: VideoEditorTimelineContro return (
-

Timelines

+

{t('timeline.title')}

- +
)} @@ -189,12 +191,12 @@ export function VideoEditorTimelineControlPanel(props: VideoEditorTimelineContro
{isActive ? ( - Active + {t('timeline.active')} ) : item.isOpen ? ( - + ) : null} {timelineItems.length > 1 && ( - +
@@ -1513,7 +1515,7 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin className="w-full text-left px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700 flex items-center gap-2" > - Export Timeline... + {t('timeline.exportTimelineDots')}
@@ -1537,7 +1539,7 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin className="w-full text-left px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700 flex items-center gap-2" > - Close Tab + {t('timeline.closeTimeline')} {timelines.length > 1 && ( <> @@ -1546,7 +1548,7 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin className="w-full text-left px-3 py-1.5 text-xs text-red-400 hover:bg-zinc-700 flex items-center gap-2" > - Delete + {t('timeline.deleteTimelineMenu')} )} @@ -1559,7 +1561,7 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin {/* Tools Panel */}
{PRIMARY_TOOLS.map(tool => ( - + @@ -1694,14 +1696,14 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin
- + @@ -1710,7 +1712,7 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin <>
- + @@ -1905,7 +1907,7 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin
- V | A + {t('clip.vaDivider')}
)}
{/* Row 2: tools */}
- + - + - + - + - +
- + {track.kind !== 'audio' && ( - + @@ -2118,13 +2120,13 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin className={`px-1 py-0.5 rounded text-[10px] font-bold leading-none ${ track.solo ? 'bg-yellow-500/80 text-black' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700' }`} - title={track.solo ? 'Unsolo track' : 'Solo track'} + title={track.solo ? t('track.unsolo') : t('track.solo')} > S )} {tracks.length > 1 && ( - + - + {clip.type === 'video' && ( - +
@@ -2773,7 +2775,7 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin
)} {/* Cancel button */} - +
)} @@ -3089,7 +3091,7 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin onClick={() => addCrossDissolve(cp.leftClip.id, cp.rightClip.id)} > - Dissolve + {t('clip.dissolve')}
)} @@ -3148,7 +3150,7 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin onClick={() => actions.openExportModal()} > - Export + {t('toolbar.export')} @@ -3160,19 +3162,19 @@ export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditin
- + {Math.round(zoom * 100)}% - +