From 206a7d9b06ea1c974197e13f7c83b762b7c13f32 Mon Sep 17 00:00:00 2001 From: Wayne Mao Date: Fri, 6 Feb 2026 21:52:17 +0800 Subject: [PATCH 1/6] feat: custom language support --- config.js | 1 - extractSub.js | 7 +- findSub.js | 249 +++++++++++++++++++++++++++++++------------------- index.js | 8 +- 4 files changed, 167 insertions(+), 98 deletions(-) diff --git a/config.js b/config.js index f782348..c0f31d9 100644 --- a/config.js +++ b/config.js @@ -1,7 +1,6 @@ const config = { workdir: './', exts: ['.mp4', '.mkv'], - srtTag: 'chs-eng', }; if (process.argv.length > 2) { diff --git a/extractSub.js b/extractSub.js index 397a712..cb4f63d 100644 --- a/extractSub.js +++ b/extractSub.js @@ -39,8 +39,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; diff --git a/findSub.js b/findSub.js index 969b4d6..a0a90a4 100644 --- a/findSub.js +++ b/findSub.js @@ -1,53 +1,141 @@ import readline from 'readline'; +// 默认要查找并合成的字幕:简体中文 + 英语。可扩展为更多 code。 +const DEFAULT_TARGETS = [ + { code: 'chi', label: '简体中文', finder: findChiSub }, + { code: 'eng', label: '英语', finder: findEngSub }, +]; + export const findSub = async (subTitles) => { - console.log('查找简体和英语字幕...'); - let chsSub = findChiSub(subTitles); - let engSub = findEngSub(subTitles); + const prefix = '将自动查找简体中文和英语字幕并合成,按任意键可中断并手动选择。'; + const interrupted = await waitForInterruptWithCountdown(3000, prefix); - if (chsSub) { - console.log('找到简体中文字幕,索引为:', chsSub.index); - } else { - console.log('没有找到简体中文字幕'); + if (interrupted) { + console.log('\n已中断自动流程,请手动选择要合并的 2 个字幕。'); + return await manualSelectSubs(subTitles); } - if (engSub) { - console.log('找到英语字幕,索引为:', engSub.index); + console.log('\n自动查找简体中文和英语字幕...'); + const [target1, target2] = DEFAULT_TARGETS; + let sub1 = target1.finder(subTitles); + let sub2 = target2.finder(subTitles); + + if (sub1) { + console.log(`找到${target1.label}字幕,索引为:${sub1.index}`); } else { - console.log('没有找到英语字幕'); + console.log(`没有找到${target1.label}字幕`); + } + if (sub2) { + console.log(`找到${target2.label}字幕,索引为:${sub2.index}`); + } else { + console.log(`没有找到${target2.label}字幕`); } - if (!chsSub || !engSub) { - console.log('所有可用字幕信息如下:'); + if (!sub1 || !sub2) { + console.log('可用字幕列表:'); subTitles.forEach((s) => { console.log(`索引=${s.index}, code=${s.code}, name="${s.name}", duration=${s.duration}, frames=${s.frames}`); }); + if (!sub1) sub1 = await promptForSubIndex(subTitles, target1.label); + if (!sub2) sub2 = await promptForSubIndex(subTitles, target2.label, sub1.index); + } - if (!chsSub) { - chsSub = await promptForSubIndex(subTitles, '中文'); - } + console.log(`最终选择的${target1.label}字幕索引:${sub1.index},帧:${sub1.frames ?? '-'}`); + console.log(`最终选择的${target2.label}字幕索引:${sub2.index},帧:${sub2.frames ?? '-'}`); + console.log('时长:', sub1.duration); + return [sub1, sub2]; +}; - if (!engSub) { - engSub = await promptForSubIndex(subTitles, '英文'); +/** + * 等待指定毫秒,期间显示倒计时(数字每秒更新),任意键中断。 + * @param {number} ms 总等待时间(毫秒) + * @param {string} prefix 倒计时前的提示文案(同一行) + * @returns {Promise} 是否被按键中断 + */ +const waitForInterruptWithCountdown = (ms, prefix) => { + return new Promise((resolve) => { + if (!process.stdin.isTTY) { + setTimeout(() => resolve(false), ms); + return; } - } + const stdin = process.stdin; + const wasRaw = stdin.isRaw || false; + if (!wasRaw) stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + + const totalSec = Math.ceil(ms / 1000); + let leftSec = totalSec; + + const formatCountdown = (sec) => { + // 使用高亮颜色和箭头让数字更醒目 + const YELLOW = '\x1b[33m'; + const BOLD = '\x1b[1m'; + const RESET = '\x1b[0m'; + return `${prefix} ${YELLOW}${BOLD}>>> ${sec} <<<${RESET}`; + }; - console.log('最终选择的简体中文字幕索引为:', chsSub.index, ',帧:', chsSub.frames); - console.log('最终选择的英语字幕索引为:', engSub.index, ',帧:', chsSub.frames); - console.log('时长:', chsSub.duration); + const writeLine = () => { + readline.cursorTo(process.stdout, 0); + readline.clearLine(process.stdout, 1); + process.stdout.write(formatCountdown(leftSec)); + }; + + writeLine(); + + let tickId; + tickId = setInterval(() => { + leftSec -= 1; + if (leftSec <= 0) { + clearInterval(tickId); + cleanup(); + readline.cursorTo(process.stdout, 0); + readline.clearLine(process.stdout, 1); + process.stdout.write('\n'); + resolve(false); + return; + } + writeLine(); + }, 1000); + + const cleanup = () => { + if (tickId) clearInterval(tickId); + stdin.removeListener('data', onKeyPress); + if (!wasRaw) stdin.setRawMode(false); + stdin.pause(); + }; - return [chsSub, engSub]; + const onKeyPress = (key) => { + if (key === '\u0003') { + cleanup(); + process.exit(0); + } + cleanup(); + resolve(true); + }; + stdin.on('data', onKeyPress); + }); }; -const promptForSubIndex = (subTitles, label) => { - return new Promise((resolve) => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }); +const manualSelectSubs = async (subTitles) => { + console.log('\n所有可用字幕信息如下:'); + subTitles.forEach((s, idx) => { + console.log(`[${idx}] 索引=${s.index}, code=${s.code}, name="${s.name}", duration=${s.duration}, frames=${s.frames}`); + }); + const sub1 = await promptForSubIndex(subTitles, '第一个'); + const sub2 = await promptForSubIndex(subTitles, '第二个', sub1.index); + console.log(`最终选择的第一个字幕:索引=${sub1.index}, code=${sub1.code}, name="${sub1.name}"`); + console.log(`最终选择的第二个字幕:索引=${sub2.index}, code=${sub2.code}, name="${sub2.name}"`); + console.log('时长:', sub1.duration); + return [sub1, sub2]; +}; +const promptForSubIndex = (subTitles, label, excludeIndex = null) => { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const excludeHint = excludeIndex !== null ? `(不能选择索引 ${excludeIndex})` : ''; const ask = () => { - rl.question(`请输入${label}字幕的索引(按回车退出): `, (answer) => { + rl.question(`请输入${label}字幕的索引${excludeHint}(按回车退出): `, (answer) => { const trimmed = answer.trim(); if (trimmed === '') { console.log('已退出。'); @@ -66,88 +154,61 @@ const promptForSubIndex = (subTitles, label) => { ask(); return; } + if (excludeIndex !== null && target.index === excludeIndex) { + console.log('不能选择与第一个字幕相同的索引,请重新输入。'); + ask(); + return; + } rl.close(); resolve({ index: target.index, - duration: target.duration + duration: target.duration, + frames: target.frames, + code: target.code, + name: target.name, }); }); }; - ask(); }); }; +// ---------- 按 language code 的查找策略(可扩展) ---------- + /** - * 目前看到的数据可能有: - * chi,简体 - * chi,Simplified Chinese - * 查找策略是:先找 'chi', 如果数量大于1,则进一步找 "简体" + * 简体中文字幕:code=chi,多条时优先 name 含「简体」或 "simplified" */ -const findChiSub = (subTitles) => { - // TBD: Consider to include 'chs' later - const chineseSubtitles = subTitles.filter(subTitle => subTitle.code === 'chi'); - - if (chineseSubtitles.length === 0) { - return null; - } - - if (chineseSubtitles.length === 1) { - return { - index: chineseSubtitles[0].index, - duration: chineseSubtitles[0].duration - }; - } - - // If multiple Chinese subtitles, look for Simplified Chinese - const targetSub = chineseSubtitles.find(subTitle => - subTitle.name.includes('简体') || - subTitle.name.includes('simplified') - ); - - return targetSub ? { - index: targetSub.index, - duration: targetSub.duration, - frames: targetSub.frames - } : null; +function findChiSub(subTitles) { + const list = subTitles.filter((s) => s.code === 'chi'); + if (list.length === 0) return null; + if (list.length === 1) return toSub(list[0]); + const preferred = list.find((s) => s.name.includes('简体') || s.name.includes('simplified')); + return preferred ? toSub(preferred) : null; } /** - * 目前看到的数据可能有: - * 9,subrip,eng - * 10,subrip,eng,SDH - * 23,subrip,eng,English[CC] - * 选择策略: - * 1. 过滤掉空字幕(只有当 NUMBER_OF_FRAMES 存在且 < 10 时才过滤) - * 2. 如果同时有SDH和非SDH版本,选非SDH版本 - * 3. 按原始顺序选择第一个可用的字幕 + * 英语字幕:code=eng,过滤空字幕(帧数过少),多条时优先非 SDH */ -const findEngSub = (subTitles) => { - const englishSubs = subTitles.filter(sub => sub.code === 'eng'); - - if (englishSubs.length === 0) return null; - - // 过滤掉空字幕 - const nonEmpty = englishSubs.filter(sub => { - const frames = Number(sub.frames); +function findEngSub(subTitles) { + const list = subTitles.filter((s) => s.code === 'eng'); + if (list.length === 0) return null; + const nonEmpty = list.filter((s) => { + const frames = Number(s.frames); if (!frames) return true; return frames >= 100; }); + const pool = nonEmpty.length > 0 ? nonEmpty : list; + const nonSDH = pool.filter((s) => !s.name.includes('sdh')); + const final = nonSDH.length > 0 ? nonSDH : pool; + return final[0] ? toSub(final[0]) : null; +} - const candidatePool = nonEmpty.length > 0 ? nonEmpty : englishSubs; - - if (candidatePool.length === 0) return null; - - // 多个英文字幕时,优先去除 SDH - const nonSDHSubs = candidatePool.filter(sub => !sub.name.includes('sdh')); - const finalPool = nonSDHSubs.length > 0 ? nonSDHSubs : candidatePool; - - // 按原始顺序选择第一个可用的字幕 - const targetSub = finalPool[0]; - - return targetSub ? { - index: targetSub.index, - duration: targetSub.duration, - frames: targetSub.frames - } : null; -}; +function toSub(s) { + return { + index: s.index, + duration: s.duration, + frames: s.frames, + code: s.code, + name: s.name, + }; +} \ No newline at end of file diff --git a/index.js b/index.js index a86af0d..fc6c3a0 100755 --- a/index.js +++ b/index.js @@ -16,7 +16,13 @@ const main = async () => { const subTitles = await analyzeMedia(file); const targetSubs = await findSub(subTitles); const srts = await extractSub(file, targetSubs); - subtitleMerge(config.workdir + srts[0], config.workdir + srts[1], `${config.workdir}${removeExtension(file)}.${config.srtTag}.srt`); + + // 使用字幕的 code 来生成输出文件名 + const code1 = targetSubs[0].code || `sub${targetSubs[0].index}`; + const code2 = targetSubs[1].code || `sub${targetSubs[1].index}`; + const outputSrt = `${config.workdir}${removeExtension(file)}.${code1}-${code2}.srt`; + + subtitleMerge(config.workdir + srts[0], config.workdir + srts[1], outputSrt); deleteFile(srts[0]); deleteFile(srts[1]); } From 59281837a07355268026c3714c78c5034437b7de Mon Sep 17 00:00:00 2001 From: Wayne Mao Date: Fri, 6 Feb 2026 21:58:03 +0800 Subject: [PATCH 2/6] chore: log format --- analyzeMedia.js | 2 +- findSub.js | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/analyzeMedia.js b/analyzeMedia.js index becebff..1873064 100644 --- a/analyzeMedia.js +++ b/analyzeMedia.js @@ -35,7 +35,7 @@ export const analyzeMedia = (file) => { duration: Math.round(stream.duration), frames: Number(stream.tags.NUMBER_OF_FRAMES) || 0 })); - console.log(`找到 ${subTitles.length} 条字幕。`); + console.log('找到', subTitles.length, '条字幕。'); resolve(subTitles); }) .catch(function (err) { diff --git a/findSub.js b/findSub.js index a0a90a4..2b0fa3d 100644 --- a/findSub.js +++ b/findSub.js @@ -21,14 +21,14 @@ export const findSub = async (subTitles) => { let sub2 = target2.finder(subTitles); if (sub1) { - console.log(`找到${target1.label}字幕,索引为:${sub1.index}`); + console.log('找到', target1.label, '字幕,索引为:', sub1.index); } else { - console.log(`没有找到${target1.label}字幕`); + console.log('没有找到', target1.label, '字幕'); } if (sub2) { - console.log(`找到${target2.label}字幕,索引为:${sub2.index}`); + console.log('找到', target2.label, '字幕,索引为:', sub2.index); } else { - console.log(`没有找到${target2.label}字幕`); + console.log('没有找到', target2.label, '字幕'); } if (!sub1 || !sub2) { @@ -40,8 +40,8 @@ export const findSub = async (subTitles) => { if (!sub2) sub2 = await promptForSubIndex(subTitles, target2.label, sub1.index); } - console.log(`最终选择的${target1.label}字幕索引:${sub1.index},帧:${sub1.frames ?? '-'}`); - console.log(`最终选择的${target2.label}字幕索引:${sub2.index},帧:${sub2.frames ?? '-'}`); + console.log('最终选择的', target1.label, '字幕索引:', sub1.index, ',帧:', sub1.frames ?? '-'); + console.log('最终选择的', target2.label, '字幕索引:', sub2.index, ',帧:', sub2.frames ?? '-'); console.log('时长:', sub1.duration); return [sub1, sub2]; }; @@ -211,4 +211,4 @@ function toSub(s) { code: s.code, name: s.name, }; -} \ No newline at end of file +} From 5c65c67144111e8aeed973e927d9eb6e709dc375 Mon Sep 17 00:00:00 2001 From: Wayne Mao Date: Sat, 7 Feb 2026 23:24:39 +0800 Subject: [PATCH 3/6] feat: i18n --- analyzeMedia.js | 10 ++++--- extractSub.js | 20 ++++++++----- findSub.js | 79 +++++++++++++++++++++++++++++++++++-------------- i18n.js | 63 +++++++++++++++++++++++++++++++++++++++ index.js | 13 ++++---- locales/en.js | 38 ++++++++++++++++++++++++ locales/zh.js | 40 +++++++++++++++++++++++++ 7 files changed, 223 insertions(+), 40 deletions(-) create mode 100644 i18n.js create mode 100644 locales/en.js create mode 100644 locales/zh.js diff --git a/analyzeMedia.js b/analyzeMedia.js index 1873064..b62fdc5 100644 --- a/analyzeMedia.js +++ b/analyzeMedia.js @@ -1,14 +1,15 @@ 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; @@ -16,7 +17,7 @@ const getFFprobePath = () => { } export const analyzeMedia = (file) => { - console.log(`获取字幕信息...`); + console.log(t('gettingSubtitleInfo')); const ffprobePath = getFFprobePath(); /* @@ -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) { diff --git a/extractSub.js b/extractSub.js index cb4f63d..ebc90d9 100644 --- a/extractSub.js +++ b/extractSub.js @@ -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; @@ -55,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) { @@ -64,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); }); }); diff --git a/findSub.js b/findSub.js index 2b0fa3d..4f79a00 100644 --- a/findSub.js +++ b/findSub.js @@ -1,4 +1,5 @@ import readline from 'readline'; +import { t } from './i18n.js'; // 默认要查找并合成的字幕:简体中文 + 英语。可扩展为更多 code。 const DEFAULT_TARGETS = [ @@ -6,43 +7,69 @@ const DEFAULT_TARGETS = [ { code: 'eng', label: '英语', finder: findEngSub }, ]; +// 在同一进程内,一旦用户手动选择过一次字幕,后续文件都直接走手动选择流程 +let alwaysManual = false; + export const findSub = async (subTitles) => { - const prefix = '将自动查找简体中文和英语字幕并合成,按任意键可中断并手动选择。'; + // 如果已经进入“总是手动选择”模式,跳过倒计时和自动匹配 + if (alwaysManual) { + return await manualSelectSubs(subTitles); + } + + const prefix = t('autoCountdownPrefix'); const interrupted = await waitForInterruptWithCountdown(3000, prefix); if (interrupted) { - console.log('\n已中断自动流程,请手动选择要合并的 2 个字幕。'); + console.log(t('interruptedManual')); + alwaysManual = true; return await manualSelectSubs(subTitles); } - console.log('\n自动查找简体中文和英语字幕...'); + console.log(t('autoFindingChiEng')); const [target1, target2] = DEFAULT_TARGETS; let sub1 = target1.finder(subTitles); let sub2 = target2.finder(subTitles); if (sub1) { - console.log('找到', target1.label, '字幕,索引为:', sub1.index); + console.log(t('foundLangSub', { label: target1.label }), sub1.index); } else { - console.log('没有找到', target1.label, '字幕'); + console.log(t('notFoundLangSub', { label: target1.label })); } if (sub2) { - console.log('找到', target2.label, '字幕,索引为:', sub2.index); + console.log(t('foundLangSub', { label: target2.label }), sub2.index); } else { - console.log('没有找到', target2.label, '字幕'); + console.log(t('notFoundLangSub', { label: target2.label })); } if (!sub1 || !sub2) { - console.log('可用字幕列表:'); + console.log(t('availableSubtitleList')); subTitles.forEach((s) => { - console.log(`索引=${s.index}, code=${s.code}, name="${s.name}", duration=${s.duration}, frames=${s.frames}`); + console.log( + t('subtitleListItem'), + s.index, + s.code, + s.name, + s.duration, + s.frames, + ); }); if (!sub1) sub1 = await promptForSubIndex(subTitles, target1.label); if (!sub2) sub2 = await promptForSubIndex(subTitles, target2.label, sub1.index); + // 一旦出现手动输入索引,后续文件全部改为手动选择模式 + alwaysManual = true; } - console.log('最终选择的', target1.label, '字幕索引:', sub1.index, ',帧:', sub1.frames ?? '-'); - console.log('最终选择的', target2.label, '字幕索引:', sub2.index, ',帧:', sub2.frames ?? '-'); - console.log('时长:', sub1.duration); + console.log( + t('finalSelectedLang', { label: target1.label }), + sub1.index, + sub1.frames ?? '-', + ); + console.log( + t('finalSelectedLang', { label: target2.label }), + sub2.index, + sub2.frames ?? '-', + ); + console.log(t('duration'), sub1.duration); return [sub1, sub2]; }; @@ -118,44 +145,50 @@ const waitForInterruptWithCountdown = (ms, prefix) => { }; const manualSelectSubs = async (subTitles) => { - console.log('\n所有可用字幕信息如下:'); + console.log(t('manualAllAvailable')); subTitles.forEach((s, idx) => { - console.log(`[${idx}] 索引=${s.index}, code=${s.code}, name="${s.name}", duration=${s.duration}, frames=${s.frames}`); + console.log( + t('subtitleListItem'), + s.index, + s.code, + s.name, + s.duration, + s.frames, + ); }); const sub1 = await promptForSubIndex(subTitles, '第一个'); const sub2 = await promptForSubIndex(subTitles, '第二个', sub1.index); - console.log(`最终选择的第一个字幕:索引=${sub1.index}, code=${sub1.code}, name="${sub1.name}"`); - console.log(`最终选择的第二个字幕:索引=${sub2.index}, code=${sub2.code}, name="${sub2.name}"`); - console.log('时长:', sub1.duration); + console.log(t('manualSelectedFirst'), sub1.index, sub1.code, sub1.name); + console.log(t('manualSelectedSecond'), sub2.index, sub2.code, sub2.name); + console.log(t('duration'), sub1.duration); return [sub1, sub2]; }; const promptForSubIndex = (subTitles, label, excludeIndex = null) => { return new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - const excludeHint = excludeIndex !== null ? `(不能选择索引 ${excludeIndex})` : ''; const ask = () => { - rl.question(`请输入${label}字幕的索引${excludeHint}(按回车退出): `, (answer) => { + rl.question(t('promptIndex', { label, excludeIndex }), (answer) => { const trimmed = answer.trim(); if (trimmed === '') { - console.log('已退出。'); + console.log(t('exited')); rl.close(); process.exit(0); } const value = Number(trimmed); if (!Number.isInteger(value)) { - console.log('请输入有效的整数索引。'); + console.log(t('invalidInteger')); ask(); return; } const target = subTitles.find((s) => s.index === value); if (!target) { - console.log('未找到该索引对应的字幕,请重新输入。'); + console.log(t('indexNotFound')); ask(); return; } if (excludeIndex !== null && target.index === excludeIndex) { - console.log('不能选择与第一个字幕相同的索引,请重新输入。'); + console.log(t('sameAsFirst')); ask(); return; } diff --git a/i18n.js b/i18n.js new file mode 100644 index 0000000..0a5bf0d --- /dev/null +++ b/i18n.js @@ -0,0 +1,63 @@ +import { execSync } from 'child_process'; +import zh from './locales/zh.js'; +import en from './locales/en.js'; + +const locales = { en, zh }; + +// 只区分 zh / en:默认英文,检测到中文则用中文 +export const detectLocale = () => { + // 1. 显式环境变量强制覆盖:DUAL_SUBTITLE_LANG=zh/en + const override = (process.env.DUAL_SUBTITLE_LANG || '').toLowerCase(); + if (override === 'zh' || override === 'en') { + return override; + } + + // 2. 终端相关环境变量 + const env = + process.env.LC_ALL || + process.env.LC_MESSAGES || + process.env.LANG || + ''; + const envLower = String(env).toLowerCase(); + if (envLower.includes('zh')) return 'zh'; + + // 3. Node Intl 默认 locale + const intl = Intl.DateTimeFormat().resolvedOptions().locale || ''; + const intlLower = String(intl).toLowerCase(); + if (intlLower.startsWith('zh')) return 'zh'; + + // 4. macOS 上读取系统首选语言(AppleLanguages) + if (process.platform === 'darwin') { + try { + const out = execSync('defaults read -g AppleLanguages', { encoding: 'utf8' }); + if (String(out).toLowerCase().includes('zh')) { + return 'zh'; + } + } catch (e) { + // 忽略读取失败,继续走默认逻辑 + } + } + + // 5. 默认英文 + return 'en'; +}; + +const activeLocale = detectLocale(); +const dict = locales[activeLocale] || locales.en; + +/** + * t('key', params) -> string + * key 对应 locales 里的字段;字段可为字符串或 (params) => string + */ +export const t = (key, params = {}) => { + const value = dict[key]; + if (typeof value === 'function') { + return value(params); + } + if (typeof value === 'string') { + return value; + } + // 未配置的 key,回退为 key 本身,方便在开发时发现问题 + return key; +}; + diff --git a/index.js b/index.js index fc6c3a0..1b37adc 100755 --- a/index.js +++ b/index.js @@ -2,17 +2,18 @@ import fs from 'fs'; import path from 'path'; import subtitleMerge from "subtitle-merge"; -import {config} from './config.js'; -import {findSub} from './findSub.js'; -import {analyzeMedia} from "./analyzeMedia.js"; -import {extractSub} from "./extractSub.js"; -import {deleteFile, removeExtension} from "./utils.js"; +import { config } from './config.js'; +import { findSub } from './findSub.js'; +import { analyzeMedia } from "./analyzeMedia.js"; +import { extractSub } from "./extractSub.js"; +import { deleteFile, removeExtension } from "./utils.js"; +import { t } from './i18n.js'; const main = async () => { const mediaFiles = fs.readdirSync(config.workdir).filter((file) => config.exts.includes(path.extname(file))); for (const file of mediaFiles) { - console.log(`正在处理:${file}`); + console.log(t('processingFile', { file })); const subTitles = await analyzeMedia(file); const targetSubs = await findSub(subTitles); const srts = await extractSub(file, targetSubs); diff --git a/locales/en.js b/locales/en.js new file mode 100644 index 0000000..7a1816e --- /dev/null +++ b/locales/en.js @@ -0,0 +1,38 @@ +export default { + processingFile: ({ file }) => `Processing: ${file}`, + usingLocalFfprobe: 'Using local ffprobe', + usingLocalFfmpeg: 'Using local ffmpeg', + gettingSubtitleInfo: 'Getting subtitle info...', + // Use %d placeholder so console.log can highlight numeric args + foundSubtitleCount: 'Found %d subtitle track(s).', + + // findSub + autoCountdownPrefix: 'Auto-detect Simplified Chinese (chi) + English (eng) and merge. Press any key to interrupt for manual selection.', + interruptedManual: 'Auto flow interrupted. Please manually select 2 subtitle tracks to merge.', + autoFindingChiEng: 'Auto-detecting Simplified Chinese (chi) and English (eng)...', + foundLangSub: ({ label }) => `Found ${label} subtitle. Index: %d`, + notFoundLangSub: ({ label }) => `No ${label} subtitle found`, + availableSubtitleList: 'Available subtitle tracks:', + subtitleListItem: 'index=%d, code=%s, name="%s", duration=%d, frames=%d', + finalSelectedLang: ({ label }) => `Selected ${label}: index=%d, frames=%s`, + duration: 'Duration: %d', + + manualAllAvailable: 'All available subtitle tracks:', + manualSelectedFirst: 'Selected #1: index=%d, code=%s, name="%s"', + manualSelectedSecond: 'Selected #2: index=%d, code=%s, name="%s"', + + promptIndex: ({ label, excludeIndex }) => { + const excludeHint = excludeIndex !== null && excludeIndex !== undefined ? ` (cannot select index ${excludeIndex})` : ''; + return `Enter ${label} subtitle index${excludeHint} (press Enter to exit): `; + }, + exited: 'Exited.', + invalidInteger: 'Please enter a valid integer index.', + indexNotFound: 'No subtitle found for that index. Please try again.', + sameAsFirst: 'Cannot select the same index as the first subtitle. Please try again.', + + // extractSub + extractingStart: 'Extracting subtitle files...', + extractingProgress: ({ progressPercent, remaining }) => `Extracting... ${progressPercent || 0}% | ETA: ${remaining}`, + extractingDone: 'Subtitle extraction completed.', + extractingError: ({ err }) => `Subtitle extraction error: ${err}`, +}; diff --git a/locales/zh.js b/locales/zh.js new file mode 100644 index 0000000..811bb5f --- /dev/null +++ b/locales/zh.js @@ -0,0 +1,40 @@ +export default { + processingFile: ({ file }) => `正在处理:${file}`, + usingLocalFfprobe: '使用本地ffprobe', + usingLocalFfmpeg: '使用本地ffmpeg', + gettingSubtitleInfo: '获取字幕信息...', + // 使用 %d 占位符,搭配 console.log 的多参数高亮数字 + foundSubtitleCount: '找到 %d 条字幕。', + + // findSub + autoCountdownPrefix: '将自动查找简体中文(chi)和英语(eng)字幕并合成,按任意键可中断并手动选择。', + interruptedManual: '\n已中断自动流程,请手动选择要合并的 2 个字幕。', + autoFindingChiEng: '\n自动查找简体中文和英语字幕...', + foundLangSub: ({ label }) => `找到${label}字幕,索引为:%d`, + notFoundLangSub: ({ label }) => `没有找到${label}字幕`, + availableSubtitleList: '可用字幕列表:', + subtitleListItem: '索引=%d, code=%s, name="%s", duration=%d, frames=%d', + finalSelectedLang: ({ label }) => `最终选择的${label}字幕索引:%d,帧:%s`, + duration: '时长:%d', + + manualAllAvailable: '\n所有可用字幕信息如下:', + manualSelectedFirst: '最终选择的第一个字幕:索引=%d, code=%s, name="%s"', + manualSelectedSecond: '最终选择的第二个字幕:索引=%d, code=%s, name="%s"', + + promptIndex: ({ label, excludeIndex }) => { + const excludeHint = excludeIndex !== null && excludeIndex !== undefined ? `(不能选择索引 ${excludeIndex})` : ''; + return `请输入${label}字幕的索引${excludeHint}(按回车退出): `; + }, + exited: '已退出。', + invalidInteger: '请输入有效的整数索引。', + indexNotFound: '未找到该索引对应的字幕,请重新输入。', + sameAsFirst: '不能选择与第一个字幕相同的索引,请重新输入。', + + // extractSub + extractingStart: '正在提取字幕文件...', + extractingProgress: ({ progressPercent, remaining }) => + `字幕提取中,进度:${progressPercent || 0}% | 预计剩余:${remaining}`, + extractingDone: '\n字幕提取完成。', + extractingError: ({ err }) => `字幕提取出错:${err}`, +}; + From e48a9e7c4881d2e48e7a1c97501eee136aac8ebd Mon Sep 17 00:00:00 2001 From: Wayne Mao Date: Sat, 7 Feb 2026 23:25:21 +0800 Subject: [PATCH 4/6] chore: rollback test --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fd67589..8064d62 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dual-subtitle": "index.js" }, "scripts": { - "test": "node index.js /Volumes/Download/一战再战[杜比视界版本][中文字幕].2025.2160p.iTunes.WEB-DL.DDP.5.1.Atmos.DV.H.265-DreamHD", + "test": "node index.js ./data/", "bumpVersion": "npx bbump" }, "author": "helloint", From 3fd99a4b959c3d42721863011da5015baa421b69 Mon Sep 17 00:00:00 2001 From: Wayne Mao Date: Sat, 7 Feb 2026 23:26:02 +0800 Subject: [PATCH 5/6] chore: bump version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e746eb8..fee7285 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dual-subtitle", - "version": "0.5.2", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dual-subtitle", - "version": "0.5.2", + "version": "0.6.0", "license": "MIT", "dependencies": { "@ffmpeg-installer/ffmpeg": "^1.1.0", diff --git a/package.json b/package.json index 8064d62..6ad4548 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dual-subtitle", - "version": "0.5.2", + "version": "0.6.0", "main": "index.js", "type": "module", "bin": { From caee984fa1df0dbdf9725f133d202fe2cb6347f8 Mon Sep 17 00:00:00 2001 From: Wayne Mao Date: Sat, 7 Feb 2026 23:36:49 +0800 Subject: [PATCH 6/6] chore: readme en --- README.md | 52 +++++++++++++++++++++++++++++++++++++------------ README.zh-CN.md | 47 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 README.zh-CN.md diff --git a/README.md b/README.md index b39ede7..40c2d0f 100644 --- a/README.md +++ b/README.md @@ -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: `.-.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) diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..25a304e --- /dev/null +++ b/README.zh-CN.md @@ -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 等播放器上观看流媒体时,将两条内嵌字幕(如中英)合并成一条双语字幕轨道,以解决播放器无法同时显示两条字幕的问题。 + +更多说明见知乎文章: