43 Commits

Author SHA1 Message Date
YILS
f86ed53ccc 优化 MAC 构建配置 2025-10-22 23:56:15 +08:00
YILS
56600b3353 优化 MAC 构建配置 2025-10-22 23:43:00 +08:00
YILS
1cedfae6a7 优化 MAC 构建配置 2025-10-22 23:20:26 +08:00
YILS
b35c4420f2 优化 MAC 构建配置 2025-10-22 23:06:16 +08:00
YILS
3768010b60 优化 MAC 构建配置 2025-10-22 22:30:59 +08:00
YILS
7002e0a153 优化 MAC 构建配置 2025-10-22 22:20:43 +08:00
YILS
5bf62a7c71 更新版本 2025-10-22 22:11:22 +08:00
YILS
42c94a56f8 解决 BGM 文件夹存在非 mp3 文件时报错问题 2025-10-22 22:09:30 +08:00
YILS
7097a95d78 优化 Action Mac构建配置 2025-10-22 22:05:30 +08:00
YILS
240c0bce72 优化MAC构建配置 2025-10-22 17:39:58 +08:00
YILS
d2dfee949b 发布小版本更新,MAC嵌入ffmpeg,架构分隔 2025-10-22 17:32:34 +08:00
YILS
ff94d9679c 完善 MAC 嵌入 ffmpeg 支持,优化构建配置 2025-10-21 21:50:15 +08:00
YILS
cd68ba810a 优化i18n国际化结构 2025-10-20 11:24:48 +08:00
YILS
0f880f81bb 解决不设置bgm文件夹报错 2025-10-10 17:10:01 +08:00
YILS
53e940ea4f 修复vuetify/styles类型报错 2025-09-26 10:21:38 +08:00
YILS
b19e421a8c 调整FFmpeg二进制开发与构建逻辑,优化工程化配置 2025-09-24 17:27:48 +08:00
YILS
5a5bc3387a Update README.md 2025-09-16 11:11:25 +08:00
YILS
ef281573a1 Update README.md 2025-09-09 15:26:37 +08:00
YILS
00a75a69a6 Update README.md 2025-09-09 15:16:43 +08:00
YILS
41ee746613 Update README.md 2025-09-01 11:30:38 +08:00
YILS
3f13a62cbd Update README.md 2025-08-28 14:02:11 +08:00
YILS
fb198c1b00 修复MAC复制粘贴快捷键失效问题 2025-08-26 16:36:03 +08:00
YILS
8ea9d06efa 重构i18n,支持全局多语言切换 2025-08-23 17:35:20 +08:00
YILS
6c2733b8af 优化编辑器配置 2025-08-23 17:33:29 +08:00
YILS
e3939ae743 Merge pull request #2 from SebastianBoehler/feature/english-support-2025-08-21
feat: english support
2025-08-22 14:38:34 +08:00
Sebastian Boehler
13a45a817b feat: english support 2025-08-21 12:01:28 +02:00
YILS
b66714cb0e Update README.md 2025-08-18 11:26:10 +08:00
YILS
7f600c9dbf Update README.md 2025-08-18 11:11:48 +08:00
YILS
3696f20277 Update README.md 2025-08-18 09:33:09 +08:00
YILS
9be840e88e Update README.md 2025-08-14 13:56:56 +08:00
YILS
8608c401c9 Update README.md 2025-08-14 13:53:16 +08:00
YILS
a997766543 Update CHANGELOG.md 2025-08-12 14:19:20 +08:00
YILS
00a21aef46 修复渲染卡帧问题 2025-08-12 14:13:42 +08:00
YILS
50e3c403d0 修复混剪片段与语音时长不一致问题 2025-08-12 09:30:20 +08:00
YILS
29adaa3989 Update README.md 2025-08-12 08:54:26 +08:00
YILS
31780f4703 🎉 发布第一个1.0.0正式版 2025-08-08 14:27:38 +08:00
YILS
2cead83ddc 构建测试 2025-08-08 14:21:22 +08:00
YILS
95b1aa359f 构建测试 2025-08-08 14:02:40 +08:00
YILS
b9b656da9e 构建测试 2025-08-08 13:44:24 +08:00
YILS
a328a5aea3 构建测试 2025-08-08 13:38:27 +08:00
YILS
204468935b 构建测试 2025-08-08 10:13:34 +08:00
YILS
46c612946b 优化构建配置 2025-08-08 10:04:41 +08:00
YILS
8c77530f58 优化构建配置 2025-08-08 09:48:52 +08:00
36 changed files with 1049 additions and 195 deletions

View File

@@ -32,6 +32,7 @@ jobs:
if: matrix.os == 'macos-latest'
run: |
export ELECTRON_BUILDER_EXTRA_ARGS="--universal"
pnpm run lipo-ffmpeg
pnpm run build
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -71,22 +72,18 @@ jobs:
echo "EOF" >> $GITHUB_ENV
shell: bash
# Upload artifacts
- name: Upload artifacts
# Publish Release
- name: Publish Release
uses: softprops/action-gh-release@v2
with:
files: |
release/${env.VERSION}/*.dmg
release/${env.VERSION}/*.exe
release/${env.VERSION}/*.deb
release/${env.VERSION}/*.rpm
release/${env.VERSION}/*.AppImage
release/${env.VERSION}/latest*.yml
release/${env.VERSION}/*.blockmap
release/${{ env.VERSION }}/*.dmg
release/${{ env.VERSION }}/*.exe
release/${{ env.VERSION }}/*.deb
release/${{ env.VERSION }}/*.rpm
release/${{ env.VERSION }}/*.AppImage
body: ${{ env.NOTES }}
draft: false
prerelease: false
overwrite_files: false
append_body: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,8 +1,12 @@
{
"scss.validate": false,
"css.validate": false,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.quickSuggestions": {
"strings": "on"
},
"css.customData": [
".vscode/unocss.json"
],
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

11
.vscode/unocss.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"version": 1.1,
"atDirectives": [
{
"name": "@apply"
},
{
"name": "@screen"
}
]
}

View File

@@ -1,7 +1,42 @@
# Changelog
All significant changes to this project will be recorded in this file.
此项目的所有显著更改都将记录在此文件中。
## [v1.0.0] - 2025-08-07
## [v1.1.10] - 2025-10-22
### Added
- Add MAC embedding FFmpeg support
### Fixed
- Resolve the error of not setting BGM folder
- Resolve the issue of reporting errors when there are non mp3 files in the BGM folder
- Remove the restriction that the total duration of video materials should not be shorter than that of voice
- Optimize some details
### 添加
- 增加MAC嵌入FFmpeg支持
### 修复
- 解决不设置bgm文件夹报错
- 解决 BGM 文件夹存在非 mp3 文件时报错问题
- 移除视频素材总时长不的短于语音的限制
- 优化一些细节
## [v1.1.1] - 2025-08-26
### Fixed
- Fix MAC copy and paste shortcut key failure issue
### 修复
- 修复MAC复制粘贴快捷键失效问题
## [v1.1.0] - 2025-08-22
### Added
- Multi-Language Support
### 添加
- 多语言支持
## [v1.0.1] - 2025-08-12
### Fixed
- 修复混剪片段与语音时长不一致问题
- 修复渲染卡帧问题
## [v1.0.0] - 2025-08-08
### Added
- 发布第一个正式版本
- 支持使用大语言模型生成文案推荐免费的GLM-4.5-Flash
@@ -11,7 +46,7 @@
- 支持自动化批量任务
- 美观的UI界面
## [v0.7.5] - 2025-08-07
## [v0.7.12] - 2025-08-08
### Added
- 构建测试
- 跨平台: macOS dmg, Windows exe, Linux AppImage.
- 跨平台: macOS dmg, Windows exe, Linux AppImage.

View File

@@ -21,12 +21,15 @@
</p>
<!-- 项目徽章 -->
[![贡献者][contributors-shield]][contributors-url]
[![分支][forks-shield]][forks-url]
[![星标][stars-shield]][stars-url]
[![问题][issues-shield]][issues-url]
<!-- [![最新版本][release-shield]][release-url]
![发布日期][release-date-shield] -->
[![最新版本][release-shield]][release-url]
<!-- ![发布日期][release-date-shield] -->
[![许可证][license-shield]][license-url]
<p align="center">
@@ -37,6 +40,7 @@
</div>
<!-- 关于项目 -->
## 📖 关于项目
短视频工厂是一个开源的桌面端应用旨在通过AI技术简化短视频的制作流程。用户可以通过简单的提示词文本+视频分镜素材快速且自动的剪辑出高质量的产品营销和泛内容短视频。该项目集成了AI驱动的文案生成、语音合成、视频剪辑、字幕特效等功能旨在为用户提供开箱即用的短视频制作体验。
@@ -48,11 +52,21 @@
- 🎥 **自动剪辑**:支持多种视频格式,自动化批量处理视频剪辑任务
- 🎙️ **语音合成**:将生成的文案转换为自然流畅的语音
- 🎬 **字幕特效**:自动添加字幕和特效,提升视频质量
- 📦 **批量处理**:支持批量任务,按预设自动持续合成视频
- 🌐 **多语言支持**:支持中文、英文等多种语言,满足不同用户需求
- 📦 **开箱即用**:无需复杂配置,用户可以快速上手
- 📈 **持续更新**定期发布新版本修复bug并添加新功能
- 🔒 **安全可靠**:完全本地本地化运行,确保用户数据安全
- 🎨 **用户友好**:简洁直观的用户界面,易于操作
- 🌐 **多平台支持**支持Windows、macOS和Linux等多个操作系统
- 💻 **多平台支持**支持Windows、macOS和Linux等多个操作系统
<p align="right">(<a href="#readme-top">返回顶部</a>)</p>
## 🚀 开始使用
前往 [Github Release](https://github.com/YILS-LIN/short-video-factory/releases) 下载最新版本
前往 [官方文档](https://short-video-factory.yils.blog) 查看使用手册
<p align="right">(<a href="#readme-top">返回顶部</a>)</p>
@@ -66,14 +80,39 @@
- [x] 语音合成支持EdgeTTS
- [x] 视频剪辑,文案、视频、音频、字幕合成,自动混剪
- [x] 批量处理,支持一个批量任务,按预设自动持续合成视频
- [x] 多语言支持,能够支持中文、英文等多种语言
- [x] 完善的使用手册
- [ ] 更全面的参数调整
- [ ] 更多的语音合成API
- [ ] 字幕特效,支持多种字幕样式和特效
查看[开放问题](https://github.com/YILS-LIN/short-video-factory/issues)以获取提议功能(和已知问题)的完整列表。
<p align="right">(<a href="#readme-top">返回顶部</a>)</p>
## 🎞️ 示例视频
<table>
<thead>
<tr>
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> 《产品营销短视频》</th>
<th align="center"><g-emoji class="g-emoji" alias="arrow_forward">▶️</g-emoji> 《暖心治愈系语录》</th>
</tr>
</thead>
<tbody>
<tr>
<td align="center"><video src="https://github.com/user-attachments/assets/165a8f96-861b-4cf3-946c-444b9692cef8"></video></td>
<td align="center"><video src="https://github.com/user-attachments/assets/12694618-e0fe-4848-8a7e-98b3f3a7aece"></video></td>
</tr>
</tbody>
</table>
注:素材来源于网络,仅用于展示剪辑效果
<p align="right">(<a href="#readme-top">返回顶部</a>)</p>
<!-- 贡献 -->
## 🤝 贡献
贡献让开源社区成为了一个学习、启发和创造的绝佳场所。**非常感谢**您所做的任何贡献。
@@ -103,6 +142,7 @@
<p align="right">(<a href="#readme-top">返回顶部</a>)</p>
<!-- 许可证 -->
## 🎗 许可证
[![许可证][license-shield]][license-url]
@@ -111,7 +151,22 @@ Copyright © 2025 YILS.
<p align="right">(<a href="#readme-top">返回顶部</a>)</p>
## 🐱 捐赠
如果这个项目对你有帮助,欢迎请作者喝杯咖啡(或者啤酒) 🍺
你的 **Star ⭐****捐赠** 是我持续更新的最大动力!
<div align="left">
<img src="https://github.com/user-attachments/assets/6b832dd3-38ea-4927-9c3b-97549c77a1f0" alt="YILS的微信赞赏码" width="400">
</div>
👉 在此处查看捐赠者名单:[千古留名 - 捐赠者留言板](https://short-video-factory.yils.blog/donate/list.html)
<p align="right">(<a href="#readme-top">返回顶部</a>)</p>
<!-- 星标历史 -->
## ⭐ 星标历史
<div align="center">
@@ -120,7 +175,10 @@ Copyright © 2025 YILS.
</a>
</div>
<p align="right">(<a href="#readme-top">返回顶部</a>)</p>
<!-- MARKDOWN链接和图片 -->
[contributors-shield]: https://img.shields.io/github/contributors/YILS-LIN/short-video-factory.svg?color=c4f042&labelColor=black&style=flat-square
[contributors-url]: https://github.com/YILS-LIN/short-video-factory/graphs/contributors
[forks-shield]: https://img.shields.io/github/forks/YILS-LIN/short-video-factory.svg?color=8ae8ff&labelColor=black&style=flat-square
@@ -129,8 +187,8 @@ Copyright © 2025 YILS.
[stars-url]: https://github.com/YILS-LIN/short-video-factory/stargazers
[issues-shield]: https://img.shields.io/github/issues/YILS-LIN/short-video-factory.svg?labelColor=black&style=flat-square
[issues-url]: https://github.com/YILS-LIN/short-video-factory/issues
[release-shield]: https://img.shields.io/github/v/release/YILS-LIN/short-video-factory?style=flat-round
[release-shield]: https://img.shields.io/github/v/release/YILS-LIN/short-video-factory?labelColor=black&style=flat-square
[release-url]: https://github.com/YILS-LIN/short-video-factory/releases
[release-date-shield]: https://img.shields.io/github/release-date/YILS-LIN/short-video-factory?color=9cf&style=flat-round
[license-shield]: https://img.shields.io/github/license/YILS-LIN/short-video-factory.svg?labelColor=black&style=flat-square
[license-url]: https://github.com/YILS-LIN/short-video-factory/blob/master/LICENSE.txt
[license-url]: https://github.com/YILS-LIN/short-video-factory/blob/main/LICENSE

View File

@@ -7,12 +7,17 @@
directories: {
output: 'release/${version}',
},
files: ['dist', 'dist-electron', 'dist-native'],
files: ['dist', 'dist-electron', 'dist-native', 'locales'],
npmRebuild: false, // disable rebuild node_modules 使用包内自带预构建二进制,而不重新构建
beforePack: './scripts/before-pack.js',
mac: {
target: ['dmg'],
artifactName: '${productName}-Mac-${arch}-${version}-Installer.${ext}',
target: [
{
target: 'dmg',
arch: ['universal'],
},
],
artifactName: '${name}-${version}-mac-${arch}-installer.${ext}',
icon: './public/icon.png',
},
win: {
@@ -21,7 +26,7 @@
target: 'nsis',
},
],
artifactName: '${productName}-Windows-${arch}-${version}-Setup.${ext}',
artifactName: '${name}-${version}-win-${arch}-setup.${ext}',
icon: './public/icon.png',
},
nsis: {
@@ -33,7 +38,8 @@
},
linux: {
target: ['AppImage'],
artifactName: '${productName}-Linux-${arch}-${version}.${ext}',
artifactName: '${name}-${version}-linux-${arch}.${ext}',
icon: './public/icon.png',
},
publish: null, // disable auto publish 防止重复发版
}

View File

@@ -24,6 +24,11 @@ declare namespace NodeJS {
// 在渲染器进程中使用,在 `preload.ts` 中暴露方法
interface Window {
ipcRenderer: Pick<import('electron').IpcRenderer, 'on' | 'once' | 'off' | 'send' | 'invoke'>
i18n: {
getLocalesPath: () => Promise<string>
getLanguage: () => Promise<string>
changeLanguage: (lng: string) => Promise<string>
}
electron: {
isWinMaxed: () => Promise<boolean>
winMin: () => void

View File

@@ -8,11 +8,10 @@ import { generateUniqueFileName } from '../lib/tools'
const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
const isWindows = process.platform === 'win32'
const ffmpegPath: string = isWindows
? VITE_DEV_SERVER_URL
? require('ffmpeg-static')
: (require('ffmpeg-static') as string).replace('app.asar', 'app.asar.unpacked')
: 'ffmpeg'
const ffmpegPath: string = VITE_DEV_SERVER_URL
? require('ffmpeg-static')
: (require('ffmpeg-static') as string).replace('app.asar', 'app.asar.unpacked')
// async function test() {
// try {
@@ -86,7 +85,10 @@ export async function renderVideo(
})
// 拼接视频
filters.push(`[${videoStreams.join('][')}]concat=n=${videoFiles.length}:v=1:a=0[vout]`)
filters.push(`[${videoStreams.join('][')}]concat=n=${videoFiles.length}:v=1:a=0[vconcat]`)
// 重置时间基、帧率、色彩空间
filters.push(`[vconcat]fps=30,format=yuv420p,setpts=PTS-STARTPTS[vout]`)
// 在视频拼接后添加字幕
filters.push(`[vout]subtitles=${subtitleFile.replace(/\:/g, '\\\\:')}[with_subs]`)
@@ -122,6 +124,8 @@ export async function renderVideo(
'aac',
'-b:a',
'128k',
'-fps_mode',
'cfr',
'-s',
`${outputSize.width}x${outputSize.height}`,
'-progress',
@@ -132,6 +136,7 @@ export async function renderVideo(
)
// 打印命令
// console.log('传入参数:', params)
// console.log('执行命令:', args.join(' '))
// 执行命令

View File

@@ -0,0 +1,15 @@
import { InitOptions } from 'i18next'
export const i18nLanguages = [
{ code: 'en', name: 'English' },
{ code: 'zh-CN', name: '简体中文' },
]
export const i18nCommonOptions: InitOptions = {
fallbackLng: i18nLanguages[0].code,
supportedLngs: i18nLanguages.map((l) => l.code),
load: 'currentOnly',
ns: ['common'],
defaultNS: 'common',
interpolation: { escapeValue: false },
}

42
electron/i18n/index.ts Normal file
View File

@@ -0,0 +1,42 @@
import i18next from 'i18next'
import Backend from 'i18next-fs-backend'
import { app, BrowserWindow, ipcMain } from 'electron'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { i18nCommonOptions } from './common-options'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
process.env.APP_ROOT = path.join(__dirname, '..')
const localesPath = path.join(process.env.APP_ROOT, 'locales/{{lng}}/{{ns}}.json')
export const initI18n = async () => {
await i18next.use(Backend).init({
// initAsync: false,
// debug: true,
...i18nCommonOptions,
lng: app.getLocale(), // 获取系统语言
backend: {
loadPath: localesPath,
},
})
// 获取多语言文件路径
ipcMain.handle('i18n-getLocalesPath', () => localesPath)
// 读取当前语言
ipcMain.handle('i18n-getLanguage', () => i18next.language)
// 渲染进程切换语言
ipcMain.handle('i18n-changeLanguage', async (_, lng: string) => {
await changeAppLanguage(lng)
return lng
})
}
export const changeAppLanguage = async (lng: string) => {
await i18next.changeLanguage(lng)
BrowserWindow.getAllWindows().forEach((win) => {
win.webContents.send('i18n-changeLanguage', lng)
})
}

View File

@@ -20,7 +20,7 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
? path.join(process.env.APP_ROOT, 'public')
: RENDERER_DIST
export default function initIPC(win: BrowserWindow) {
export default function initIPC() {
// sqlite 查询
ipcMain.handle('sqlite-query', (_event, params) => sqQuery(params))
// sqlite 插入
@@ -33,15 +33,18 @@ export default function initIPC(win: BrowserWindow) {
ipcMain.handle('sqlite-bulk-insert-or-update', (_event, params) => sqBulkInsertOrUpdate(params))
// 是否最大化
ipcMain.handle('is-win-maxed', () => {
ipcMain.handle('is-win-maxed', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win?.isMaximized()
})
//最小化
ipcMain.on('win-min', () => {
ipcMain.on('win-min', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.minimize()
})
//最大化
ipcMain.on('win-max', () => {
ipcMain.on('win-max', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win?.isMaximized()) {
win?.restore()
} else {
@@ -49,7 +52,8 @@ export default function initIPC(win: BrowserWindow) {
}
})
//关闭程序
ipcMain.on('win-close', () => {
ipcMain.on('win-close', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.close()
})
@@ -59,7 +63,12 @@ export default function initIPC(win: BrowserWindow) {
})
// 选择文件夹
ipcMain.handle('select-folder', async (_event, params?: SelectFolderParams) => {
ipcMain.handle('select-folder', async (event, params?: SelectFolderParams) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) {
throw new Error('无法获取窗口')
}
const result = await dialog.showOpenDialog(win, {
properties: ['openDirectory'],
title: params?.title || '选择文件夹',

1
electron/lib/is-dev.ts Normal file
View File

@@ -0,0 +1 @@
export const isDev = !!process.env['VITE_DEV_SERVER_URL']

View File

@@ -2,8 +2,6 @@ import fs from 'node:fs'
import path from 'node:path'
import { app } from 'electron'
// import packageJson from '~/package.json'
/**
* 生成有序的唯一文件名,用于处理文件已存在的情况
*/

View File

@@ -1,9 +1,13 @@
import { app, BrowserWindow, screen } from 'electron'
import { app, BrowserWindow, screen, Menu } from 'electron'
import type { MenuItemConstructorOptions } from 'electron'
import { fileURLToPath } from 'node:url'
import { isDev } from './lib/is-dev'
import path from 'node:path'
import GlobalSetting from '../setting.global'
import initIPC from './ipc'
import { initSqlite } from './sqlite'
import i18next from 'i18next'
import { changeAppLanguage, initI18n } from './i18n'
import { i18nLanguages } from './i18n/common-options'
import useCookieAllowCrossSite from './lib/cookie-allow-cross-site'
// 用于引入 CommonJS 模块的方法
@@ -28,9 +32,7 @@ export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron')
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist')
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
? path.join(process.env.APP_ROOT, 'public')
: RENDERER_DIST
process.env.VITE_PUBLIC = isDev ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST
let win: BrowserWindow | null
@@ -38,7 +40,6 @@ function createWindow() {
const { width, height } = screen.getPrimaryDisplay().workAreaSize
win = new BrowserWindow({
icon: path.join(process.env.VITE_PUBLIC, 'icon.png'),
title: GlobalSetting.appName,
width: Math.ceil(width * 0.8),
height: Math.ceil(height * 0.8),
minWidth: 800,
@@ -69,6 +70,94 @@ function createWindow() {
}
}
function buildMenu() {
const template: MenuItemConstructorOptions[] = [
// macOS standard app menu
...(process.platform === 'darwin'
? [
{
label: i18next.t('app.name'),
submenu: [
{
label: i18next.t('menu.app.about'),
click: async () => {
const { shell } = await import('electron')
await shell.openExternal('https://github.com/YILS-LIN/short-video-factory')
},
},
{ type: 'separator' },
{ label: i18next.t('menu.app.services'), role: 'services' },
{ type: 'separator' },
{ label: i18next.t('menu.app.hide'), role: 'hide' },
{ label: i18next.t('menu.app.hideOthers'), role: 'hideOthers' },
{ label: i18next.t('menu.app.unhide'), role: 'unhide' },
{ type: 'separator' },
{ label: i18next.t('menu.app.quit'), role: 'quit' },
] as MenuItemConstructorOptions[],
},
]
: []),
{
label: i18next.t('menu.language'),
submenu: i18nLanguages.map((lng) => ({
label: lng.name,
type: 'radio',
checked: i18next.language === lng.code,
click: () => {
changeAppLanguage(lng.code)
},
})) as MenuItemConstructorOptions[],
},
{
label: i18next.t('menu.edit.root'),
submenu: [
{ label: i18next.t('menu.edit.undo'), role: 'undo' },
{ label: i18next.t('menu.edit.redo'), role: 'redo' },
{ type: 'separator' },
{ label: i18next.t('menu.edit.cut'), role: 'cut' },
{ label: i18next.t('menu.edit.copy'), role: 'copy' },
{ label: i18next.t('menu.edit.paste'), role: 'paste' },
{ label: i18next.t('menu.edit.selectAll'), role: 'selectAll' },
] as MenuItemConstructorOptions[],
},
{
label: i18next.t('menu.view.root'),
submenu: [
{ role: 'toggleDevTools', visible: false },
{ label: i18next.t('menu.view.resetZoom'), role: 'resetZoom' },
{ label: i18next.t('menu.view.zoomIn'), role: 'zoomIn' },
{ label: i18next.t('menu.view.zoomOut'), role: 'zoomOut' },
{ type: 'separator' },
{ label: i18next.t('menu.view.toggleFullscreen'), role: 'togglefullscreen' },
] as MenuItemConstructorOptions[],
},
{
label: i18next.t('menu.window.root'),
role: 'window',
submenu: [
{ label: i18next.t('menu.window.minimize'), role: 'minimize' },
{ label: i18next.t('menu.window.close'), role: 'close' },
] as MenuItemConstructorOptions[],
},
{
label: i18next.t('menu.help.root'),
role: 'help',
submenu: [
{
label: i18next.t('menu.help.learnMore'),
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。
@@ -91,9 +180,14 @@ app.on('activate', () => {
// app.disableHardwareAcceleration();
app.whenReady().then(() => {
createWindow()
initSqlite()
initIPC(win as BrowserWindow)
initI18n()
initIPC()
createWindow()
i18next.on('languageChanged', () => {
buildMenu()
})
// 允许跨站请求携带cookie
useCookieAllowCrossSite()

View File

@@ -35,6 +35,12 @@ contextBridge.exposeInMainWorld('ipcRenderer', {
},
})
contextBridge.exposeInMainWorld('i18n', {
getLocalesPath: () => ipcRenderer.invoke('i18n-getLocalesPath'),
getLanguage: () => ipcRenderer.invoke('i18n-getLanguage'),
changeLanguage: (lng: string) => ipcRenderer.invoke('i18n-changeLanguage', lng),
})
contextBridge.exposeInMainWorld('electron', {
isWinMaxed: () => ipcRenderer.invoke('is-win-maxed'),
winMin: () => ipcRenderer.send('win-min'),

147
locales/en/common.json Normal file
View File

@@ -0,0 +1,147 @@
{
"app": {
"name": "AI Short Video Factory"
},
"menu": {
"app": {
"about": "About",
"services": "Services",
"hide": "Hide",
"hideOthers": "Hide Others",
"unhide": "Unhide",
"quit": "Quit"
},
"language": "Language",
"edit": {
"root": "Edit",
"undo": "Undo",
"redo": "Redo",
"cut": "Cut",
"copy": "Copy",
"paste": "Paste",
"selectAll": "Select All"
},
"view": {
"root": "View",
"resetZoom": "Reset Zoom",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"toggleFullscreen": "Toggle Full Screen"
},
"window": {
"root": "Window",
"minimize": "Minimize",
"close": "Close"
},
"help": {
"root": "Help",
"learnMore": "Learn More"
}
},
"common": {
"close": "Close",
"test": "Test",
"save": "Save",
"select": "Select",
"noData": "No data"
},
"actions": {
"generate": "Generate",
"stop": "Stop",
"config": "Configure",
"refreshAssets": "Refresh Assets"
},
"dialogs": {
"selectAssetsFolderTitle": "Select storyboard assets folder",
"selectOutputFolderTitle": "Select video export folder",
"selectBgmFolderTitle": "Select background music folder",
"renderConfigTitle": "Configure render options"
},
"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"
},
"llm": {
"promptLabel": "Prompt",
"outputLabel": "Output Text (editable)",
"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"
},
"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"
},
"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"
},
"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"
}
},
"footer": {
"poweredBy": "Powered by YILS (Blog: https://yils.blog)"
}
}

147
locales/zh-CN/common.json Normal file
View File

@@ -0,0 +1,147 @@
{
"app": {
"name": "AI Short Video Factory - 短视频工厂"
},
"menu": {
"app": {
"about": "关于",
"services": "服务",
"hide": "隐藏",
"hideOthers": "隐藏其他",
"unhide": "取消隐藏",
"quit": "退出"
},
"language": "语言",
"edit": {
"root": "编辑",
"undo": "撤销",
"redo": "重做",
"cut": "剪切",
"copy": "复制",
"paste": "粘贴",
"selectAll": "全选"
},
"view": {
"root": "视图",
"resetZoom": "重置缩放",
"zoomIn": "放大",
"zoomOut": "缩小",
"toggleFullscreen": "切换全屏"
},
"window": {
"root": "窗口",
"minimize": "最小化",
"close": "关闭"
},
"help": {
"root": "帮助",
"learnMore": "了解更多"
}
},
"common": {
"close": "关闭",
"test": "测试",
"save": "保存",
"select": "选择",
"noData": "无数据"
},
"actions": {
"generate": "生成",
"stop": "停止",
"config": "配置",
"refreshAssets": "刷新素材库"
},
"dialogs": {
"selectAssetsFolderTitle": "选择分镜素材文件夹",
"selectOutputFolderTitle": "选择视频导出文件夹",
"selectBgmFolderTitle": "选择背景音乐文件夹",
"renderConfigTitle": "配置合成选项"
},
"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": "从上面选择一个包含足够分镜素材的文件夹"
},
"llm": {
"promptLabel": "提示词",
"outputLabel": "输出文案(可编辑)",
"configTitle": "配置大语言模型接口",
"modelName": "模型名称",
"apiUrl": "API 地址",
"apiKey": "API Key",
"compatibleNote": "兼容任意 OpenAI 标准接口",
"connectSuccess": "大模型连接成功",
"connectFailedPrefix": "大模型连接失败,请检查配置是否正确"
},
"videoManage": {
"assetsFolderLabel": "分镜视频素材文件夹",
"noMp4InFolder": "选择的文件夹中不包含MP4视频文件",
"emptyFolder": "选择的文件夹为空",
"readSuccess": "素材读取成功",
"readFailed": "素材读取失败,请检查文件夹是否存在"
},
"tts": {
"language": "语言",
"gender": "性别",
"voice": "声音",
"speed": "语速",
"tryText": "试听文本",
"tryListen": "试听",
"selectLanguageGenderFirst": "请先选择语言和性别",
"selectVoiceWarning": "请选择一个声音",
"tryTextEmptyWarning": "试听文本不能为空",
"playTryAudio": "播放试听语音",
"trySynthesisFailedNetwork": "试听语音合成失败,请检查网络",
"genderMale": "男性",
"genderFemale": "女性",
"speedSlow": "慢",
"speedMedium": "中",
"speedFast": "快"
},
"render": {
"status": {
"idle": "空闲,可以开始合成",
"generatingText": "正在使用 AI 大模型生成文案",
"synthesizingSpeech": "正在使用 TTS 合成语音",
"segmentingVideo": "正在处理分镜素材",
"rendering": "正在渲染视频",
"success": "渲染成功,可以开始下一个",
"failed": "渲染失败,请重新尝试"
},
"startRender": "开始合成",
"stopRender": "停止合成",
"autoBatch": "自动批量合成",
"bgmFolderLabel": "背景音乐文件夹(随机选取.mp3为空则无背景音",
"output": {
"width": "导出视频宽度",
"height": "导出视频高度",
"fileName": "导出文件名",
"format": "导出格式",
"folder": "导出文件夹"
}
},
"footer": {
"poweredBy": "Powered by YILS博客地址https://yils.blog"
}
}

View File

@@ -1,14 +1,14 @@
{
"name": "short-video-factory",
"description": "短视频工厂一键生成产品营销与泛内容短视频AI批量自动剪辑",
"version": "0.7.5",
"version": "1.1.10",
"author": {
"name": "YILS",
"developer": "YILS",
"email": "yils_lin@163.com",
"url": "https://yils.blog/"
},
"repository": "https://github.com/YILS-LIN/short-video-factory",
"homepage": "https://github.com/YILS-LIN/short-video-factory",
"main": "dist-electron/main.js",
"scripts": {
"dev": "cross-env VITE_CJS_IGNORE_WARNING=true vite",
@@ -16,12 +16,15 @@
"preview": "vite preview",
"format": "prettier --write .",
"preinstall": "npx only-allow pnpm",
"postinstall": "node scripts/post-install.js"
"postinstall": "node scripts/post-install.js",
"lipo-ffmpeg":"node scripts/lipo-ffmpeg.js"
},
"dependencies": {
"axios": "^1.11.0",
"better-sqlite3": "9.6.0",
"ffmpeg-static": "^5.2.0",
"i18next": "^25.4.0",
"i18next-fs-backend": "^2.3.2",
"music-metadata": "^11.7.3",
"subtitle": "4.2.2-alpha.0",
"ws": "^8.18.3"
@@ -39,6 +42,8 @@
"cross-env": "^7.0.3",
"electron": "^22.3.27",
"electron-builder": "^24.13.3",
"i18next-http-backend": "^3.0.2",
"i18next-vue": "^5.3.0",
"mitt": "^3.0.1",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.4.1",
@@ -57,6 +62,20 @@
"vue-tsc": "3.0.1",
"vuetify": "^3.9.0"
},
"pnpm": {
"ignoredBuiltDependencies": [
"better-sqlite3",
"ffmpeg-static"
],
"onlyBuiltDependencies": [
"@parcel/watcher",
"bufferutil",
"electron",
"esbuild",
"utf-8-validate",
"vue-demi"
]
},
"packageManager": "pnpm@10.12.4",
"engines": {
"node": ">=22.17.0",

97
pnpm-lock.yaml generated
View File

@@ -17,6 +17,12 @@ importers:
ffmpeg-static:
specifier: ^5.2.0
version: 5.2.0
i18next:
specifier: ^25.4.0
version: 25.4.0(typescript@5.6.2)
i18next-fs-backend:
specifier: ^2.3.2
version: 2.3.2
music-metadata:
specifier: ^11.7.3
version: 11.7.3
@@ -63,6 +69,12 @@ importers:
electron-builder:
specifier: ^24.13.3
version: 24.13.3(electron-builder-squirrel-windows@24.13.3)
i18next-http-backend:
specifier: ^3.0.2
version: 3.0.2
i18next-vue:
specifier: ^5.3.0
version: 5.3.0(i18next@25.4.0(typescript@5.6.2))(vue@3.5.17(typescript@5.6.2))
mitt:
specifier: ^3.0.1
version: 3.0.1
@@ -293,6 +305,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.28.3':
resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==}
engines: {node: '>=6.9.0'}
'@babel/template@7.27.2':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
@@ -1365,6 +1381,9 @@ packages:
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
hasBin: true
cross-fetch@4.0.0:
resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1809,6 +1828,26 @@ packages:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'}
i18next-fs-backend@2.3.2:
resolution: {integrity: sha512-LIwUlkqDZnUI8lnUxBnEj8K/FrHQTT/Sc+1rvDm9E8YvvY5YxzoEAASNx+W5M9DfD5s77lI5vSAFWeTp26B/3Q==}
i18next-http-backend@3.0.2:
resolution: {integrity: sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==}
i18next-vue@5.3.0:
resolution: {integrity: sha512-X5gYF1R9FadUdRyIze6p+mU4+kztIRWb1SYeoegB0eFBt/lDA++i0A235enWq5qdrRpWZIHlLV8gd/D5xakOsw==}
peerDependencies:
i18next: '>=23'
vue: ^3.4.38
i18next@25.4.0:
resolution: {integrity: sha512-UH5aiamXsO3cfrZFurCHiB6YSs3C+s+XY9UaJllMMSbmaoXILxFgqDEZu4NbfzJFjmUo3BNMa++Rjkr3ofjfLw==}
peerDependencies:
typescript: ^5
peerDependenciesMeta:
typescript:
optional: true
iconv-corefoundation@1.1.7:
resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==}
engines: {node: ^8.11.2 || >=10}
@@ -2137,6 +2176,15 @@ packages:
node-fetch-native@1.6.6:
resolution: {integrity: sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
@@ -2582,6 +2630,9 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
truncate-utf8-bytes@1.0.2:
resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==}
@@ -2794,6 +2845,12 @@ packages:
webpack-plugin-vuetify:
optional: true
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -3083,6 +3140,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/runtime@7.28.3': {}
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
@@ -4256,6 +4315,12 @@ snapshots:
dependencies:
cross-spawn: 7.0.6
cross-fetch@4.0.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -4789,6 +4854,25 @@ snapshots:
human-signals@8.0.1: {}
i18next-fs-backend@2.3.2: {}
i18next-http-backend@3.0.2:
dependencies:
cross-fetch: 4.0.0
transitivePeerDependencies:
- encoding
i18next-vue@5.3.0(i18next@25.4.0(typescript@5.6.2))(vue@3.5.17(typescript@5.6.2)):
dependencies:
i18next: 25.4.0(typescript@5.6.2)
vue: 3.5.17(typescript@5.6.2)
i18next@25.4.0(typescript@5.6.2):
dependencies:
'@babel/runtime': 7.28.3
optionalDependencies:
typescript: 5.6.2
iconv-corefoundation@1.1.7:
dependencies:
cli-truncate: 2.1.0
@@ -5067,6 +5151,10 @@ snapshots:
node-fetch-native@1.6.6: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-gyp-build@4.8.4:
optional: true
@@ -5538,6 +5626,8 @@ snapshots:
totalist@3.0.1: {}
tr46@0.0.3: {}
truncate-utf8-bytes@1.0.2:
dependencies:
utf8-byte-length: 1.0.5
@@ -5743,6 +5833,13 @@ snapshots:
optionalDependencies:
typescript: 5.6.2
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which@2.0.2:
dependencies:
isexe: 2.0.0

View File

@@ -1,11 +0,0 @@
ignoredBuiltDependencies:
- better-sqlite3
- ffmpeg-static
onlyBuiltDependencies:
- '@parcel/watcher'
- bufferutil
- electron
- esbuild
- utf-8-validate
- vue-demi

48
scripts/lipo-ffmpeg.js Normal file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env node
/**
* 1. 设置环境变量 FFMPEG_BINARIES_URL
* 2. 分别 rebuild x64 / arm64 两个架构
* 3. 用 lipo 合并为通用二进制
*/
const { execSync } = require('child_process')
const path = require('path')
// 1. 环境变量
const FFMPEG_BINARIES_URL = process.env['npm_config_ffmpeg_binaries_url']
// 2. 工具函数:执行命令并带彩色输出
function run(cmd, opts = {}) {
console.log(`\n${cmd}`)
try {
execSync(cmd, { stdio: 'inherit', shell: true, ...opts })
} catch (e) {
console.error(`❌ 命令失败: ${cmd}`)
process.exit(1)
}
}
// 3. 开始干活
const archs = ['x64', 'arm64']
const ffmpegStaticDir = path.join(__dirname, '..', 'node_modules', 'ffmpeg-static')
// 移除原来的二进制文件
run('rm -f ffmpeg', { cwd: ffmpegStaticDir })
// 获取两种架构的二进制文件
archs.forEach((arch) => {
run(
`pnpm cross-env FFMPEG_BINARIES_URL=${FFMPEG_BINARIES_URL} npm_config_arch=${arch} npm run install`,
{
cwd: ffmpegStaticDir,
},
)
run(`mv ${ffmpegStaticDir}/ffmpeg ${ffmpegStaticDir}/ffmpeg-${arch}`)
})
// 合并
run('lipo -create ffmpeg-arm64 ffmpeg-x64 -output ffmpeg', { cwd: ffmpegStaticDir })
// 赋权
run('chmod 0755 ffmpeg', { cwd: ffmpegStaticDir })
console.log('\n✅ 通用 ffmpeg 已生成:', path.join(ffmpegStaticDir, 'ffmpeg'))

View File

@@ -1,22 +1,18 @@
const { execSync } = require('node:child_process')
const ffmpeg = require('ffmpeg-static')
const path = require('node:path')
const fs = require('node:fs')
const isWindows = process.platform === 'win32'
console.log('Downloading ffmpeg...')
execSync(
`cross-env FFMPEG_BINARIES_URL=${process.env['npm_config_ffmpeg_binaries_url']} npm rebuild ffmpeg-static`,
)
console.log(`FFmpeg downloaded to path: ${ffmpeg}`)
if (isWindows) {
console.log('Windows detected, running install for ffmpeg-static...')
try {
// 进入 ffmpeg-static 目录并运行其构建脚本
execSync('npm explore ffmpeg-static -- pnpm run install ', {
cwd: process.cwd(),
stdio: 'inherit',
})
console.log('ffmpeg-static installed successfully.')
} catch (error) {
console.error('Failed to install ffmpeg-static:', error)
process.exit(1)
}
} else {
console.log('Not Windows, skipping install ffmpeg-static.')
const isWindows = process.platform === 'win32'
if (!isWindows) {
console.log('Not Windows, need to set ffmpeg permissions.')
fs.chmodSync(ffmpeg, 0o755)
execSync(`chmod +x ${ffmpeg}`)
console.log('FFmpeg permissions already set.')
}

View File

@@ -1,3 +0,0 @@
export default {
appName: 'AI Short Video Factory - 短视频工厂',
}

View File

@@ -2,9 +2,35 @@
<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="window-no-drag">
<v-menu location="bottom right">
<template v-slot:activator="{ props }">
<div class="control-btn control-btn-translate" v-bind="props">
<v-icon icon="mdi-translate" size="small" />
</div>
</template>
<v-list
class="p-2 space-y-1"
activatable
:activated="i18next.language"
@update:activated="handleChangeLanguage"
>
<v-list-item
v-for="(item, index) in i18nLanguages"
:key="index"
:value="item.code"
color="primary"
density="compact"
rounded
>
<v-list-item-title>{{ item.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<div class="control-btn control-btn-min" @click="handleMin">
<v-icon icon="mdi-window-minimize" size="small" />
</div>
@@ -21,13 +47,27 @@
</template>
<script lang="ts" setup>
import GlobalSetting from '../../setting.global'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { useTranslation } from 'i18next-vue'
import { i18nLanguages } from '~/electron/i18n/common-options'
const { i18next, t } = useTranslation()
// const lang = ref(i18next.language)
// console.log('i18next.language', i18next.language)
document.title = t('app.name')
const route = useRoute()
const windowIsMaxed = ref(false)
const handleChangeLanguage = (lng: unknown) => {
console.log('handleChangeLanguage', lng)
if ((lng as string[])[0]) {
window.i18n.changeLanguage((lng as string[])[0])
}
}
window.addEventListener('resize', async () => {
windowIsMaxed.value = await window.electron.isWinMaxed()
})

27
src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,27 @@
import { useAppStore } from '@/store'
import i18next from 'i18next'
import Backend from 'i18next-http-backend'
import { toRaw } from 'vue'
import { i18nCommonOptions } from '~/electron/i18n/common-options'
const i18nInitialized = async () => {
const appStore = useAppStore()
if (appStore.locale) {
await window.i18n.changeLanguage(toRaw(appStore.locale))
} else {
const systemLocale = await window.i18n.getLanguage()
appStore.updateLocale(systemLocale)
}
return i18next.use(Backend).init({
// debug: true,
...i18nCommonOptions,
lng: appStore.locale,
backend: {
loadPath: 'file:///' + (await window.i18n.getLocalesPath()),
},
})
}
export const i18n = i18next
export default i18nInitialized

View File

@@ -1,4 +1,4 @@
import 'vuetify/styles'
import 'vuetify/styles/main.sass'
import '@mdi/font/css/materialdesignicons.css'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
@@ -12,11 +12,14 @@ import 'virtual:uno.css'
import './assets/base.scss'
import { createApp } from 'vue'
import GlobalSetting from '../setting.global'
import router from './router/index.ts'
import store from './store/index.ts'
import store, { useAppStore } from './store/index.ts'
import App from './App.vue'
import i18next from 'i18next'
import I18NextVue from 'i18next-vue'
import i18nInitialized from './lib/i18n.ts'
const vuetify = createVuetify({
components,
directives,
@@ -29,8 +32,6 @@ const vuetify = createVuetify({
},
})
document.title = GlobalSetting.appName
const app = createApp(App)
app.use(vuetify)
@@ -38,9 +39,19 @@ app.use(Toast, { position: 'bottom-left', pauseOnFocusLoss: false } as PluginOpt
app.use(router)
app.use(store)
app.mount('#app').$nextTick(() => {
// Use contextBridge
window.ipcRenderer.on('main-process-message', (_event, message) => {
console.log(message)
// 初始化并应用国际化
i18nInitialized().then(() => {
app.use(I18NextVue, { i18next })
app.mount('#app').$nextTick(() => {
// 测试消息
window.ipcRenderer.on('main-process-message', (_event, message) => {
console.log(message)
})
// 监听主进程切换语言
window.ipcRenderer.on('i18n-changeLanguage', (_event, lng) => {
i18next.changeLanguage(lng)
useAppStore().updateLocale(lng)
})
})
})

View File

@@ -15,6 +15,12 @@ export enum RenderStatus {
export const useAppStore = defineStore(
'app',
() => {
// 国际化区域设置
const locale = ref('')
const updateLocale = (newLocale: string) => {
locale.value = newLocale
}
// 大模型文案生成
const prompt = ref('')
const llmConfig = ref({
@@ -72,6 +78,9 @@ export const useAppStore = defineStore(
}
return {
locale,
updateLocale,
prompt,
llmConfig,
updateLLMConfig,

View File

@@ -5,7 +5,7 @@
<v-textarea
class="h-full"
v-model="appStore.prompt"
label="提示词"
:label="t('llm.promptLabel')"
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,55 @@
: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 +90,7 @@
<v-textarea
class="h-full"
v-model="outputText"
label="输出文案(可编辑)"
:label="t('llm.outputLabel')"
counter
persistent-counter
no-resize
@@ -102,9 +106,11 @@ import { nextTick, ref, toRaw } from 'vue'
import { createOpenAI } from '@ai-sdk/openai'
import { generateText, streamText } from 'ai'
import { useToast } from 'vue-toastification'
import { useTranslation } from 'i18next-vue'
const toast = useToast()
const appStore = useAppStore()
const { t } = useTranslation()
defineProps<{
disabled?: boolean
@@ -116,8 +122,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({
@@ -150,7 +156,7 @@ const handleGenerate = async (oprions?: { noToast?: boolean }) => {
const errorMessage = error?.message || error?.error?.message
!oprions?.noToast &&
toast.error(
`生成失败,请检查大模型配置是否正确\n${errorMessage ? '错误信息:“' + errorMessage + '”' : ''}`,
`${t('errors.generateFailedPrefix')}\n${errorMessage ? 'Error: ' + errorMessage : ''}`,
)
throw error
}
@@ -197,15 +203,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>
@@ -60,9 +60,11 @@
import { ref, computed, onMounted } from 'vue'
import { useAppStore } from '@/store'
import { useToast } from 'vue-toastification'
import { useTranslation } from 'i18next-vue'
const toast = useToast()
const appStore = useAppStore()
const { t } = useTranslation()
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 { useTranslation } from 'i18next-vue'
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 } = useTranslation()
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
}
@@ -128,11 +130,19 @@ refreshAssets()
// 获取视频分镜随机素材片段
const videoInfoList = ref<VideoInfo[]>([])
const getVideoSegments = (options: { duration: number }) => {
// 判断素材库是否满足时长要求
if (videoInfoList.value.reduce((pre, cur) => pre + cur.duration, 0) < options.duration) {
throw new Error('素材总时长不足')
// 视频资源总时长
const videeAssetsTotalDuration = videoInfoList.value.reduce((pre, cur) => pre + cur.duration, 0)
// 判断素材库视频时长是否过短
if (videeAssetsTotalDuration < 1) {
throw new Error(t('errors.assetsDurationInsufficient'))
}
// 判断素材库是否满足TTS时长要求
// if (videeAssetsTotalDuration < options.duration) {
// throw new Error(t('errors.assetsDurationInsufficient'))
// }
// 搜集随机素材片段
const segments: Pick<RenderVideoParams, 'videoFiles' | 'timeRanges'> = {
videoFiles: [],
@@ -153,7 +163,7 @@ const getVideoSegments = (options: { duration: number }) => {
// 获取一个随机素材以及相关信息
const randomAsset = random.choice(tempVideoAssets)!
const randomAssetIndex = tempVideoAssets.indexOf(randomAsset)
const randomAssetIndex = videoAssets.value.findIndex((asset) => asset.path === randomAsset.path)
const randomAssetInfo = videoInfoList.value[randomAssetIndex]
// 删除已选素材
@@ -180,8 +190,8 @@ const getVideoSegments = (options: { duration: number }) => {
// 处理最后一个片段时长小于最小片段时长情况
if (options.duration - currentTotalDuration - randomSegmentDuration < minSegmentDuration) {
if (randomSegmentDuration + minSegmentDuration < randomAssetInfo.duration) {
randomSegmentDuration += minSegmentDuration
if (options.duration - currentTotalDuration < randomAssetInfo.duration) {
randomSegmentDuration = options.duration - currentTotalDuration
}
}

View File

@@ -1,28 +1,30 @@
<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 +48,7 @@
prepend-icon="mdi-rocket-launch"
@click="emit('renderVideo')"
>
开始合成
{{ t('render.startRender') }}
</v-btn>
<v-btn
v-else
@@ -55,31 +57,36 @@
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 +95,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 +103,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 +114,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 +131,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 +154,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 +165,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 +173,11 @@
<script lang="ts" setup>
import { ref, toRaw, nextTick, computed } from 'vue'
import { useTranslation } from 'i18next-vue'
import { RenderStatus, useAppStore } from '@/store'
const appStore = useAppStore()
const { t } = useTranslation()
const emit = defineEmits<{
(e: 'renderVideo'): void
@@ -206,7 +215,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 +225,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)
@@ -226,7 +235,7 @@ const handleSelectBgmFolder = async () => {
}
const handleOpenHomePage = () => {
window.electron.openExternal({ url: 'https://yils.blog' })
window.electron.openExternal({ url: 'https://yils.blog/?ref=short-video-factory' })
}
</script>

View File

@@ -36,12 +36,14 @@ import VideoRender from './components/video-render.vue'
import { ref } from 'vue'
import { RenderStatus, useAppStore } from '@/store'
import { useTranslation } from 'i18next-vue'
import { useToast } from 'vue-toastification'
import { ListFilesFromFolderRecord } from '~/electron/types'
import random from 'random'
const toast = useToast()
const appStore = useAppStore()
const { t } = useTranslation()
// 渲染合成视频
const TextGenerateInstance = ref<InstanceType<typeof TextGenerate> | null>()
@@ -49,29 +51,31 @@ 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
}
let randomBgm: ListFilesFromFolderRecord | undefined = undefined
try {
const bgmList = await window.electron.listFilesFromFolder({
folderPath: appStore.renderConfig.bgmPath.replace(/\\/g, '/'),
})
if (bgmList.length > 0) {
randomBgm = random.choice(bgmList)
if (appStore.renderConfig.bgmPath) {
try {
const bgmList = (await window.electron.listFilesFromFolder({
folderPath: appStore.renderConfig.bgmPath.replace(/\\/g, '/'),
})).filter((asset) => asset.name.endsWith('.mp3'))
if (bgmList.length > 0) {
randomBgm = random.choice(bgmList)
}
} catch (error) {
console.log('获取背景音乐列表失败', error)
toast.error(t('errors.bgmListFailed'))
}
} catch (error) {
console.log('获取背景音乐列表失败', error)
toast.error('获取背景音乐列表失败,请检查文件夹是否存在')
}
try {
@@ -92,10 +96,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 +136,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 +150,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 +175,7 @@ const handleCancelRender = () => {
break
}
appStore.updateRenderStatus(RenderStatus.None)
toast.info('视频合成已终止')
toast.info(t('info.renderCanceled'))
}
</script>

View File

@@ -17,7 +17,6 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"~/*": ["./*"]

View File

@@ -6,7 +6,6 @@
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"~/*": ["./*"]

View File

@@ -24,7 +24,7 @@ export default defineConfig({
position: 'absolute',
top: '0',
right: '0',
width: '120px',
width: '168px',
height: '35px',
'-webkit-app-region': 'no-drag',
},

View File

@@ -42,6 +42,7 @@ export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'~': fileURLToPath(new URL('./', import.meta.url)),
},
},
build: {