@deepwhale-cn/dsh-media-batch 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -97,3 +97,20 @@ bash tests/run.sh
97
97
  ## License
98
98
 
99
99
  MIT © 温州深鲸智能科技有限公司
100
+
101
+
102
+ ## 语音转文字(可选,联网)
103
+
104
+ `audio` 只抽音轨;要文字就用 `transcribe`,走 DeepSeek 官方 ASR:
105
+
106
+ ```sh
107
+ export DEEPSEEK_API_KEY=sk-...
108
+ dsh-media-batch transcribe --input 会议录音.mp4 --out out
109
+ ```
110
+
111
+ - 输入:视频或音频(m4a / mp3 / wav / aac / flac / ogg / opus / amr / wma …)
112
+ - 自动用 ffmpeg 抽成 16 kHz 单声道 WAV,按 `--segment`(默认 110 秒)切片,再逐段上传
113
+ - 限制(官方):单段 ≤ 8 MiB、≤ 120 秒;语种自动识别,不用传参数
114
+ - 计费:0.39 元/小时(限时 5 折,刊例 0.78);跑完打印时长与估算费用
115
+ - 输出:`<原名>.转写.txt`;多段按空行拼接
116
+ - **只有 `transcribe` 会联网**
package/bin/cli.mjs CHANGED
@@ -21,6 +21,8 @@ import { basename, dirname, extname, join, resolve } from 'node:path';
21
21
  import { tmpdir } from 'node:os';
22
22
  import { fileURLToPath } from 'node:url';
23
23
 
24
+ import { CloudError, asrFile } from '../lib/cloud.mjs';
25
+
24
26
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
25
27
  const WM_PNG = join(PKG_ROOT, 'lib', 'watermark_png.py');
26
28
  const CMD_NAME = 'dsh-media-batch';
@@ -30,6 +32,11 @@ const VIDEO_EXTS = new Set([
30
32
  '.mts', '.mpg', '.mpeg', '.wmv', '.3gp',
31
33
  ]);
32
34
 
35
+ const AUDIO_EXTS = new Set([
36
+ '.m4a', '.mp3', '.wav', '.aac', '.flac', '.ogg', '.opus', '.amr', '.wma', '.aiff', '.aif',
37
+ ]);
38
+ const TRANSCRIBE_EXTS = new Set([...VIDEO_EXTS, ...AUDIO_EXTS]);
39
+
33
40
  const COMMANDS = {
34
41
  compress: { actions: [], modes: {} },
35
42
  transcode: { actions: ['format', 'resize'], modes: { format: 'format', resize: 'resize' } },
@@ -37,12 +44,13 @@ const COMMANDS = {
37
44
  audio: { actions: ['m4a', 'mp3'], modes: { m4a: 'm4a', mp3: 'mp3' } },
38
45
  watermark: { actions: ['text'], modes: { text: 'text' } },
39
46
  subtitle: { actions: ['burn'], modes: { burn: 'burn' } },
47
+ transcribe: { actions: [], modes: {} },
40
48
  };
41
49
 
42
50
  const VALUE_FLAGS = new Set([
43
51
  '--input', '--out', '--crf', '--preset', '--format', '--width', '--height',
44
52
  '--every', '--fps', '--text', '--position', '--fontsize', '--opacity',
45
- '--subtitle', '--bitrate',
53
+ '--subtitle', '--bitrate', '--segment',
46
54
  ]);
47
55
  const BOOL_FLAGS = new Set(['--json', '--demo', '--probe', '--help', '-h']);
48
56
 
@@ -93,6 +101,7 @@ function printHelp() {
93
101
  audio 抽音轨(动作 m4a|mp3 或 --format m4a|mp3,--bitrate)
94
102
  watermark 文字水印(--text 必填,--position tl|tr|bl|br|center,--fontsize,--opacity)
95
103
  subtitle 烧录 .srt 字幕(--subtitle 必填;需 ffmpeg 带 libass)
104
+ transcribe 语音转文字(官方 ASR,需 DEEPSEEK_API_KEY;--segment 默认 110 秒)
96
105
 
97
106
  示例:
98
107
  ${CMD_NAME} compress --crf 28 --input raw/
@@ -101,6 +110,7 @@ function printHelp() {
101
110
  ${CMD_NAME} audio mp3 --input clip.mp4
102
111
  ${CMD_NAME} watermark --text "内部资料" --position br --input clip.mp4
103
112
  ${CMD_NAME} subtitle --subtitle a.srt --input clip.mp4
113
+ ${CMD_NAME} transcribe --input 会议录音.mp4 # 联网,按 0.39 元/小时(限时 5 折)计费
104
114
  ${CMD_NAME} --probe # 探测 ffmpeg 能力(libass / drawtext / 编码器)
105
115
  ${CMD_NAME} --demo # 自造样例跑完整流程,打印产物路径
106
116
 
@@ -116,6 +126,7 @@ function printHelp() {
116
126
  · 依赖 ffmpeg / ffprobe;缺失时明确提示「缺什么、怎么装」。
117
127
  · 文字水印:有 drawtext 用 drawtext,没有则自动回退 PNG+overlay,并在结果里标明。
118
128
  · 字幕烧录:本机 ffmpeg 无 libass 时明确报「本机 ffmpeg 无字幕烧录能力」,不假装成功。
129
+ · 只有 transcribe 会把音频发给 api.deepseek.com;其余子命令全程本地。
119
130
  · 退出码:0 成功 / 2 参数或输入错误 / 3 依赖或能力缺失 / 4 执行失败。`);
120
131
  }
121
132
 
@@ -126,7 +137,7 @@ function parseArgs(argv) {
126
137
  inputs: [], out: null, json: false, demo: false, probe: false, help: false,
127
138
  crf: null, preset: null, format: null, width: null, height: null, every: null,
128
139
  fps: null, text: null, position: null, fontsize: null, opacity: null,
129
- subtitle: null, bitrate: null, positionals: [],
140
+ subtitle: null, bitrate: null, segment: null, positionals: [],
130
141
  };
131
142
  for (let i = 0; i < argv.length; i += 1) {
132
143
  const raw = argv[i];
@@ -170,6 +181,7 @@ function parseArgs(argv) {
170
181
  case '--opacity': opts.opacity = value; break;
171
182
  case '--subtitle': opts.subtitle = value; break;
172
183
  case '--bitrate': opts.bitrate = value; break;
184
+ case '--segment': opts.segment = value; break;
173
185
  default: break;
174
186
  }
175
187
  continue;
@@ -317,7 +329,7 @@ function verifyOutput(ffprobe, file, { needVideo = true, needAudio = false } = {
317
329
 
318
330
  // ------------------------------------------------------------------ 输入/输出
319
331
 
320
- function collectInputs(opts, json) {
332
+ function collectInputs(opts, json, exts = VIDEO_EXTS) {
321
333
  const seen = new Set();
322
334
  const files = [];
323
335
  for (const raw of opts.inputs) {
@@ -333,7 +345,7 @@ function collectInputs(opts, json) {
333
345
  const st = statSync(abs);
334
346
  if (st.isDirectory()) {
335
347
  const entries = readdirSync(abs)
336
- .filter((f) => VIDEO_EXTS.has(extname(f).toLowerCase()))
348
+ .filter((f) => exts.has(extname(f).toLowerCase()))
337
349
  .filter((f) => {
338
350
  try { return statSync(join(abs, f)).isFile(); } catch { return false; }
339
351
  })
@@ -342,7 +354,7 @@ function collectInputs(opts, json) {
342
354
  fail(`目录里没有视频文件:${raw}`, {
343
355
  code: 2,
344
356
  json,
345
- hint: `支持的扩展名:${[...VIDEO_EXTS].join(' ')}`,
357
+ hint: `支持的扩展名:${[...exts].join(' ')}`,
346
358
  extra: { input: raw },
347
359
  });
348
360
  }
@@ -351,11 +363,11 @@ function collectInputs(opts, json) {
351
363
  if (!seen.has(full)) { seen.add(full); files.push(full); }
352
364
  }
353
365
  } else if (st.isFile()) {
354
- if (!VIDEO_EXTS.has(extname(abs).toLowerCase())) {
355
- fail(`不是支持的视频文件:${raw}`, {
366
+ if (!exts.has(extname(abs).toLowerCase())) {
367
+ fail(`不是支持的文件:${raw}`, {
356
368
  code: 2,
357
369
  json,
358
- hint: `支持的扩展名:${[...VIDEO_EXTS].join(' ')}`,
370
+ hint: `支持的扩展名:${[...exts].join(' ')}`,
359
371
  extra: { input: raw },
360
372
  });
361
373
  }
@@ -872,6 +884,96 @@ function runDemo(opts) {
872
884
 
873
885
  // ------------------------------------------------------------------ 主流程
874
886
 
887
+
888
+ // ------------------------------------------------------------------ 语音转写(官方 ASR)
889
+
890
+ function runTranscribe(opts, env) {
891
+ const segment = opts.segment == null ? 110 : Number(opts.segment);
892
+ if (!Number.isFinite(segment) || segment < 10 || segment > 120) {
893
+ fail(`--segment 需要 10~120 的数值(官方单次上限 120 秒),收到:${opts.segment}`, {
894
+ code: 2, json: opts.json, hint: '默认 110 秒;再长会被官方接口拒绝',
895
+ });
896
+ }
897
+ const files = collectInputs(opts, opts.json, TRANSCRIBE_EXTS);
898
+ const outDir = defaultOutDir(files, opts);
899
+ mkdirSync(outDir, { recursive: true });
900
+
901
+ (async () => {
902
+ const outputs = [];
903
+ let totalSec = 0;
904
+ for (const file of files) {
905
+ const info = probeFile(env.ffprobe, file);
906
+ if (!info.ok) {
907
+ fail(`读不出音视频信息:${basename(file)}`, {
908
+ code: 4, json: opts.json, hint: info.stderr || '文件可能损坏', extra: { input: file },
909
+ });
910
+ }
911
+ const tmp = mkdtempSync(join(tmpdir(), 'dsh-media-asr-'));
912
+ try {
913
+ const r = run(env.ffmpeg, [
914
+ '-y', '-v', 'error', '-i', file,
915
+ '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le',
916
+ '-f', 'segment', '-segment_time', String(Math.floor(segment)), '-reset_timestamps', '1',
917
+ join(tmp, 'part%04d.wav'),
918
+ ]);
919
+ if (!r.ok) {
920
+ fail(`抽音频失败:${basename(file)}`, {
921
+ code: 4, json: opts.json,
922
+ hint: (r.stderr || r.error?.message || '').slice(0, 400) || '确认文件里有音轨',
923
+ extra: { input: file },
924
+ });
925
+ }
926
+ const parts = readdirSync(tmp).filter((f) => f.endsWith('.wav')).sort();
927
+ if (parts.length === 0) {
928
+ fail(`没有可转写的音频:${basename(file)}`, {
929
+ code: 4, json: opts.json, hint: '文件可能没有音轨', extra: { input: file },
930
+ });
931
+ }
932
+ const texts = [];
933
+ let ms = 0;
934
+ for (const part of parts) {
935
+ try {
936
+ const res = await asrFile(join(tmp, part));
937
+ texts.push(res.text);
938
+ ms += res.duration_ms || 0;
939
+ } catch (e) {
940
+ if (e instanceof CloudError) {
941
+ fail(e.message, { code: e.code, hint: e.hint, json: opts.json, extra: { input: file, part } });
942
+ }
943
+ fail(`转写失败:${e?.message || e}`, { code: 4, json: opts.json, extra: { input: file, part } });
944
+ }
945
+ }
946
+ const body = texts.join('\n\n');
947
+ const out = uniquePath(outDir, `${stemOf(file)}.转写`, '.txt');
948
+ writeFileSync(out, body, 'utf8');
949
+ const sec = ms / 1000;
950
+ totalSec += sec;
951
+ outputs.push({
952
+ input: file, output: out, segments: parts.length,
953
+ durationSec: Number(sec.toFixed(1)), chars: body.length,
954
+ });
955
+ if (!opts.json) {
956
+ console.log(` ✅ ${basename(file)} · ${parts.length} 段 · ${sec.toFixed(1)} 秒 · ${body.length} 字 → ${out}`);
957
+ }
958
+ } finally {
959
+ rmSync(tmp, { recursive: true, force: true });
960
+ }
961
+ }
962
+ const cost = Number((totalSec / 3600 * 0.39).toFixed(4));
963
+ if (opts.json) {
964
+ console.log(JSON.stringify({
965
+ ok: true, command: 'transcribe', outDir,
966
+ files: files.length, durationSec: Number(totalSec.toFixed(1)),
967
+ costYuan: cost, costYuanList: Number((totalSec / 3600 * 0.78).toFixed(4)), outputs,
968
+ }));
969
+ } else {
970
+ console.log('');
971
+ console.log(`✅ 转写完成:${files.length} 个文件 · ${(totalSec / 60).toFixed(1)} 分钟 · 约 ${cost} 元(ASR 限时 5 折 0.39 元/小时)`);
972
+ console.log(` 输出:${outDir}`);
973
+ }
974
+ })().catch((e) => fail(`转写执行失败:${e?.message || e}`, { code: 4, json: opts.json }));
975
+ }
976
+
875
977
  function main() {
876
978
  const argv = process.argv.slice(2);
877
979
  if (argv.length === 0) {
@@ -968,6 +1070,8 @@ function main() {
968
1070
  });
969
1071
  }
970
1072
 
1073
+ if (cmd === 'transcribe') { runTranscribe(opts, env); return; }
1074
+
971
1075
  // 数值校验
972
1076
  const intOpt = (name, raw, min, max, dflt) => {
973
1077
  if (raw == null) return dflt;
package/lib/cloud.mjs ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * 官方「全家桶应用 API」· 语音识别(ASR)客户端(Node 侧)
3
+ *
4
+ * 用途:把音频交给 DeepSeek 官方 ASR 接口,返回整段转写文本。
5
+ * 官方限制:16 位 PCM WAV 或 Ogg Opus;单次 ≤ 8 MiB;≤ 120 秒。
6
+ * 边界:只有显式跑 `transcribe` 子命令才联网,其它子命令完全本地。
7
+ */
8
+ import { readFileSync, statSync } from 'node:fs';
9
+ import { basename } from 'node:path';
10
+
11
+ const DEFAULT_BASE = 'https://api.deepseek.com';
12
+ const MAX_BYTES = 8 * 1024 * 1024;
13
+
14
+ export class CloudError extends Error {
15
+ constructor(message, { hint = '', code = 4, extra = {} } = {}) {
16
+ super(message);
17
+ this.name = 'CloudError';
18
+ this.hint = hint;
19
+ this.code = code;
20
+ this.extra = extra;
21
+ }
22
+ }
23
+
24
+ export function baseUrl() {
25
+ return (process.env.DEEPSEEK_BASE_URL || DEFAULT_BASE).replace(/\/+$/, '');
26
+ }
27
+
28
+ export function requireKey() {
29
+ const key = process.env.DEEPSEEK_API_KEY;
30
+ if (!key) {
31
+ throw new CloudError('缺少 DEEPSEEK_API_KEY', {
32
+ code: 3,
33
+ hint: 'transcribe 要调用官方 ASR 接口,需要先设置环境变量:\n export DEEPSEEK_API_KEY=sk-...\nKey 在 platform.deepseek.com 申请;不联网的本地命令不受影响。',
34
+ });
35
+ }
36
+ return key;
37
+ }
38
+
39
+ const STATUS_HINT = {
40
+ 400: '音频格式不受支持:需要 16 位 PCM WAV 或 Ogg Opus(本工具已用 ffmpeg 转 16k 单声道 WAV)',
41
+ 408: '上传未在 10 分钟内完成:换更小的片段或更好的网络',
42
+ 413: '音频超过 8 MiB:把 --segment 调小',
43
+ 429: '并发请求超上限(每账号 50 个):等前面的跑完再试',
44
+ 503: '服务繁忙:稍后重试',
45
+ 504: '识别超时:把片段切短一点',
46
+ };
47
+
48
+ function parseJsonLoose(text) {
49
+ const i = text.indexOf('{');
50
+ if (i < 0) throw new CloudError('响应不是 JSON', { hint: `返回内容前 200 字:${text.slice(0, 200)}` });
51
+ return JSON.parse(text.slice(i));
52
+ }
53
+
54
+ export function asrMaxBytes() { return MAX_BYTES; }
55
+
56
+ export async function asrFile(filePath) {
57
+ const key = requireKey();
58
+ const size = statSync(filePath).size;
59
+ if (size > MAX_BYTES) {
60
+ throw new CloudError(`音频片段超过 8 MiB(${(size / 1048576).toFixed(1)} MiB)`, {
61
+ hint: '把音频切短(--segment 调小),或降到 16 kHz 单声道',
62
+ });
63
+ }
64
+ const form = new FormData();
65
+ form.set('file', new Blob([readFileSync(filePath)]), basename(filePath));
66
+
67
+ let res;
68
+ try {
69
+ res = await fetch(`${baseUrl()}/v1/tools/asr`, {
70
+ method: 'POST',
71
+ headers: { authorization: `Bearer ${key}` },
72
+ body: form,
73
+ });
74
+ } catch (e) {
75
+ throw new CloudError('连不上 ASR 接口(网络错误)', {
76
+ hint: `目标:${baseUrl()}/v1/tools/asr\n原因:${e?.message || e}`,
77
+ });
78
+ }
79
+
80
+ const raw = await res.text();
81
+ let body;
82
+ try {
83
+ body = parseJsonLoose(raw);
84
+ } catch (e) {
85
+ throw new CloudError(`ASR 返回无法解析(HTTP ${res.status})`, {
86
+ hint: `返回内容前 200 字:${raw.slice(0, 200)}`,
87
+ extra: { status: res.status },
88
+ });
89
+ }
90
+ if (body.error) {
91
+ throw new CloudError(`ASR 失败:${body.error.message || '未知错误'}`, {
92
+ hint: STATUS_HINT[res.status] || '参见官方文档 /zh-cn/guides/asr',
93
+ extra: { status: res.status, error: body.error },
94
+ });
95
+ }
96
+ if (!res.ok) {
97
+ throw new CloudError(`ASR 请求失败(HTTP ${res.status})`, {
98
+ code: 4,
99
+ hint: STATUS_HINT[res.status] || '参见官方文档 /zh-cn/guides/asr',
100
+ extra: { status: res.status },
101
+ });
102
+ }
103
+ if (typeof body.text !== 'string') {
104
+ throw new CloudError('ASR 响应缺少 text 字段', { hint: '接口结构可能已变更,参见官方文档' });
105
+ }
106
+ return body;
107
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepwhale-cn/dsh-media-batch",
3
- "version": "0.1.0",
4
- "description": "视频批量压缩 / 转码 / 截帧 / 加字幕 / 加水印 / 提取音频(ffmpeg)",
3
+ "version": "0.1.1",
4
+ "description": "视频批量压缩 / 转码 / 截帧 / 加字幕 / 加水印 / 提取音频(ffmpeg);可选用官方 ASR 转写",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "keywords": ["dsh", "dsh-plugin", "deepseek-harness", "skill", "tool", "media-batch"],
@@ -58,3 +58,16 @@
58
58
 
59
59
  把产物路径 + 可核对数据(压缩前后字节、ffprobe 编码/分辨率/时长、抽帧数量、抽音轨编码、
60
60
  水印 renderer、字幕能力分支)告诉用户;产物不要用「应该可以」描述,以磁盘和 ffprobe 输出为准。
61
+
62
+
63
+ ## 语音转写(需要联网,先确认用户知情)
64
+
65
+ 要文字而不是音轨时:
66
+
67
+ ```sh
68
+ export DEEPSEEK_API_KEY=sk-...
69
+ dsh-media-batch transcribe --input <文件或目录> --segment 110 --out <目录>
70
+ ```
71
+
72
+ - 计费 0.39 元/小时(限时 5 折);先告诉用户「会上传音频到 api.deepseek.com,按时长计费」。
73
+ - 长音频自动切片,无需手动分段;其它子命令不联网。