Skip to content

Commit

Permalink
feat: support audio and subtitle for translate (#39)
Browse files Browse the repository at this point in the history
* feat: support audio and subtitle for translate

* feat: storing task lists via ipc

---------

Co-authored-by: linxiaodong <[email protected]>
  • Loading branch information
buxuku and linxiaodong authored Sep 29, 2024
1 parent 59b4fb9 commit c7be724
Show file tree
Hide file tree
Showing 18 changed files with 263 additions and 180 deletions.
54 changes: 39 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,46 @@
## 💥特性

它保留了之前 [VideoSubtitleGenerator](https://github.com/buxuku/VideoSubtitleGenerator) 这个命令行工具的全部特性
它保留了之前 [VideoSubtitleGenerator](https://github.com/buxuku/VideoSubtitleGenerator) 这个命令行工具的全部特性,并新增了以下功能:

- 图形用户界面,操作更加便捷
- 源语言字幕文件和目标语言字幕文件放在视频同目录下,方便播放时任意挂载字幕文件
- 批量处理目录下面的所有视频文件
- 可以只生成字幕,不翻译,方便批量为视频生成字幕
- 支持火山引擎翻译
- 支持百度翻译
- 支持 deeplx 翻译 (批量翻译容易存在被限流的情况)
- 支持本地模型 ollama 翻译
- 支持 OpenAI 风格 API 翻译 如 [deepseek](https://platform.deepseek.com/)
- 批量处理视频/音频/字幕文件
- 支持视频/音频生成字幕
- 支持对生成的字幕,或者导入的字幕进行翻译
- 支持多种翻译服务:
- 火山引擎翻译
- 百度翻译
- DeepLX 翻译 (批量翻译容易存在被限流的情况)
- 本地模型 Ollama 翻译
- 支持 OpenAI 风格 API 翻译,如 DeepSpeed 等
- 自定义字幕文件名,方便兼容不同的播放器挂载字幕识别
- 自定义翻译后的字幕文件内容,纯翻译结果,原字幕+翻译结果
- 项目集成 `whisper.cpp` 它对 apple silicon 进行了优化,有较快的生成速度
- 项目集成了 `fluent-ffmpeg`, 无须安装 `ffmpeg`
- 自定义翻译后的字幕文件内容,支持纯翻译结果或原字幕+翻译结果
- 项目集成 `whisper.cpp`对 Apple Silicon 进行了优化,有较快的生成速度
- 项目集成了 `fluent-ffmpeg`,无须单独安装 `ffmpeg`
- 支持运行本地安装的 `whisper` 命令
- 支持选择模型下载源(国内镜像源或官方源)
- 支持自定义并发任务数量


## 翻译服务

本项目的翻译能力是基于 **百度/火山/deeplx** 的翻译API来实现的,这些 API 的使用需要申请对的 KEY 和 SECRET, 因此,如果你需要使用到翻译服务,需要先申请一个 API 。
本项目支持多种翻译服务,包括百度翻译、火山引擎翻译、DeepLX、Ollama 本地模型以及 OpenAI 风格的 API。使用这些服务需要相应的 API 密钥或配置

具体的申请方法,可以参考 https://bobtranslate.com/service/ ,感谢 [Bob](https://bobtranslate.com/) 这款优秀的软件
对于百度翻译、火山引擎等服务的 API 申请方法,可以参考 https://bobtranslate.com/service/ ,感谢 [Bob](https://bobtranslate.com/) 这款优秀的软件提供的信息

## 🔦使用 (普通用户)

前往 [release](https://github.com/buxuku/video-subtitle-master/releases) 页面根据自己的操作系统下载安装包,安装后即可直接使用
1. 前往 [release](https://github.com/buxuku/video-subtitle-master/releases) 页面根据自己的操作系统下载安装包
2. 安装并运行程序
3. 在程序中配置所需的翻译服务
4. 选择要处理的视频文件或字幕文件
5. 设置相关参数(如源语言、目标语言、模型等)
6. 开始处理任务

## 🔦使用 (开发用户)

1️⃣ 克隆本项目到本地
1️⃣ 克隆本项目在本地

```shell
Expand All @@ -49,4 +61,16 @@ cd video-subtitle-master
yarn install
```

3️⃣ 依赖包安装好之后,执行 `yarn start` 或者 `npm start`
3️⃣ 依赖包安装好之后,执行 `yarn start` 或者 `npm start` 启动项目

```shell
yarn start
```

## 贡献

欢迎提交 Issue 和 Pull Request 来帮助改进这个项目!

## 许可证

本项目采用 MIT 许可证。详情请见 [LICENSE](LICENSE) 文件。
2 changes: 2 additions & 0 deletions main/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { setupIpcHandlers } from './helpers/ipcHandler';
import { setupTaskProcessor } from './helpers/taskProcessor';
import { setupSystemInfoManager } from './helpers/systemInfoManager';
import { setupStoreHandlers } from './helpers/storeManager';
import { setupTaskManager } from "./helpers/taskManager";

const isProd = process.env.NODE_ENV === "production";

Expand Down Expand Up @@ -37,6 +38,7 @@ if (isProd) {
setupIpcHandlers(mainWindow);
setupTaskProcessor(mainWindow);
setupSystemInfoManager(mainWindow);
setupTaskManager();
})();

app.on("window-all-closed", () => {
Expand Down
142 changes: 64 additions & 78 deletions main/helpers/fileProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,100 +6,86 @@ import { extractAudio } from './ffmpeg';
import translate from './translate';
import { renderTemplate, isWin32, getExtraResourcesPath } from './utils';

export async function processFile(event, file, formData, hasOpenAiWhisper, provider) {
const {
model,
sourceLanguage,
targetLanguage,
sourceSrtSaveFileName,
translateProvider,
saveSourceSrt,
} = formData || {};
async function extractAudioFromVideo(event, file, filePath, audioFile) {
event.sender.send("taskStatusChange", file, "extractAudio", "loading");
await extractAudio(filePath, audioFile);
event.sender.send("taskStatusChange", file, "extractAudio", "done");
}

async function generateSubtitle(event, file, audioFile, srtFile, formData, hasOpenAiWhisper) {
const { model, sourceLanguage } = formData;
const userPath = app.getPath("userData");
const whisperPath = path.join(userPath, "whisper.cpp/");
const whisperModel = model?.toLowerCase();

let mainPath = `${whisperPath}main`;
if (isWin32()) {
mainPath = path.join(getExtraResourcesPath(), "whisper-bin-x64", "main.exe");
}

let runShell = `"${mainPath}" -m "${whisperPath}models/ggml-${whisperModel}.bin" -f "${audioFile}" -osrt -of "${srtFile}" -l ${sourceLanguage}`;
if (hasOpenAiWhisper) {
runShell = `whisper "${audioFile}" --model ${whisperModel} --device cuda --output_format srt --output_dir ${path.dirname(srtFile)} --language ${sourceLanguage}`;
}

event.sender.send("taskStatusChange", file, "extractSubtitle", "loading");

await new Promise((resolve, reject) => {
exec(runShell, (error, stdout, stderr) => {
if (error) {
reject(error);
return;
}
event.sender.send("taskStatusChange", file, "extractSubtitle", "done");
resolve(1);
});
});

return `${srtFile}.srt`;
}

async function translateSubtitle(event, file, directory, fileName, srtFile, formData, provider) {
event.sender.send("taskStatusChange", file, "translateSubtitle", "loading");
await translate(event, directory, fileName, srtFile, formData, provider);
event.sender.send("taskStatusChange", file, "translateSubtitle", "done");
}

export async function processFile(event, file, formData, hasOpenAiWhisper, provider) {
const { sourceLanguage, targetLanguage, sourceSrtSaveFileName, translateProvider, saveSourceSrt } = formData || {};

try {
const { filePath } = file;
let directory = path.dirname(filePath);
let fileName = path.basename(filePath, path.extname(filePath));
const audioFile = path.join(directory, `${fileName}.wav`);
const templateData = {
fileName,
sourceLanguage,
targetLanguage,
};
const srtFile = path.join(
directory,
`${renderTemplate(
saveSourceSrt ? sourceSrtSaveFileName : "${fileName}-temp",
templateData,
)}`,
);
const whisperModel = model?.toLowerCase();
const fileExtension = path.extname(filePath).toLowerCase();

// 提取音频
event.sender.send("taskStatusChange", file, "extractAudio", "loading");
await extractAudio(filePath, audioFile);
event.sender.send("taskStatusChange", file, "extractAudio", "done");
const isSubtitleFile = ['.srt', '.vtt', '.ass', '.ssa'].includes(fileExtension);
let srtFile = filePath;

// 生成字幕
let mainPath = `${whisperPath}main`;
if (isWin32()) {
mainPath = path.join(
getExtraResourcesPath(),
"whisper-bin-x64",
"main.exe",
if (!isSubtitleFile) {
const audioFile = path.join(directory, `${fileName}_temp.wav`);
const templateData = { fileName, sourceLanguage, targetLanguage };
srtFile = path.join(
directory,
`${renderTemplate(saveSourceSrt ? sourceSrtSaveFileName : "${fileName}-temp", templateData)}`,
);

await extractAudioFromVideo(event, file, filePath, audioFile);
srtFile = await generateSubtitle(event, file, audioFile, srtFile, formData, hasOpenAiWhisper);
} else {
// 如果是字幕文件,可能需要进行格式转换
event.sender.send("taskStatusChange", file, "prepareSubtitle", "loading");
// 这里可以添加字幕格式转换的逻辑,如果需要的话
event.sender.send("taskStatusChange", file, "prepareSubtitle", "done");
}
let runShell = `"${mainPath}" -m "${whisperPath}models/ggml-${whisperModel}.bin" -f "${audioFile}" -osrt -of "${srtFile}" -l ${sourceLanguage}`;
if (hasOpenAiWhisper) {
runShell = `whisper "${audioFile}" --model ${whisperModel} --device cuda --output_format srt --output_dir ${directory} --language ${sourceLanguage}`;
}
event.sender.send("taskStatusChange", file, "extractSubtitle", "loading");

await new Promise((resolve, reject) => {
exec(runShell, async (error, stdout, stderr) => {
if (error) {
reject(error);
return;
}
event.sender.send("taskStatusChange", file, "extractSubtitle", "done");
fs.unlink(audioFile, (err) => {
if (err) {
console.log(err);
}
});
resolve(1);
});
});

// 翻译字幕
if (translateProvider !== "-1") {
event.sender.send(
"taskStatusChange",
file,
"translateSubtitle",
"loading",
);
await translate(
event,
directory,
fileName,
`${srtFile}.srt`,
formData,
provider
);
event.sender.send(
"taskStatusChange",
file,
"translateSubtitle",
"done",
);
await translateSubtitle(event, file, directory, fileName, srtFile, formData, provider);
}

// 清理临时文件
if (!saveSourceSrt) {
fs.unlink(`${srtFile}.srt`, (err) => {
if (!isSubtitleFile && !saveSourceSrt) {
fs.unlink(srtFile, (err) => {
if (err) console.log(err);
});
}
Expand Down
17 changes: 16 additions & 1 deletion main/helpers/ipcHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,22 @@ export function setupIpcHandlers(mainWindow: BrowserWindow) {
ipcMain.on("openDialog", async (event) => {
const result = await dialog.showOpenDialog({
properties: ["openFile", "multiSelections"],
filters: [{ name: "Movies", extensions: ["mkv", "avi", "mp4"] }],
filters: [
{
name: "音频、视频和字幕文件",
extensions: [
// 视频格式
"mp4", "avi", "mov", "mkv", "flv", "wmv", "webm",
// 音频格式
"mp3", "wav", "ogg", "aac", "wma", "flac", "m4a",
"aiff", "ape", "opus", "ac3", "amr", "au", "mid",
// 其他常见格式
"3gp", "asf", "rm", "rmvb", "vob", "ts", "mts", "m2ts",
// 字幕格式
"srt", "vtt", "ass", "ssa"
]
},
],
});

try {
Expand Down
1 change: 0 additions & 1 deletion main/helpers/storeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ export function setupStoreHandlers() {
// 更新存储
store.set('translationProviders', allProviders);

console.log(allProviders, 'translationProviders');
return allProviders;
});

Expand Down
17 changes: 17 additions & 0 deletions main/helpers/taskManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ipcMain } from 'electron';

let taskList = [];

export function setupTaskManager() {
ipcMain.handle('getTasks', () => {
return taskList;
});

ipcMain.on('setTasks', (event, tasks) => {
taskList = tasks;
});

ipcMain.on('clearTasks', () => {
taskList = [];
});
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"private": true,
"name": "video-subtitle-master",
"description": "视频转字幕,字幕翻译软件",
"version": "1.0.18",
"version": "1.0.19",
"author": "buxuku <[email protected]>",
"main": "app/background.js",
"scripts": {
Expand Down
6 changes: 3 additions & 3 deletions renderer/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,9 @@ const Layout = ({ children }) => {
</aside>
<div className="flex flex-col">
<header className="sticky top-0 z-10 flex h-[57px] items-center gap-1 border-b bg-background px-4">
<h1 className="text-xl font-semibold">
批量为视频生成字幕,并翻译成其它语言
</h1>
<h4 className="text-base font-semibold">
视频/音频批量生成字幕,字幕翻译
</h4>
</header>
<main className="">{children}</main>
<Toaster />
Expand Down
2 changes: 1 addition & 1 deletion renderer/components/TaskConfigForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const TaskConfigForm = ({
name="sourceLanguage"
render={({ field }) => (
<FormItem>
<FormLabel>视频原始语言</FormLabel>
<FormLabel>视频/字幕原始语言</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>
Expand Down
6 changes: 5 additions & 1 deletion renderer/components/TaskControls.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Button } from './ui/button';
import { toast } from 'sonner';
import { isSubtitleFile } from 'lib/utils';

const TaskControls = ({ files, formData }) => {
const [taskStatus, setTaskStatus] = useState('idle');
Expand All @@ -22,13 +23,16 @@ const TaskControls = ({ files, formData }) => {
if (formData.translateProvider === '-1') {
return basicProcessingDone;
}
if (isSubtitleFile(item?.filePath)) {
return item.translateSubtitle;
}

return basicProcessingDone && item.translateSubtitle;
});

if (isAllFilesProcessed) {
toast('消息通知', {
description: '所有文件都已经生成字幕,无需再次生成',
description: '所有文件都处理完成',
});
return;
}
Expand Down
Loading

0 comments on commit c7be724

Please sign in to comment.