Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 40 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,49 @@
# 一键批量提取流媒体视频的中英字幕,并自动合并为双语字幕。
# dual-subtitle

Extract two embedded subtitle tracks from video files and merge them into a single dual-language subtitle file. Supports batch processing for `.mp4` and `.mkv`.

**[简体中文](README.zh-CN.md)**

[![NPM](https://nodei.co/npm/dual-subtitle.png?downloads=true)](https://www.npmjs.com/package/dual-subtitle)

## 依赖
需要本地有`Node.js`环境(包括`npm`)
## Requirements

- **Node.js** (with npm): [nodejs.org](https://nodejs.org/)
- The CLI prefers your system **ffmpeg** and **ffprobe**; if missing, it falls back to bundled installers.

## Usage

```bash
# Process all .mp4 and .mkv in the current directory
npx dual-subtitle

# Or specify a directory (with or without trailing /)
npx dual-subtitle /path/to/videos
```

> Without `npx` (e.g. some Synology setups):
> `node /path/to/dual-subtitle/index.js [directory]`

### Output

- Merged subtitle file: `<basename>.<lang1>-<lang2>.srt`
Example: `movie.chi-eng.srt` when auto-detecting Simplified Chinese + English.

* 安装【[Node.js/npm环境](https://nodejs.org/zh-cn/)】
## Flow

## 使用
1. For **each video**, the tool scans embedded subtitle streams.
2. **Before running**, there is a **3-second countdown**:
- **Do nothing**: It auto-selects **Simplified Chinese (chi)** and **English (eng)** and merges them. If either is missing, it lists tracks and asks you to enter the two **stream indices** to merge.
- **Press any key**: Skip auto-detect; it lists all subtitle tracks and asks you to enter the two indices to merge.
3. **Batch**: If you **manually choose indices** for any file in the run, **all following files** in that run also use manual selection (no countdown, no chi/eng auto-detect).

1. 进入视频所在目录
2. 命令行执行:`npx dual-subtitle`
## UI language

注:有些环境(比如群晖)如果没有`npx`,可以用`npm exec`代替。
- **Default**: English. If the system or environment suggests Chinese (e.g. `LANG`, `LC_ALL`, or on macOS the primary system language), the UI switches to Chinese.
- **Override**: Set `DUAL_SUBTITLE_LANG=zh` or `DUAL_SUBTITLE_LANG=en` to force the language.

生成的字幕文件会以`.chs-eng.srt`结尾。
## About

## 介绍
此工具主要是为了满足在Infuse上看流媒体视频时,能够显示双语字幕的需求。Infuse本身并不支持同时显示2条不同语言的字幕。
Useful for players like Infuse that don’t support showing two subtitle tracks at once: merge two embedded tracks (e.g. Chinese + English) into one dual subtitle file.

更多介绍,可以访问知乎文章:https://zhuanlan.zhihu.com/p/1915534266130997832
More background (Chinese): [Zhihu](https://zhuanlan.zhihu.com/p/1915534266130997832)
47 changes: 47 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# dual-subtitle

一键批量提取视频内嵌字幕中的两条轨道,并合并为双语字幕文件。

[![NPM](https://nodei.co/npm/dual-subtitle.png?downloads=true)](https://www.npmjs.com/package/dual-subtitle)

## 依赖

- 需要本地安装 **Node.js**(含 npm):[Node.js 官网](https://nodejs.org/zh-cn/)
- 本工具会优先使用系统自带的 `ffmpeg` / `ffprobe`;若未安装,会通过依赖包自动使用内置版本。

## 使用

```bash
# 在当前目录下处理所有 .mp4 / .mkv
npx dual-subtitle

# 指定目录(末尾可带或不带 /)
npx dual-subtitle /path/to/videos
```

> 若无 `npx`(如部分群晖环境),可用:`node /path/to/dual-subtitle/index.js [目录]`

### 输出文件

- 合并后的字幕文件名为:`<原文件名>.<语言1>-<语言2>.srt`
- 例如自动匹配到简体中文 + 英语时,生成:`movie.chi-eng.srt`

## 运行流程

1. **每个视频文件**会先扫描内嵌字幕轨道。
2. **启动前有 3 秒倒计时**:
- 不按键:自动按「简体中文(chi) + 英语(eng)」查找并合成;若缺某一条,会列出字幕并提示输入要合并的两条**索引**。
- **按任意键**:跳过自动查找,直接列出所有字幕,由你依次输入两条要合并的字幕索引。
3. **批量处理**:若在**第一个**(或任意一个)文件中进行了「手动选择索引」,则**本轮后续所有文件**都会直接进入手动选择流程,不再倒计时、也不再自动匹配 chi/eng。

## 界面语言

- **默认**:根据系统语言自动选择中文或英文(会读取环境变量 `LANG` / `LC_ALL` 等;在 macOS 上还会读取系统首选语言)。
- **强制指定**:
`DUAL_SUBTITLE_LANG=zh` 或 `DUAL_SUBTITLE_LANG=en` 可覆盖自动检测。

## 简介

主要用于在 Infuse 等播放器上观看流媒体时,将两条内嵌字幕(如中英)合并成一条双语字幕轨道,以解决播放器无法同时显示两条字幕的问题。

更多说明见知乎文章:<https://zhuanlan.zhihu.com/p/1915534266130997832>
10 changes: 6 additions & 4 deletions analyzeMedia.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import { execSync } from 'child_process';
import ffprobe from "ffprobe";
import ffprobeInstaller from '@ffprobe-installer/ffprobe';
import {config} from "./config.js";
import { config } from "./config.js";
import { t } from './i18n.js';

const getFFprobePath = () => {
try {
// 检查系统是否安装了 ffprobe
execSync('ffprobe -version', { stdio: 'ignore' });
// 如果能执行到这里,说明系统已安装
console.log('使用本地ffprobe');
console.log(t('usingLocalFfprobe'));
return 'ffprobe'; // 返回系统命令
} catch (e) {
return ffprobeInstaller.path;
}
}

export const analyzeMedia = (file) => {
console.log(`获取字幕信息...`);
console.log(t('gettingSubtitleInfo'));
const ffprobePath = getFFprobePath();

/*
Expand All @@ -35,7 +36,8 @@ export const analyzeMedia = (file) => {
duration: Math.round(stream.duration),
frames: Number(stream.tags.NUMBER_OF_FRAMES) || 0
}));
console.log(`找到 ${subTitles.length} 条字幕。`);
// 使用 printf 风格 + 多参数,让编辑器高亮数字
console.log(t('foundSubtitleCount'), subTitles.length);
resolve(subTitles);
})
.catch(function (err) {
Expand Down
1 change: 0 additions & 1 deletion config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
const config = {
workdir: './',
exts: ['.mp4', '.mkv'],
srtTag: 'chs-eng',
};

if (process.argv.length > 2) {
Expand Down
27 changes: 18 additions & 9 deletions extractSub.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ import readline from 'readline';
import { execSync } from 'child_process';
import ffmpegInstaller from '@ffmpeg-installer/ffmpeg';
import ffmpeg from 'fluent-ffmpeg';
import {config} from "./config.js";
import {removeExtension} from "./utils.js";
import { config } from "./config.js";
import { removeExtension } from "./utils.js";
import { t } from './i18n.js';

const getFFmpegPath = () => {
try {
// 检查系统是否安装了 ffprobe
execSync('ffmpeg -version', { stdio: 'ignore' });
// 如果能执行到这里,说明系统已安装
console.log('使用本地ffmpeg');
console.log(t('usingLocalFfmpeg'));
return 'ffmpeg'; // 返回系统命令
} catch (e) {
return ffmpegInstaller.path;
Expand Down Expand Up @@ -39,8 +40,11 @@ const timemarkToSeconds = (timemark) => {

export const extractSub = (filename, targetSubs) => {
return new Promise((resolve, reject) => {
const mainSrt = `${removeExtension(filename)}.chs.srt`;
const secondarySrt = `${removeExtension(filename)}.eng.srt`;
// 使用字幕的 code 或 index 来生成文件名
const code1 = targetSubs[0].code || `sub${targetSubs[0].index}`;
const code2 = targetSubs[1].code || `sub${targetSubs[1].index}`;
const mainSrt = `${removeExtension(filename)}.${code1}.srt`;
const secondarySrt = `${removeExtension(filename)}.${code2}.srt`;
const duration = targetSubs[0].duration;
let startTs = 0;

Expand All @@ -52,7 +56,7 @@ export const extractSub = (filename, targetSubs) => {
.outputOptions(['-map', `0:${targetSubs[1].index}`, '-c', 'copy'])
.run()
.on('start', function (str) {
console.log('正在提取字幕文件...', str);
console.log(t('extractingStart'), str);
startTs = Date.now();
})
.on('progress', function (progress) {
Expand All @@ -61,14 +65,19 @@ export const extractSub = (filename, targetSubs) => {
const elapsedSec = startTs ? (Date.now() - startTs) / 1000 : 0;
const remainingSec = fraction > 0 ? elapsedSec * (1 - fraction) / fraction : 0;
readline.cursorTo(process.stdout, 0);
process.stdout.write(`字幕提取中,进度:${(progressPercent || 0)}% | 预计剩余:${formatSeconds(remainingSec)}`);
process.stdout.write(
t('extractingProgress', {
progressPercent,
remaining: formatSeconds(remainingSec),
}),
);
})
.on('end', function (str) {
console.log('\n字幕提取完成。');
console.log(t('extractingDone'));
resolve([mainSrt, secondarySrt]);
})
.on('error', function (err) {
console.log('字幕提取出错:', err);
console.log(t('extractingError', { err }));
reject(err);
});
});
Expand Down
Loading