dsh-ffmpeg 0.1.0

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.en.md ADDED
@@ -0,0 +1,68 @@
1
+ [中文](README.md)
2
+
3
+ # dsh-ffmpeg
4
+
5
+ DSH (DeepSeek Harness) video-processing plugin: seven tools covering probing, cutting, concatenation, transcoding, subtitles, extraction and GIF creation — all powered by ffmpeg/ffprobe.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ dsh plugin --profile web add dsh-ffmpeg
11
+ ```
12
+
13
+ ffmpeg must be installed locally (`ffmpeg -version` should work); use `ffmpegPath` / `ffprobePath` when it is not on PATH.
14
+
15
+ ## Configuration
16
+
17
+ Override the plugin row in your profile's `cordis.patch.yml` (defaults apply when absent):
18
+
19
+ ```yaml
20
+ - id: ffmpeg
21
+ name: 'dsh-ffmpeg'
22
+ config:
23
+ # ffmpegPath: C:\tools\ffmpeg\bin\ffmpeg.exe # explicit path when not on PATH
24
+ # ffprobePath: C:\tools\ffmpeg\bin\ffprobe.exe
25
+ timeoutMs: 300000 # per-operation timeout (default 5 min, 10s - 2h)
26
+ # overwrite: true # allow overwriting outputs (default auto-suffix _1/_2)
27
+ ```
28
+
29
+ ## Tools
30
+
31
+ | Tool | Purpose | Key parameters |
32
+ | :-- | :-- | :-- |
33
+ | `ffmpeg_probe` | Probe media info (format/duration/resolution/fps/bitrate/audio/subtitle streams) | `input` required |
34
+ | `ffmpeg_cut` | Cut a clip (stream copy by default, accurate re-encode optional) | `input` required; `start`/`end`/`duration` |
35
+ | `ffmpeg_concat` | Concatenate 2-20 clips (stream copy for identical codecs / re-encode for mixed) | `inputs` array required |
36
+ | `ffmpeg_encode` | Transcode with presets (bilibili 1080p/4K, vertical 1080p, web-720p) plus crf/fps/scale overrides | `input` required; `preset` optional |
37
+ | `ffmpeg_subtitle` | Burn subtitles (SRT/ASS hard subs) | `input` + `subtitle` required |
38
+ | `ffmpeg_extract` | Extract audio (m4a) / frame sequences / single frame / subtitle stream | `input` + `what` required |
39
+ | `ffmpeg_gif` | Video to high-quality GIF (two-pass palette) | `input` required; `fps`/`width`/`duration` optional |
40
+
41
+ ### Examples
42
+
43
+ ```text
44
+ ffmpeg_probe { input: E:\videos\raw.mp4 }
45
+ ffmpeg_cut { input: E:\videos\raw.mp4, start: 10, end: 30 }
46
+ ffmpeg_encode { input: E:\videos\raw.mp4, preset: bilibili-1080p }
47
+ ffmpeg_subtitle { input: E:\videos\raw.mp4, subtitle: E:\videos\subs.srt }
48
+ ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
49
+ ```
50
+
51
+ ## Safety
52
+
53
+ - **No shell**: every argument is passed as its own argv element — user input cannot inject commands
54
+ - **Runs on the official DSH subprocess service**: tree-scoped termination (timeout escalates SIGTERM → kill; taskkill /T on Windows), zero runtime dependencies
55
+ - **No accidental overwrites**: existing outputs get auto-suffixed; output == input is rejected
56
+ - **Timeout clamps**: per-operation 10s - 2h; probes additionally capped at 60s
57
+ - **Input validation**: time formats, preset enums, crf/fps/scale ranges are validated up front with actionable errors
58
+
59
+ ## Development
60
+
61
+ ```bash
62
+ pnpm install
63
+ pnpm test # build + 50 tests, including a real-ffmpeg end-to-end suite (auto-skipped without ffmpeg)
64
+ ```
65
+
66
+ ## License
67
+
68
+ MIT
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ [English](README.en.md)
2
+
3
+ # dsh-ffmpeg
4
+
5
+ DSH(DeepSeek Harness)视频处理工具插件:七个工具覆盖探测、剪辑、拼接、转码、字幕、提取与 GIF 制作,全部由 ffmpeg/ffprobe 完成。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ dsh plugin --profile web add dsh-ffmpeg
11
+ ```
12
+
13
+ 需要本机已安装 ffmpeg(`ffmpeg -version` 能出结果即可);不在 PATH 上时用 `ffmpegPath`/`ffprobePath` 显式指定。
14
+
15
+ ## 配置
16
+
17
+ 在你自己的 profile 的 `cordis.patch.yml` 里覆盖本插件行(缺省时全部用默认值):
18
+
19
+ ```yaml
20
+ - id: ffmpeg
21
+ name: 'dsh-ffmpeg'
22
+ config:
23
+ # ffmpegPath: C:\tools\ffmpeg\bin\ffmpeg.exe # 非 PATH 时显式指定
24
+ # ffprobePath: C:\tools\ffmpeg\bin\ffprobe.exe
25
+ timeoutMs: 300000 # 单次操作超时(默认 5 分钟,10 秒 - 2 小时)
26
+ # overwrite: true # 允许覆盖同名输出(默认自动加 _1/_2 序号)
27
+ ```
28
+
29
+ ## 工具一览
30
+
31
+ | 工具 | 作用 | 关键参数 |
32
+ | :-- | :-- | :-- |
33
+ | `ffmpeg_probe` | 探测媒体信息(格式/时长/分辨率/帧率/码率/音轨/字幕轨) | `input` 必填 |
34
+ | `ffmpeg_cut` | 剪辑片段(默认流拷贝秒级,可精确重编码) | `input` 必填;`start`/`end`/`duration` |
35
+ | `ffmpeg_concat` | 拼接 2-20 个片段(同编码流拷贝 / 混合编码重编码) | `inputs` 数组必填 |
36
+ | `ffmpeg_encode` | 转码(B 站 1080p/4K、竖屏 1080p、web-720p 预设 + crf/fps/scale 覆盖) | `input` 必填;`preset` 可选 |
37
+ | `ffmpeg_subtitle` | 字幕烧录(SRT/ASS 硬字幕) | `input`+`subtitle` 必填 |
38
+ | `ffmpeg_extract` | 提取音轨(m4a)/ 抽帧序列 / 单帧 / 字幕流 | `input`+`what` 必填 |
39
+ | `ffmpeg_gif` | 视频转高质量 GIF(两遍调色板) | `input` 必填;`fps`/`width`/`duration` 可选 |
40
+
41
+ ### 示例
42
+
43
+ ```text
44
+ ffmpeg_probe { input: E:\videos\raw.mp4 }
45
+ ffmpeg_cut { input: E:\videos\raw.mp4, start: 10, end: 30 }
46
+ ffmpeg_encode { input: E:\videos\raw.mp4, preset: bilibili-1080p }
47
+ ffmpeg_subtitle { input: E:\videos\raw.mp4, subtitle: E:\videos\subs.srt }
48
+ ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
49
+ ```
50
+
51
+ ## 安全设计
52
+
53
+ - **无 shell**:所有参数以独立 argv 数组传递,用户输入不可能注入命令
54
+ - **进程走 DSH 官方 subprocess 服务**:树级终止(超时自动 SIGTERM → 强杀,Windows taskkill /T),本插件零运行时依赖
55
+ - **防覆写**:默认不覆盖同名文件(自动加序号),输出与输入相同直接拒绝
56
+ - **超时钳制**:单次操作 10 秒 - 2 小时;探测额外限制 60 秒
57
+ - **参数校验**:时间格式、预设枚举、crf/fps/scale 范围全部前置校验,错误带中文指引
58
+
59
+ ## 开发
60
+
61
+ ```bash
62
+ pnpm install
63
+ pnpm test # 构建 + 50 个测试(含真实 ffmpeg 端到端集成,缺 ffmpeg 自动跳过)
64
+ ```
65
+
66
+ ## License
67
+
68
+ MIT
@@ -0,0 +1,18 @@
1
+ # dsh-ffmpeg 组合包补丁:把视频处理工具插件行插入 profile。
2
+ # 本补丁按 id 覆盖;用户应在自己 profile 的 cordis.patch.yml 里覆盖本行的整个
3
+ # config(见 README 的配置示例)。配置缺失时插件仍会加载(依赖 PATH 上的 ffmpeg)。
4
+ #
5
+ # 示例(用户 profile 的 cordis.patch.yml):
6
+ # - id: ffmpeg
7
+ # name: 'dsh-ffmpeg'
8
+ # config:
9
+ # # ffmpegPath: C:\tools\ffmpeg\bin\ffmpeg.exe # 非 PATH 时显式指定
10
+ # # ffprobePath: C:\tools\ffmpeg\bin\ffprobe.exe
11
+ # timeoutMs: 300000 # 单次操作超时(默认 5 分钟)
12
+ # # overwrite: true # 允许覆盖同名输出(默认自动加后缀)
13
+ #
14
+ - insert:
15
+ - id: ffmpeg
16
+ name: 'dsh-ffmpeg'
17
+ config:
18
+ timeoutMs: 300000
package/lib/args.d.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * ffmpeg/ffprobe 命令行构建器:纯函数,输入业务参数输出完整 argv 数组(argv[0] 为程序)。
3
+ * 所有参数以独立数组元素传递,绝不经过 shell 解释——用户输入无法注入命令。
4
+ *
5
+ * @module dsh-ffmpeg/args
6
+ */
7
+ /** 秒数格式化为 ffmpeg 友好的定点字符串。 */
8
+ export declare function fmtSeconds(seconds: number): string;
9
+ /** 转义 filter 路径(Windows 冒号与反斜杠、单引号)。 */
10
+ export declare function escapeFilterPath(path: string): string;
11
+ /** ffprobe 探测命令。 */
12
+ export declare function probeArgs(ffprobe: string, input: string): string[];
13
+ export interface CutArgsSpec {
14
+ input: string;
15
+ start: number;
16
+ duration: number;
17
+ output: string;
18
+ overwrite: boolean;
19
+ reencode: boolean;
20
+ }
21
+ /** 剪辑:流拷贝(快、关键帧对齐)或重编码(精确到帧)。 */
22
+ export declare function cutArgs(ffmpeg: string, spec: CutArgsSpec): string[];
23
+ export interface ConcatSpec {
24
+ inputs: string[];
25
+ listFilePath?: string;
26
+ output: string;
27
+ overwrite: boolean;
28
+ reencode: boolean;
29
+ }
30
+ /** 拼接:同编码流拷贝走 concat demuxer(需 list 文件),否则 filter_complex 重编码。 */
31
+ export declare function concatArgs(ffmpeg: string, spec: ConcatSpec): string[];
32
+ /** concat demuxer 的 list 文件内容(路径中的单引号按 ffmpeg 规则转义)。 */
33
+ export declare function concatListContent(inputs: string[]): string;
34
+ export type EncodePreset = 'bilibili-1080p' | 'bilibili-4k' | 'vertical-1080p' | 'web-720p';
35
+ export declare const ENCODE_PRESETS: EncodePreset[];
36
+ export interface EncodeSpec {
37
+ input: string;
38
+ output: string;
39
+ preset: EncodePreset;
40
+ crf?: number;
41
+ fps?: number;
42
+ scale?: string;
43
+ overwrite: boolean;
44
+ }
45
+ /** 转码:预设 + 可选的 crf/fps/scale 覆盖。 */
46
+ export declare function encodeArgs(ffmpeg: string, spec: EncodeSpec): string[];
47
+ export interface SubtitleSpec {
48
+ input: string;
49
+ subtitle: string;
50
+ output: string;
51
+ overwrite: boolean;
52
+ }
53
+ /** 字幕烧录(subtitles filter,路径转义)。 */
54
+ export declare function subtitleArgs(ffmpeg: string, spec: SubtitleSpec): string[];
55
+ export type ExtractWhat = 'audio' | 'frames' | 'frame' | 'subtitle';
56
+ export interface ExtractSpec {
57
+ input: string;
58
+ what: ExtractWhat;
59
+ output: string;
60
+ overwrite: boolean;
61
+ start?: number;
62
+ duration?: number;
63
+ fps?: number;
64
+ streamIndex: number;
65
+ }
66
+ /** 提取:音频(拷贝)/ 抽帧序列 / 单帧 / 字幕流。 */
67
+ export declare function extractArgs(ffmpeg: string, spec: ExtractSpec): string[];
68
+ export interface GifSpec {
69
+ input: string;
70
+ output: string;
71
+ palettePath: string;
72
+ overwrite: boolean;
73
+ start: number;
74
+ duration: number;
75
+ fps: number;
76
+ width: number;
77
+ }
78
+ /** GIF 第一遍:调色板生成(palettegen)。 */
79
+ export declare function gifPaletteArgs(ffmpeg: string, spec: GifSpec): string[];
80
+ /** GIF 第二遍:paletteuse 合成。 */
81
+ export declare function gifUseArgs(ffmpeg: string, spec: GifSpec): string[];
package/lib/args.js ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * ffmpeg/ffprobe 命令行构建器:纯函数,输入业务参数输出完整 argv 数组(argv[0] 为程序)。
3
+ * 所有参数以独立数组元素传递,绝不经过 shell 解释——用户输入无法注入命令。
4
+ *
5
+ * @module dsh-ffmpeg/args
6
+ */
7
+ /** 秒数格式化为 ffmpeg 友好的定点字符串。 */
8
+ export function fmtSeconds(seconds) {
9
+ return seconds.toFixed(3);
10
+ }
11
+ /** 覆写标志:不覆写用 -n(目标存在即报错,双保险),覆写用 -y。 */
12
+ function overwriteFlag(overwrite) {
13
+ return overwrite ? '-y' : '-n';
14
+ }
15
+ /** 转义 filter 路径(Windows 冒号与反斜杠、单引号)。 */
16
+ export function escapeFilterPath(path) {
17
+ return path.replace(/\\/g, '/').replace(/:/g, '\\:').replace(/'/g, "\\'");
18
+ }
19
+ /** ffprobe 探测命令。 */
20
+ export function probeArgs(ffprobe, input) {
21
+ return [ffprobe, '-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', input];
22
+ }
23
+ /** 剪辑:流拷贝(快、关键帧对齐)或重编码(精确到帧)。 */
24
+ export function cutArgs(ffmpeg, spec) {
25
+ const flag = overwriteFlag(spec.overwrite);
26
+ if (!spec.reencode) {
27
+ return [ffmpeg, flag, '-ss', fmtSeconds(spec.start), '-i', spec.input, '-t', fmtSeconds(spec.duration), '-c', 'copy', '-avoid_negative_ts', 'make_zero', spec.output];
28
+ }
29
+ return [ffmpeg, flag, '-i', spec.input, '-ss', fmtSeconds(spec.start), '-t', fmtSeconds(spec.duration), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-c:a', 'aac', spec.output];
30
+ }
31
+ /** 拼接:同编码流拷贝走 concat demuxer(需 list 文件),否则 filter_complex 重编码。 */
32
+ export function concatArgs(ffmpeg, spec) {
33
+ const flag = overwriteFlag(spec.overwrite);
34
+ if (!spec.reencode) {
35
+ return [ffmpeg, flag, '-f', 'concat', '-safe', '0', '-i', spec.listFilePath ?? '', '-c', 'copy', spec.output];
36
+ }
37
+ const parts = [ffmpeg, flag];
38
+ for (const input of spec.inputs)
39
+ parts.push('-i', input);
40
+ parts.push('-filter_complex', 'concat=n=' + spec.inputs.length + ':v=1:a=1', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-c:a', 'aac', spec.output);
41
+ return parts;
42
+ }
43
+ /** concat demuxer 的 list 文件内容(路径中的单引号按 ffmpeg 规则转义)。 */
44
+ export function concatListContent(inputs) {
45
+ return inputs.map((input) => "file '" + input.replace(/\\/g, '/').replace(/'/g, "'\\''") + "'").join('\n') + '\n';
46
+ }
47
+ export const ENCODE_PRESETS = ['bilibili-1080p', 'bilibili-4k', 'vertical-1080p', 'web-720p'];
48
+ const PRESET_TABLE = {
49
+ 'bilibili-1080p': { crf: 20, maxrate: '6000k', bufsize: '12000k' },
50
+ 'bilibili-4k': { crf: 18, maxrate: '20000k', bufsize: '40000k' },
51
+ 'vertical-1080p': { crf: 20, maxrate: '6000k', bufsize: '12000k', vf: 'scale=-2:1920' },
52
+ 'web-720p': { crf: 23, maxrate: '2800k', bufsize: '5600k', vf: 'scale=-2:720' },
53
+ };
54
+ /** 转码:预设 + 可选的 crf/fps/scale 覆盖。 */
55
+ export function encodeArgs(ffmpeg, spec) {
56
+ const preset = PRESET_TABLE[spec.preset];
57
+ const crf = spec.crf ?? preset.crf;
58
+ const parts = [ffmpeg, overwriteFlag(spec.overwrite), '-i', spec.input];
59
+ const vf = spec.scale !== undefined && spec.scale !== '' ? 'scale=' + spec.scale : preset.vf;
60
+ if (vf !== undefined && vf !== '')
61
+ parts.push('-vf', vf);
62
+ if (spec.fps !== undefined)
63
+ parts.push('-r', String(spec.fps));
64
+ parts.push('-c:v', 'libx264', '-preset', 'medium', '-crf', String(crf), '-maxrate', preset.maxrate, '-bufsize', preset.bufsize, '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', spec.output);
65
+ return parts;
66
+ }
67
+ /** 字幕烧录(subtitles filter,路径转义)。 */
68
+ export function subtitleArgs(ffmpeg, spec) {
69
+ const filter = "subtitles='" + escapeFilterPath(spec.subtitle) + "'";
70
+ return [ffmpeg, overwriteFlag(spec.overwrite), '-i', spec.input, '-vf', filter, '-c:a', 'copy', spec.output];
71
+ }
72
+ /** 提取:音频(拷贝)/ 抽帧序列 / 单帧 / 字幕流。 */
73
+ export function extractArgs(ffmpeg, spec) {
74
+ const flag = overwriteFlag(spec.overwrite);
75
+ if (spec.what === 'audio') {
76
+ return [ffmpeg, flag, '-i', spec.input, '-vn', '-c', 'copy', spec.output];
77
+ }
78
+ if (spec.what === 'subtitle') {
79
+ return [ffmpeg, flag, '-i', spec.input, '-map', '0:s:' + spec.streamIndex, '-c', 'copy', spec.output];
80
+ }
81
+ if (spec.what === 'frame') {
82
+ const parts = [ffmpeg, flag, '-i', spec.input];
83
+ if (spec.start !== undefined)
84
+ parts.push('-ss', fmtSeconds(spec.start));
85
+ parts.push('-frames:v', '1', spec.output);
86
+ return parts;
87
+ }
88
+ // frames 序列
89
+ const parts = [ffmpeg, flag, '-i', spec.input];
90
+ if (spec.start !== undefined)
91
+ parts.push('-ss', fmtSeconds(spec.start));
92
+ if (spec.duration !== undefined)
93
+ parts.push('-t', fmtSeconds(spec.duration));
94
+ parts.push('-vf', 'fps=' + (spec.fps ?? 1), spec.output);
95
+ return parts;
96
+ }
97
+ /** GIF 第一遍:调色板生成(palettegen)。 */
98
+ export function gifPaletteArgs(ffmpeg, spec) {
99
+ const filter = 'fps=' + spec.fps + ',scale=' + spec.width + ':-1:flags=lanczos,palettegen';
100
+ return [ffmpeg, '-y', '-i', spec.input, '-ss', fmtSeconds(spec.start), '-t', fmtSeconds(spec.duration), '-vf', filter, spec.palettePath];
101
+ }
102
+ /** GIF 第二遍:paletteuse 合成。 */
103
+ export function gifUseArgs(ffmpeg, spec) {
104
+ const filter = 'fps=' + spec.fps + ',scale=' + spec.width + ':-1:flags=lanczos[x];[x][1:v]paletteuse';
105
+ return [ffmpeg, overwriteFlag(spec.overwrite), '-i', spec.input, '-ss', fmtSeconds(spec.start), '-t', fmtSeconds(spec.duration), '-i', spec.palettePath, '-filter_complex', filter, spec.output];
106
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * dsh-ffmpeg 配置解析:ffmpeg/ffprobe 路径、超时、覆写策略。
3
+ * 缺失字段给默认值;非法字段抛中文错误。
4
+ *
5
+ * @module dsh-ffmpeg/config
6
+ */
7
+ /** 插件行配置(cordis.patch.yml 里的 config 段,可缺省)。 */
8
+ export interface FfmpegConfig {
9
+ ffmpegPath?: string;
10
+ ffprobePath?: string;
11
+ timeoutMs?: number;
12
+ graceMs?: number;
13
+ overwrite?: boolean;
14
+ }
15
+ /** 解析后的配置:所有字段都有值。 */
16
+ export interface ResolvedFfmpegConfig {
17
+ ffmpegPath: string;
18
+ ffprobePath: string;
19
+ timeoutMs: number;
20
+ graceMs: number;
21
+ overwrite: boolean;
22
+ }
23
+ /**
24
+ * 解析并校验配置。
25
+ * @param config - 插件行配置(可能为 undefined/null)。
26
+ * @throws 配置值非法时抛出中文错误。
27
+ */
28
+ export declare function resolveConfig(config: FfmpegConfig | undefined | null): ResolvedFfmpegConfig;
29
+ /**
30
+ * 解析时间参数:接受秒数(正数)或 HH:MM:SS[.mmm] 字符串。
31
+ * 返回秒(浮点);非法返回 null。
32
+ */
33
+ export declare function parseTimeArg(value: unknown): number | null;
package/lib/config.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * dsh-ffmpeg 配置解析:ffmpeg/ffprobe 路径、超时、覆写策略。
3
+ * 缺失字段给默认值;非法字段抛中文错误。
4
+ *
5
+ * @module dsh-ffmpeg/config
6
+ */
7
+ const DEFAULT_TIMEOUT_MS = 300000;
8
+ const DEFAULT_GRACE_MS = 15000;
9
+ /**
10
+ * 解析并校验配置。
11
+ * @param config - 插件行配置(可能为 undefined/null)。
12
+ * @throws 配置值非法时抛出中文错误。
13
+ */
14
+ export function resolveConfig(config) {
15
+ const cfg = config ?? {};
16
+ const ffmpegPath = typeof cfg.ffmpegPath === 'string' && cfg.ffmpegPath.trim() !== '' ? cfg.ffmpegPath.trim() : 'ffmpeg';
17
+ const ffprobePath = typeof cfg.ffprobePath === 'string' && cfg.ffprobePath.trim() !== '' ? cfg.ffprobePath.trim() : 'ffprobe';
18
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
19
+ if (cfg.timeoutMs !== undefined) {
20
+ if (typeof cfg.timeoutMs !== 'number' || !Number.isFinite(cfg.timeoutMs) || cfg.timeoutMs <= 0) {
21
+ throw new Error('timeoutMs 必须是大于 0 的数字(毫秒),例如 300000。');
22
+ }
23
+ timeoutMs = Math.min(2 * 60 * 60 * 1000, Math.max(10000, Math.round(cfg.timeoutMs)));
24
+ }
25
+ let graceMs = DEFAULT_GRACE_MS;
26
+ if (cfg.graceMs !== undefined) {
27
+ if (typeof cfg.graceMs !== 'number' || !Number.isFinite(cfg.graceMs) || cfg.graceMs <= 0) {
28
+ throw new Error('graceMs 必须是大于 0 的数字(毫秒),例如 15000。');
29
+ }
30
+ graceMs = Math.min(120000, Math.max(1000, Math.round(cfg.graceMs)));
31
+ }
32
+ const overwrite = cfg.overwrite === true;
33
+ return { ffmpegPath, ffprobePath, timeoutMs, graceMs, overwrite };
34
+ }
35
+ /**
36
+ * 解析时间参数:接受秒数(正数)或 HH:MM:SS[.mmm] 字符串。
37
+ * 返回秒(浮点);非法返回 null。
38
+ */
39
+ export function parseTimeArg(value) {
40
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
41
+ return value;
42
+ if (typeof value === 'string') {
43
+ const text = value.trim();
44
+ if (text === '')
45
+ return null;
46
+ const clock = /^(\d{1,3}):([0-5]\d):([0-5]\d)(\.\d+)?$/.exec(text);
47
+ if (clock) {
48
+ return Number(clock[1]) * 3600 + Number(clock[2]) * 60 + Number(clock[3]) + (clock[4] ? Number(clock[4]) : 0);
49
+ }
50
+ const seconds = Number(text);
51
+ if (Number.isFinite(seconds) && seconds >= 0)
52
+ return seconds;
53
+ }
54
+ return null;
55
+ }
package/lib/exec.d.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 进程执行层:把 DSH 官方 subprocess 服务包装成 Promise 式的 ProcessRunner。
3
+ * 全程 argv 数组、无 shell 解释——杜绝命令注入。
4
+ *
5
+ * @module dsh-ffmpeg/exec
6
+ */
7
+ /** 一次运行的结果。 */
8
+ export interface RunResult {
9
+ exitCode: number | null;
10
+ signal: string | null;
11
+ stdout: string;
12
+ stderr: string;
13
+ }
14
+ /** 可注入的进程执行器(生产用 subprocess 服务,测试用假实现)。 */
15
+ export interface ProcessRunner {
16
+ run(argv: readonly string[], options?: {
17
+ timeoutMs?: number;
18
+ }): Promise<RunResult>;
19
+ }
20
+ /** 工具所需的 ctx.subprocess 最小面。 */
21
+ export interface SubprocessHandleLike {
22
+ done: Promise<{
23
+ exitCode: number | null;
24
+ signal: string | null;
25
+ }>;
26
+ collected: {
27
+ stdout?: {
28
+ readFrom(offset: number): {
29
+ text: string;
30
+ };
31
+ };
32
+ stderr?: {
33
+ readFrom(offset: number): {
34
+ text: string;
35
+ };
36
+ };
37
+ };
38
+ terminate(): void;
39
+ }
40
+ export interface SubprocessSpawnLike {
41
+ (spec: {
42
+ argv: readonly string[];
43
+ cwd: string;
44
+ stdio: {
45
+ stdin: 'ignore';
46
+ stdout: {
47
+ maxBytes: number;
48
+ };
49
+ stderr: {
50
+ maxBytes: number;
51
+ };
52
+ };
53
+ graceMs: number;
54
+ signal?: AbortSignal;
55
+ }): SubprocessHandleLike;
56
+ }
57
+ /**
58
+ * 用 DSH subprocess 服务构造 ProcessRunner:collect 模式收流,AbortSignal 驱动超时,
59
+ * 超时自动触发 terminate 树级升级(SIGTERM → graceMs → SIGKILL / Windows 立即强杀)。
60
+ */
61
+ export declare function createSubprocessRunner(spawn: SubprocessSpawnLike, graceMs: number, defaultTimeoutMs: number): ProcessRunner;
package/lib/exec.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 进程执行层:把 DSH 官方 subprocess 服务包装成 Promise 式的 ProcessRunner。
3
+ * 全程 argv 数组、无 shell 解释——杜绝命令注入。
4
+ *
5
+ * @module dsh-ffmpeg/exec
6
+ */
7
+ const COLLECT_BYTES = 4 * 1024 * 1024;
8
+ /**
9
+ * 用 DSH subprocess 服务构造 ProcessRunner:collect 模式收流,AbortSignal 驱动超时,
10
+ * 超时自动触发 terminate 树级升级(SIGTERM → graceMs → SIGKILL / Windows 立即强杀)。
11
+ */
12
+ export function createSubprocessRunner(spawn, graceMs, defaultTimeoutMs) {
13
+ return {
14
+ async run(argv, options) {
15
+ const timeoutMs = options?.timeoutMs ?? defaultTimeoutMs;
16
+ const controller = new AbortController();
17
+ const timer = setTimeout(() => controller.abort(new Error('ffmpeg operation timed out')), timeoutMs);
18
+ let handle;
19
+ try {
20
+ handle = spawn({
21
+ argv,
22
+ cwd: process.cwd(),
23
+ stdio: {
24
+ stdin: 'ignore',
25
+ stdout: { maxBytes: COLLECT_BYTES },
26
+ stderr: { maxBytes: COLLECT_BYTES },
27
+ },
28
+ graceMs,
29
+ signal: controller.signal,
30
+ });
31
+ }
32
+ finally {
33
+ clearTimeout(timer);
34
+ }
35
+ const outcome = await handle.done;
36
+ const stdout = handle.collected.stdout?.readFrom(0).text ?? '';
37
+ const stderr = handle.collected.stderr?.readFrom(0).text ?? '';
38
+ return { exitCode: outcome.exitCode, signal: outcome.signal, stdout, stderr };
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * ffprobe JSON 输出解析:格式信息 + 视频/音频/字幕流归一化。
3
+ *
4
+ * @module dsh-ffmpeg/ffprobe
5
+ */
6
+ /** 视频流信息。 */
7
+ export interface VideoStreamInfo {
8
+ width: number;
9
+ height: number;
10
+ fps: number | null;
11
+ codec: string;
12
+ durationSeconds: number | null;
13
+ bitrate: number | null;
14
+ }
15
+ /** 音频流信息。 */
16
+ export interface AudioStreamInfo {
17
+ codec: string;
18
+ sampleRate: number | null;
19
+ channels: number | null;
20
+ durationSeconds: number | null;
21
+ }
22
+ /** 字幕流信息。 */
23
+ export interface SubtitleStreamInfo {
24
+ codec: string;
25
+ language: string | null;
26
+ }
27
+ /** 归一化后的媒体信息。 */
28
+ export interface MediaInfo {
29
+ formatName: string;
30
+ durationSeconds: number | null;
31
+ sizeBytes: number | null;
32
+ bitrate: number | null;
33
+ video: VideoStreamInfo | null;
34
+ audio: AudioStreamInfo[];
35
+ subtitles: SubtitleStreamInfo[];
36
+ }
37
+ /**
38
+ * 解析 ffprobe -print_format json 输出。
39
+ * @param text - ffprobe 的 stdout。
40
+ * @throws 输出不是合法 JSON 时抛中文错误。
41
+ */
42
+ export declare function parseProbeJson(text: string): MediaInfo;
package/lib/ffprobe.js ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * ffprobe JSON 输出解析:格式信息 + 视频/音频/字幕流归一化。
3
+ *
4
+ * @module dsh-ffmpeg/ffprobe
5
+ */
6
+ function num(value) {
7
+ if (typeof value === 'number' && Number.isFinite(value))
8
+ return value;
9
+ if (typeof value === 'string' && value.trim() !== '' && value.trim().toLowerCase() !== 'n/a') {
10
+ const parsed = Number(value);
11
+ if (Number.isFinite(parsed))
12
+ return parsed;
13
+ }
14
+ return null;
15
+ }
16
+ function str(value) {
17
+ return typeof value === 'string' ? value : '';
18
+ }
19
+ /**
20
+ * 解析帧率字符串(如 30000/1001)为浮点;失败返回 null。
21
+ */
22
+ function parseFps(value) {
23
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0)
24
+ return value;
25
+ if (typeof value === 'string') {
26
+ const parts = value.split('/');
27
+ if (parts.length === 2) {
28
+ const top = Number(parts[0]);
29
+ const bottom = Number(parts[1]);
30
+ if (Number.isFinite(top) && Number.isFinite(bottom) && bottom !== 0)
31
+ return top / bottom;
32
+ }
33
+ const parsed = Number(value);
34
+ if (Number.isFinite(parsed) && parsed > 0)
35
+ return parsed;
36
+ }
37
+ return null;
38
+ }
39
+ /**
40
+ * 解析 ffprobe -print_format json 输出。
41
+ * @param text - ffprobe 的 stdout。
42
+ * @throws 输出不是合法 JSON 时抛中文错误。
43
+ */
44
+ export function parseProbeJson(text) {
45
+ let raw;
46
+ try {
47
+ raw = JSON.parse(text);
48
+ }
49
+ catch (error) {
50
+ throw new Error('ffprobe 输出解析失败:' + (error instanceof Error ? error.message : String(error)));
51
+ }
52
+ const root = (typeof raw === 'object' && raw !== null ? raw : {});
53
+ const format = (typeof root.format === 'object' && root.format !== null ? root.format : {});
54
+ const streams = Array.isArray(root.streams) ? root.streams : [];
55
+ let video = null;
56
+ const audio = [];
57
+ const subtitles = [];
58
+ for (const stream of streams) {
59
+ const codecType = str(stream.codec_type);
60
+ if (codecType === 'video' && video === null) {
61
+ video = {
62
+ width: num(stream.width) ?? 0,
63
+ height: num(stream.height) ?? 0,
64
+ fps: parseFps(stream.avg_frame_rate) ?? parseFps(stream.r_frame_rate),
65
+ codec: str(stream.codec_name),
66
+ durationSeconds: num(stream.duration) ?? null,
67
+ bitrate: num(stream.bit_rate) ?? null,
68
+ };
69
+ }
70
+ else if (codecType === 'audio') {
71
+ audio.push({
72
+ codec: str(stream.codec_name),
73
+ sampleRate: num(stream.sample_rate) ?? null,
74
+ channels: num(stream.channels) ?? null,
75
+ durationSeconds: num(stream.duration) ?? null,
76
+ });
77
+ }
78
+ else if (codecType === 'subtitle') {
79
+ const tags = (typeof stream.tags === 'object' && stream.tags !== null ? stream.tags : {});
80
+ subtitles.push({
81
+ codec: str(stream.codec_name),
82
+ language: typeof tags.language === 'string' ? tags.language : null,
83
+ });
84
+ }
85
+ }
86
+ return {
87
+ formatName: str(format.format_name),
88
+ durationSeconds: num(format.duration) ?? null,
89
+ sizeBytes: num(format.size) ?? null,
90
+ bitrate: num(format.bit_rate) ?? null,
91
+ video,
92
+ audio,
93
+ subtitles,
94
+ };
95
+ }