feat: english support

This commit is contained in:
Sebastian Boehler
2025-08-21 12:01:28 +02:00
parent b66714cb0e
commit 13a45a817b
11 changed files with 459 additions and 93 deletions

View File

@@ -1,4 +1,5 @@
import { app, BrowserWindow, screen } from 'electron'
import { app, BrowserWindow, screen, Menu } from 'electron'
import type { MenuItemConstructorOptions } from 'electron'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import GlobalSetting from '../setting.global'
@@ -69,6 +70,95 @@ function createWindow() {
}
}
function buildMenu() {
const template: MenuItemConstructorOptions[] = [
// macOS standard app menu
...(process.platform === 'darwin'
? [
{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
] as MenuItemConstructorOptions[],
},
]
: []),
{
label: 'Language',
submenu: [
{
label: 'English',
type: 'radio',
checked: true,
click: () => {
BrowserWindow.getAllWindows().forEach((w) => w.webContents.send('set-locale', 'en'))
},
},
{
label: '中文',
type: 'radio',
click: () => {
BrowserWindow.getAllWindows().forEach((w) => w.webContents.send('set-locale', 'zh-CN'))
},
},
] as MenuItemConstructorOptions[],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
] as MenuItemConstructorOptions[],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
] as MenuItemConstructorOptions[],
},
{
role: 'window',
submenu: [{ role: 'minimize' }, { role: 'close' }] as MenuItemConstructorOptions[],
},
{
role: 'help',
submenu: [
{
label: 'Learn More',
click: async () => {
const { shell } = await import('electron')
await shell.openExternal('https://github.com/YILS-LIN/short-video-factory')
},
},
] as MenuItemConstructorOptions[],
},
]
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
}
//关闭所有窗口后退出macOS除外。在那里这很常见
//让应用程序及其菜单栏保持活动状态,直到用户退出
//显式使用Cmd+Q。
@@ -101,4 +191,7 @@ app.whenReady().then(() => {
app.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')
// 允许本地网络请求
app.commandLine.appendSwitch('disable-features', 'BlockInsecurePrivateNetworkRequests')
// Build application menu
buildMenu()
})

View File

@@ -24,7 +24,8 @@
"ffmpeg-static": "^5.2.0",
"music-metadata": "^11.7.3",
"subtitle": "4.2.2-alpha.0",
"ws": "^8.18.3"
"ws": "^8.18.3",
"vue-i18n": "^9.14.0"
},
"devDependencies": {
"@ai-sdk/openai": "^1.3.23",

40
pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
subtitle:
specifier: 4.2.2-alpha.0
version: 4.2.2-alpha.0
vue-i18n:
specifier: ^9.14.0
version: 9.14.5(vue@3.5.17(typescript@5.6.2))
ws:
specifier: ^8.18.3
version: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5)
@@ -497,6 +500,18 @@ packages:
'@iconify/utils@2.3.0':
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
'@intlify/core-base@9.14.5':
resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==}
engines: {node: '>= 16'}
'@intlify/message-compiler@9.14.5':
resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==}
engines: {node: '>= 16'}
'@intlify/shared@9.14.5':
resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==}
engines: {node: '>= 16'}
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
@@ -2754,6 +2769,12 @@ packages:
peerDependencies:
vue: ^3.4.37
vue-i18n@9.14.5:
resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==}
engines: {node: '>= 16'}
peerDependencies:
vue: ^3.0.0
vue-router@4.5.1:
resolution: {integrity: sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==}
peerDependencies:
@@ -3262,6 +3283,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@intlify/core-base@9.14.5':
dependencies:
'@intlify/message-compiler': 9.14.5
'@intlify/shared': 9.14.5
'@intlify/message-compiler@9.14.5':
dependencies:
'@intlify/shared': 9.14.5
source-map-js: 1.2.1
'@intlify/shared@9.14.5': {}
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.0':
@@ -5712,6 +5745,13 @@ snapshots:
dependencies:
vue: 3.5.17(typescript@5.6.2)
vue-i18n@9.14.5(vue@3.5.17(typescript@5.6.2)):
dependencies:
'@intlify/core-base': 9.14.5
'@intlify/shared': 9.14.5
'@vue/devtools-api': 6.6.4
vue: 3.5.17(typescript@5.6.2)
vue-router@4.5.1(vue@3.5.17(typescript@5.6.2)):
dependencies:
'@vue/devtools-api': 6.6.4

200
src/i18n.ts Normal file
View File

@@ -0,0 +1,200 @@
import { createI18n } from 'vue-i18n'
const messages = {
en: {
app: {
name: 'AI Short Video Factory',
},
prompt: { label: 'Prompt' },
actions: { generate: 'Generate', stop: 'Stop', config: 'Configure', refreshAssets: 'Refresh Assets' },
llm: {
configTitle: 'Configure LLM API',
modelName: 'Model Name',
apiUrl: 'API URL',
apiKey: 'API Key',
compatibleNote: 'Compatible with any OpenAI-compatible API',
connectSuccess: 'LLM connected successfully',
connectFailedPrefix: 'LLM connection failed, please check your configuration',
},
common: { close: 'Close', test: 'Test', save: 'Save', select: 'Select', noData: 'No data' },
output: { label: 'Output Text (editable)' },
errors: {
promptRequired: 'Prompt cannot be empty',
generateFailedPrefix: 'Generation failed, please check LLM configuration',
outputFileNameRequired: 'Please set an output file name first',
outputPathRequired: 'Please set an export folder first',
outputSizeRequired: 'Please set output resolution (width x height) first',
bgmListFailed: 'Failed to load BGM list. Please check the folder exists',
ttsFailedCorrupt: 'TTS failed: audio file is corrupted',
ttsZeroDuration: 'TTS duration is 0s. Check TTS config and network connectivity',
renderFailedPrefix: 'Video rendering failed. Please check all configurations',
assetsDurationInsufficient: 'Total assets duration is insufficient',
edgeTtsListFailed: 'Failed to fetch Edge TTS voice list. Please check your network',
ttsConfigInvalid: 'TTS configuration is invalid',
ttsSynthesisFailed: 'TTS synthesis failed',
},
success: { renderSuccess: 'Video rendered successfully' },
info: { batchNext: 'Start next render', renderCanceled: 'Video rendering canceled' },
empty: {
noContent: 'No content yet',
hintSelectFolder: 'Please choose a folder above with enough storyboard assets',
},
videoManage: {
assetsFolderLabel: 'Storyboard assets folder',
noMp4InFolder: 'No MP4 video files found in the selected folder',
emptyFolder: 'The selected folder is empty',
readSuccess: 'Assets loaded successfully',
readFailed: 'Failed to read assets. Please check if the folder exists',
},
dialogs: {
selectAssetsFolderTitle: 'Select storyboard assets folder',
selectOutputFolderTitle: 'Select video export folder',
selectBgmFolderTitle: 'Select background music folder',
renderConfigTitle: 'Configure render options',
},
render: {
status: {
idle: 'Idle, ready to render',
generatingText: 'Generating script with AI LLM',
synthesizingSpeech: 'Synthesizing speech with TTS',
segmentingVideo: 'Processing video segments',
rendering: 'Rendering video',
success: 'Rendered successfully, you can start the next one',
failed: 'Render failed, please try again',
},
startRender: 'Start Rendering',
stopRender: 'Stop Rendering',
autoBatch: 'Auto batch render',
bgmFolderLabel: 'Background music folder (.mp3, pick randomly)',
output: {
width: 'Output width',
height: 'Output height',
fileName: 'Output file name',
format: 'Output format',
folder: 'Output folder',
},
},
tts: {
language: 'Language',
gender: 'Gender',
voice: 'Voice',
speed: 'Speed',
tryText: 'Try-listen text',
tryListen: 'Try listen',
selectLanguageGenderFirst: 'Please select language and gender first',
selectVoiceWarning: 'Please select a voice',
tryTextEmptyWarning: 'Try-listen text cannot be empty',
playTryAudio: 'Playing try-listen audio',
trySynthesisFailedNetwork: 'Failed to synthesize try-listen audio. Please check network',
genderMale: 'Male',
genderFemale: 'Female',
speedSlow: 'Slow',
speedMedium: 'Medium',
speedFast: 'Fast',
},
footer: { poweredBy: 'Powered by YILS (Blog: https://yils.blog)' },
},
'zh-CN': {
app: { name: 'AI Short Video Factory - 短视频工厂' },
prompt: { label: '提示词' },
actions: { generate: '生成', stop: '停止', config: '配置', refreshAssets: '刷新素材库' },
llm: {
configTitle: '配置大语言模型接口',
modelName: '模型名称',
apiUrl: 'API 地址',
apiKey: 'API Key',
compatibleNote: '兼容任意 OpenAI 标准接口',
connectSuccess: '大模型连接成功',
connectFailedPrefix: '大模型连接失败,请检查配置是否正确',
},
common: { close: '关闭', test: '测试', save: '保存', select: '选择', noData: '无数据' },
output: { label: '输出文案(可编辑)' },
errors: {
promptRequired: '提示词不能为空',
generateFailedPrefix: '生成失败,请检查大模型配置是否正确',
outputFileNameRequired: '请先配置导出文件名',
outputPathRequired: '请先配置导出文件夹',
outputSizeRequired: '请先配置导出分辨率(宽高)',
bgmListFailed: '获取背景音乐列表失败,请检查文件夹是否存在',
ttsFailedCorrupt: '语音合成失败,音频文件损坏',
ttsZeroDuration: '语音时长为0秒检查TTS语音合成配置及网络连接是否正常',
renderFailedPrefix: '视频合成失败,请检查各项配置是否正确',
assetsDurationInsufficient: '素材总时长不足',
edgeTtsListFailed: '获取EdgeTTS语音列表失败请检查网络',
ttsConfigInvalid: 'TTS语音合成配置无效',
ttsSynthesisFailed: '语音合成失败',
},
success: { renderSuccess: '视频合成成功' },
info: { batchNext: '开始合成下一个', renderCanceled: '视频合成已终止' },
empty: {
noContent: '暂无内容',
hintSelectFolder: '从上面选择一个包含足够分镜素材的文件夹',
},
videoManage: {
assetsFolderLabel: '分镜视频素材文件夹',
noMp4InFolder: '选择的文件夹中不包含MP4视频文件',
emptyFolder: '选择的文件夹为空',
readSuccess: '素材读取成功',
readFailed: '素材读取失败,请检查文件夹是否存在',
},
dialogs: {
selectAssetsFolderTitle: '选择分镜素材文件夹',
selectOutputFolderTitle: '选择视频导出文件夹',
selectBgmFolderTitle: '选择背景音乐文件夹',
renderConfigTitle: '配置合成选项',
},
render: {
status: {
idle: '空闲,可以开始合成',
generatingText: '正在使用 AI 大模型生成文案',
synthesizingSpeech: '正在使用 TTS 合成语音',
segmentingVideo: '正在处理分镜素材',
rendering: '正在渲染视频',
success: '渲染成功,可以开始下一个',
failed: '渲染失败,请重新尝试',
},
startRender: '开始合成',
stopRender: '停止合成',
autoBatch: '自动批量合成',
bgmFolderLabel: '背景音乐文件夹(.mp3格式从中随机选取',
output: {
width: '导出视频宽度',
height: '导出视频高度',
fileName: '导出文件名',
format: '导出格式',
folder: '导出文件夹',
},
},
tts: {
language: '语言',
gender: '性别',
voice: '声音',
speed: '语速',
tryText: '试听文本',
tryListen: '试听',
selectLanguageGenderFirst: '请先选择语言和性别',
selectVoiceWarning: '请选择一个声音',
tryTextEmptyWarning: '试听文本不能为空',
playTryAudio: '播放试听语音',
trySynthesisFailedNetwork: '试听语音合成失败,请检查网络',
genderMale: '男性',
genderFemale: '女性',
speedSlow: '慢',
speedMedium: '中',
speedFast: '快',
},
footer: { poweredBy: 'Powered by YILS博客地址https://yils.blog' },
},
}
const stored = (typeof localStorage !== 'undefined' && localStorage.getItem('locale')) || undefined
const locale = stored || 'en'
const i18n = createI18n({
legacy: false,
locale,
fallbackLocale: 'en',
messages,
})
export default i18n

View File

@@ -2,7 +2,7 @@
<div class="layout-container">
<div class="logo" v-if="!route.meta.hideAppIcon">
<img src="/icon.png" alt="" />
<span>{{ GlobalSetting.appName }}</span>
<span>{{ t('app.name') }}</span>
</div>
<div class="window-control-bar">
<div class="control-btn control-btn-min" @click="handleMin">
@@ -21,12 +21,13 @@
</template>
<script lang="ts" setup>
import GlobalSetting from '../../setting.global'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
const route = useRoute()
const windowIsMaxed = ref(false)
const { t } = useI18n()
window.addEventListener('resize', async () => {
windowIsMaxed.value = await window.electron.isWinMaxed()

View File

@@ -12,7 +12,7 @@ import 'virtual:uno.css'
import './assets/base.scss'
import { createApp } from 'vue'
import GlobalSetting from '../setting.global'
import i18n from './i18n'
import router from './router/index.ts'
import store from './store/index.ts'
import App from './App.vue'
@@ -29,12 +29,14 @@ const vuetify = createVuetify({
},
})
document.title = GlobalSetting.appName
// Set initial document title from i18n
document.title = i18n.global.t('app.name') as string
const app = createApp(App)
app.use(vuetify)
app.use(Toast, { position: 'bottom-left', pauseOnFocusLoss: false } as PluginOptions)
app.use(i18n)
app.use(router)
app.use(store)
@@ -43,4 +45,14 @@ app.mount('#app').$nextTick(() => {
window.ipcRenderer.on('main-process-message', (_event, message) => {
console.log(message)
})
// Language switching from Electron menu
window.ipcRenderer.on('set-locale', (_event, locale: string) => {
// @ts-ignore
i18n.global.locale.value = locale
try {
localStorage.setItem('locale', locale)
} catch {}
document.title = i18n.global.t('app.name') as string
})
})

View File

@@ -5,7 +5,7 @@
<v-textarea
class="h-full"
v-model="appStore.prompt"
label="提示词"
:label="t('prompt.label')"
counter
persistent-counter
no-resize
@@ -19,7 +19,7 @@
:disabled="disabled"
@click="handleGenerate"
>
生成
{{ t('actions.generate') }}
</v-btn>
<v-btn
v-else
@@ -29,51 +29,51 @@
:disabled="disabled"
@click="handleStopGenerate"
>
停止
{{ t('actions.stop') }}
</v-btn>
<v-dialog v-model="configDialogShow" max-width="600" persistent>
<template v-slot:activator="{ props: activatorProps }">
<v-btn v-bind="activatorProps" :disabled="disabled"> 配置 </v-btn>
<v-btn v-bind="activatorProps" :disabled="disabled"> {{ t('actions.config') }} </v-btn>
</template>
<v-card prepend-icon="mdi-text-box-edit-outline" title="配置大语言模型接口">
<v-card prepend-icon="mdi-text-box-edit-outline" :title="t('llm.configTitle')">
<v-card-text>
<v-text-field
label="模型名称"
:label="t('llm.modelName')"
v-model="config.modelName"
required
clearable
></v-text-field>
<v-text-field
label="API 地址"
:label="t('llm.apiUrl')"
v-model="config.apiUrl"
required
clearable
></v-text-field>
<v-text-field
label="API Key"
:label="t('llm.apiKey')"
v-model="config.apiKey"
type="password"
required
clearable
></v-text-field>
<small class="text-caption text-medium-emphasis">兼容任意 OpenAI 标准接口</small>
<small class="text-caption text-medium-emphasis">{{ t('llm.compatibleNote') }}</small>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text="关闭" variant="plain" @click="handleCloseDialog"></v-btn>
<v-btn :text="t('common.close')" variant="plain" @click="handleCloseDialog"></v-btn>
<v-btn
color="success"
text="测试"
:text="t('common.test')"
variant="tonal"
:loading="testStatus === TestStatusEnum.LOADING"
@click="handleTestConfig"
></v-btn>
<v-btn
color="primary"
text="保存"
:text="t('common.save')"
variant="tonal"
@click="handleSaveConfig"
></v-btn>
@@ -86,7 +86,7 @@
<v-textarea
class="h-full"
v-model="outputText"
label="输出文案(可编辑)"
:label="t('output.label')"
counter
persistent-counter
no-resize
@@ -102,9 +102,11 @@ import { nextTick, ref, toRaw } from 'vue'
import { createOpenAI } from '@ai-sdk/openai'
import { generateText, streamText } from 'ai'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
const toast = useToast()
const appStore = useAppStore()
const { t } = useI18n()
defineProps<{
disabled?: boolean
@@ -116,8 +118,8 @@ const isGenerating = ref(false)
const abortController = ref<AbortController | null>(null)
const handleGenerate = async (oprions?: { noToast?: boolean }) => {
if (!appStore.prompt) {
!oprions?.noToast && toast.warning('提示词不能为空')
throw new Error('提示词不能为空')
!oprions?.noToast && toast.warning(t('errors.promptRequired'))
throw new Error(t('errors.promptRequired') as string)
}
const openai = createOpenAI({
@@ -149,9 +151,7 @@ const handleGenerate = async (oprions?: { noToast?: boolean }) => {
// @ts-ignore
const errorMessage = error?.message || error?.error?.message
!oprions?.noToast &&
toast.error(
`生成失败,请检查大模型配置是否正确\n${errorMessage ? '错误信息:“' + errorMessage + '”' : ''}`,
)
toast.error(`${t('errors.generateFailedPrefix')}\n${errorMessage ? 'Error: ' + errorMessage : ''}`)
throw error
}
} finally {
@@ -197,15 +197,13 @@ const handleTestConfig = async () => {
})
console.log(`result`, result)
testStatus.value = TestStatusEnum.SUCCESS
toast.success('大模型连接成功')
toast.success(t('llm.connectSuccess'))
} catch (error) {
console.log(error)
testStatus.value = TestStatusEnum.ERROR
// @ts-ignore
const errorMessage = error?.message
toast.error(
`大模型连接失败,请检查配置是否正确\n${errorMessage ? '错误信息:“' + errorMessage + '”' : ''}`,
)
toast.error(`${t('llm.connectFailedPrefix')}\n${errorMessage ? 'Error: ' + errorMessage : ''}`)
}
}

View File

@@ -5,16 +5,16 @@
<v-combobox
v-model="appStore.language"
density="comfortable"
label="语言"
:label="t('tts.language')"
:items="appStore.languageList"
no-data-text="无数据"
:no-data-text="t('common.noData')"
@update:model-value="clearVoice"
></v-combobox>
<v-select
v-model="appStore.gender"
density="comfortable"
label="性别"
:items="appStore.genderList"
:label="t('tts.gender')"
:items="genderItems"
item-title="label"
item-value="value"
@update:model-value="clearVoice"
@@ -22,24 +22,24 @@
<v-select
v-model="appStore.voice"
density="comfortable"
label="声音"
:label="t('tts.voice')"
:items="filteredVoicesList"
item-title="FriendlyName"
return-object
no-data-text="请先选择语言和性别"
:no-data-text="t('tts.selectLanguageGenderFirst')"
></v-select>
<v-select
v-model="appStore.speed"
density="comfortable"
label="语速"
:items="appStore.speedList"
:label="t('tts.speed')"
:items="speedItems"
item-title="label"
item-value="value"
></v-select>
<v-text-field
v-model="appStore.tryListeningText"
density="comfortable"
label="试听文本"
:label="t('tts.tryText')"
></v-text-field>
<v-btn
class="mb-2"
@@ -49,7 +49,7 @@
:disabled="disabled"
@click="handleTryListening"
>
试听
{{ t('tts.tryListen') }}
</v-btn>
</v-sheet>
</v-form>
@@ -58,11 +58,13 @@
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/store'
import { useToast } from 'vue-toastification'
const toast = useToast()
const appStore = useAppStore()
const { t } = useI18n()
defineProps<{
disabled?: boolean
@@ -70,12 +72,12 @@ defineProps<{
const configValid = () => {
if (!appStore.voice) {
toast.warning('请选择一个声音')
toast.warning(t('tts.selectVoiceWarning'))
return false
}
if (!appStore.tryListeningText) {
toast.warning('试听文本不能为空')
toast.warning(t('tts.tryTextEmptyWarning'))
return false
}
@@ -97,10 +99,10 @@ const handleTryListening = async () => {
})
const audio = new Audio(`data:audio/mp3;base64,${speech}`)
audio.play()
toast.info('播放试听语音')
toast.info(t('tts.playTryAudio'))
} catch (error) {
console.log('试听语音合成失败', error)
toast.error('试听语音合成失败,请检查网络')
toast.error(t('tts.trySynthesisFailedNetwork'))
} finally {
tryListeningLoading.value = false
}
@@ -116,13 +118,28 @@ const filteredVoicesList = computed(() => {
)
})
const genderItems = computed(() => {
return [
{ label: t('tts.genderMale'), value: 'Male' },
{ label: t('tts.genderFemale'), value: 'Female' },
]
})
const speedItems = computed(() => {
return [
{ label: t('tts.speedSlow'), value: -30 },
{ label: t('tts.speedMedium'), value: 0 },
{ label: t('tts.speedFast'), value: 30 },
]
})
const fetchVoices = async () => {
try {
appStore.originalVoicesList = await window.electron.edgeTtsGetVoiceList()
console.log('EdgeTTS语音列表更新', appStore.originalVoicesList)
} catch (error) {
console.log('获取EdgeTTS语音列表失败', error)
toast.error('获取EdgeTTS语音列表失败请检查网络')
toast.error(t('errors.edgeTtsListFailed'))
}
}
onMounted(async () => {
@@ -133,7 +150,7 @@ onMounted(async () => {
})
const synthesizedSpeechToFile = async (option: { text: string; withCaption?: boolean }) => {
if (!configValid()) throw new Error('TTS语音合成配置无效')
if (!configValid()) throw new Error(t('errors.ttsConfigInvalid'))
try {
const result = await window.electron.edgeTtsSynthesizeToFile({
@@ -147,7 +164,7 @@ const synthesizedSpeechToFile = async (option: { text: string; withCaption?: boo
return result
} catch (error) {
console.log('语音合成失败', error)
throw error
throw new Error(t('errors.ttsSynthesisFailed'))
}
}

View File

@@ -5,7 +5,7 @@
<div class="flex gap-2 mb-2">
<v-text-field
v-model="appStore.videoAssetsFolder"
label="分镜视频素材文件夹"
:label="t('videoManage.assetsFolderLabel')"
density="compact"
hide-details
readonly
@@ -17,7 +17,7 @@
:disabled="disabled"
@click="handleSelectFolder"
>
选择
{{ t('common.select') }}
</v-btn>
</div>
@@ -43,8 +43,8 @@
</div>
<v-empty-state
v-else
headline="暂无内容"
text="从上面选择一个包含足够分镜素材的文件夹"
:headline="t('empty.noContent')"
:text="t('empty.hintSelectFolder')"
></v-empty-state>
</div>
@@ -56,7 +56,7 @@
:loading="refreshAssetsLoading"
@click="refreshAssets"
>
刷新素材库
{{ t('actions.refreshAssets') }}
</v-btn>
</div>
</v-sheet>
@@ -66,6 +66,7 @@
<script lang="ts" setup>
import { ref, toRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/store'
import { useToast } from 'vue-toastification'
import { ListFilesFromFolderRecord } from '~/electron/types'
@@ -75,6 +76,7 @@ import random from 'random'
const toast = useToast()
const appStore = useAppStore()
const { t } = useI18n()
defineProps<{
disabled?: boolean
@@ -83,7 +85,7 @@ defineProps<{
// 选择文件夹
const handleSelectFolder = async () => {
const folderPath = await window.electron.selectFolder({
title: '选择分镜素材文件夹',
title: t('dialogs.selectAssetsFolderTitle'),
defaultPath: appStore.videoAssetsFolder,
})
console.log('用户选择分镜素材文件夹,绝对路径:', folderPath)
@@ -109,16 +111,16 @@ const refreshAssets = async () => {
videoAssets.value = assets.filter((asset) => asset.name.endsWith('.mp4'))
if (!videoAssets.value.length) {
if (assets.length) {
toast.warning('选择的文件夹中不包含MP4视频文件')
toast.warning(t('videoManage.noMp4InFolder'))
} else {
toast.warning('选择的文件夹为空')
toast.warning(t('videoManage.emptyFolder'))
}
} else {
toast.success('素材读取成功')
toast.success(t('videoManage.readSuccess'))
}
} catch (error) {
console.log(error)
toast.error('素材读取失败,请检查文件夹是否存在')
toast.error(t('videoManage.readFailed'))
} finally {
refreshAssetsLoading.value = false
}
@@ -130,7 +132,7 @@ const videoInfoList = ref<VideoInfo[]>([])
const getVideoSegments = (options: { duration: number }) => {
// 判断素材库是否满足时长要求
if (videoInfoList.value.reduce((pre, cur) => pre + cur.duration, 0) < options.duration) {
throw new Error('素材总时长不足')
throw new Error(t('errors.assetsDurationInsufficient'))
}
// 搜集随机素材片段

View File

@@ -1,28 +1,28 @@
<template>
<div class="h-0 flex-1 relative">
<div class="absolute top-1/12 w-full flex justify-center cursor-default select-none">
<v-chip v-if="appStore.renderStatus === RenderStatus.None"> 空闲可以开始合成 </v-chip>
<v-chip v-if="appStore.renderStatus === RenderStatus.None"> {{ t('render.status.idle') }} </v-chip>
<v-chip v-if="appStore.renderStatus === RenderStatus.GenerateText" variant="elevated">
正在使用 AI 大模型生成文案
{{ t('render.status.generatingText') }}
</v-chip>
<v-chip v-if="appStore.renderStatus === RenderStatus.SynthesizedSpeech" variant="elevated">
正在使用 TTS 合成语音
{{ t('render.status.synthesizingSpeech') }}
</v-chip>
<v-chip v-if="appStore.renderStatus === RenderStatus.SegmentVideo" variant="elevated">
正在处理分镜素材
{{ t('render.status.segmentingVideo') }}
</v-chip>
<v-chip v-if="appStore.renderStatus === RenderStatus.Rendering" variant="elevated">
正在渲染视频
{{ t('render.status.rendering') }}
</v-chip>
<v-chip
v-if="appStore.renderStatus === RenderStatus.Completed"
variant="elevated"
color="success"
>
渲染成功可以开始下一个
{{ t('render.status.success') }}
</v-chip>
<v-chip v-if="appStore.renderStatus === RenderStatus.Failed" variant="elevated" color="error">
渲染失败请重新尝试
{{ t('render.status.failed') }}
</v-chip>
</div>
@@ -46,7 +46,7 @@
prepend-icon="mdi-rocket-launch"
@click="emit('renderVideo')"
>
开始合成
{{ t('render.startRender') }}
</v-btn>
<v-btn
v-else
@@ -55,31 +55,31 @@
prepend-icon="mdi-stop"
@click="emit('cancelRender')"
>
停止合成
{{ t('render.stopRender') }}
</v-btn>
<v-dialog v-model="configDialogShow" max-width="600" persistent>
<template v-slot:activator="{ props: activatorProps }">
<v-btn v-bind="activatorProps" :disabled="taskInProgress"> 合成配置 </v-btn>
<v-btn v-bind="activatorProps" :disabled="taskInProgress"> {{ t('actions.config') }} </v-btn>
</template>
<v-card prepend-icon="mdi-text-box-edit-outline" title="配置合成选项">
<v-card prepend-icon="mdi-text-box-edit-outline" :title="t('dialogs.renderConfigTitle')">
<v-card-text>
<div class="w-full flex gap-2 mb-4 items-center">
<v-text-field
label="导出视频宽度"
:label="t('render.output.width')"
v-model="config.outputSize.width"
hide-details
></v-text-field>
<v-text-field
v-model="config.outputSize.height"
label="导出视频高度"
:label="t('render.output.height')"
hide-details
required
></v-text-field>
</div>
<div class="w-full flex gap-2 mb-4 items-center">
<v-text-field
label="导出文件名"
:label="t('render.output.fileName')"
v-model="config.outputFileName"
hide-details
required
@@ -88,7 +88,7 @@
<v-text-field
class="w-[120px] flex-none"
v-model="config.outputFileExt"
label="导出格式"
:label="t('render.output.format')"
hide-details
readonly
required
@@ -96,7 +96,7 @@
</div>
<div class="w-full flex gap-2 mb-4 items-center">
<v-text-field
label="导出文件夹"
:label="t('render.output.folder')"
v-model="config.outputPath"
hide-details
readonly
@@ -107,12 +107,12 @@
prepend-icon="mdi-folder-open"
@click="handleSelectOutputFolder"
>
选择
{{ t('common.select') }}
</v-btn>
</div>
<div class="w-full flex gap-2 mb-2 items-center">
<v-text-field
label="背景音乐文件夹(.mp3格式从中随机选取"
:label="t('render.bgmFolderLabel')"
v-model="config.bgmPath"
hide-details
readonly
@@ -124,17 +124,17 @@
prepend-icon="mdi-folder-open"
@click="handleSelectBgmFolder"
>
选择
{{ t('common.select') }}
</v-btn>
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text="关闭" variant="plain" @click="handleCloseDialog"></v-btn>
<v-btn :text="t('common.close')" variant="plain" @click="handleCloseDialog"></v-btn>
<v-btn
color="primary"
text="保存"
:text="t('common.save')"
variant="tonal"
@click="handleSaveConfig"
></v-btn>
@@ -147,7 +147,7 @@
<div class="w-full flex justify-center">
<v-switch
v-model="appStore.autoBatch"
label="自动批量合成"
:label="t('render.autoBatch')"
color="indigo"
density="compact"
hide-details
@@ -158,7 +158,7 @@
<div class="absolute bottom-2 w-full flex justify-center text-sm">
<span class="text-indigo cursor-pointer select-none" @click="handleOpenHomePage">
Powered by YILS博客地址https://yils.blog
{{ t('footer.poweredBy') }}
</span>
</div>
</div>
@@ -166,9 +166,11 @@
<script lang="ts" setup>
import { ref, toRaw, nextTick, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { RenderStatus, useAppStore } from '@/store'
const appStore = useAppStore()
const { t } = useI18n()
const emit = defineEmits<{
(e: 'renderVideo'): void
@@ -206,7 +208,7 @@ const handleSaveConfig = () => {
// 选择文件夹
const handleSelectOutputFolder = async () => {
const folderPath = await window.electron.selectFolder({
title: '选择视频导出文件夹',
title: t('dialogs.selectOutputFolderTitle'),
defaultPath: config.value.outputPath,
})
console.log('用户选择视频导出文件夹,绝对路径:', folderPath)
@@ -216,7 +218,7 @@ const handleSelectOutputFolder = async () => {
}
const handleSelectBgmFolder = async () => {
const folderPath = await window.electron.selectFolder({
title: '选择背景音乐文件夹',
title: t('dialogs.selectBgmFolderTitle'),
defaultPath: config.value.bgmPath,
})
console.log('用户选择背景音乐文件夹,绝对路径:', folderPath)

View File

@@ -35,6 +35,7 @@ import TtsControl from './components/tts-control.vue'
import VideoRender from './components/video-render.vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { RenderStatus, useAppStore } from '@/store'
import { useToast } from 'vue-toastification'
import { ListFilesFromFolderRecord } from '~/electron/types'
@@ -42,6 +43,7 @@ import random from 'random'
const toast = useToast()
const appStore = useAppStore()
const { t } = useI18n()
// 渲染合成视频
const TextGenerateInstance = ref<InstanceType<typeof TextGenerate> | null>()
@@ -49,15 +51,15 @@ const VideoManageInstance = ref<InstanceType<typeof VideoManage> | null>()
const TtsControlInstance = ref<InstanceType<typeof TtsControl> | null>()
const handleRenderVideo = async () => {
if (!appStore.renderConfig.outputFileName) {
toast.warning('请先配置导出文件名')
toast.warning(t('errors.outputFileNameRequired'))
return
}
if (!appStore.renderConfig.outputPath) {
toast.warning('请先配置导出文件夹')
toast.warning(t('errors.outputPathRequired'))
return
}
if (!appStore.renderConfig.outputSize?.width || !appStore.renderConfig.outputSize?.height) {
toast.warning('请先配置导出分辨率(宽高)')
toast.warning(t('errors.outputSizeRequired'))
return
}
@@ -71,7 +73,7 @@ const handleRenderVideo = async () => {
}
} catch (error) {
console.log('获取背景音乐列表失败', error)
toast.error('获取背景音乐列表失败,请检查文件夹是否存在')
toast.error(t('errors.bgmListFailed'))
}
try {
@@ -92,10 +94,10 @@ const handleRenderVideo = async () => {
withCaption: true,
})
if (ttsResult?.duration === undefined) {
throw new Error('语音合成失败,音频文件损坏')
throw new Error(t('errors.ttsFailedCorrupt'))
}
if (ttsResult?.duration === 0) {
throw new Error('语音时长为0秒检查TTS语音合成配置及网络连接是否正常')
throw new Error(t('errors.ttsZeroDuration'))
}
// 获取视频片段
@@ -132,11 +134,11 @@ const handleRenderVideo = async () => {
appStore.renderConfig.outputFileExt,
})
toast.success('视频合成成功')
toast.success(t('success.renderSuccess'))
appStore.updateRenderStatus(RenderStatus.Completed)
if (appStore.autoBatch) {
toast.info('开始合成下一个')
toast.info(t('info.batchNext'))
TextGenerateInstance.value?.clearOutputText()
handleRenderVideo()
}
@@ -146,9 +148,7 @@ const handleRenderVideo = async () => {
// @ts-ignore
const errorMessage = error?.message || error?.error?.message
toast.error(
`视频合成失败,请检查各项配置是否正确\n${errorMessage ? '错误信息:“' + errorMessage + '”' : ''}`,
)
toast.error(`${t('errors.renderFailedPrefix')}${errorMessage ? '\n' + errorMessage : ''}`)
appStore.updateRenderStatus(RenderStatus.Failed)
}
}
@@ -173,7 +173,7 @@ const handleCancelRender = () => {
break
}
appStore.updateRenderStatus(RenderStatus.None)
toast.info('视频合成已终止')
toast.info(t('info.renderCanceled'))
}
</script>