dsh-ffmpeg 0.5.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -4,6 +4,11 @@
4
4
 
5
5
  [历史英文记录](docs/CHANGELOG.en.md)
6
6
 
7
+ ## 0.5.1(2026-10-26)
8
+
9
+ - 抽帧默认使用每次独立的 run 目录;显式帧序列检查实际文件冲突。输出保护覆盖所有拼接输入、字幕源、符号链接及硬链接别名。
10
+ - Windows 回归 114 项通过、1 项跳过;宿主与未验收范围见[本次记录](docs/validation-0.5.1.md)。
11
+
7
12
  ## 0.5.0 (2026-10-15)
8
13
 
9
14
  - 修复 GIF 的起点和时长选项作用到错误输入,调色板以单帧写入;非零起点也能按指定区间导出。
package/docs/USAGE.en.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  [Overview](../README.en.md) · [Changelog](../CHANGELOG.md) · [Validation](VALIDATION.md)
4
4
 
5
+ ## Frame output and source protection
6
+
7
+ Default frame sequences go to a fresh `<input>-frames/run-*/` directory. An explicit pattern needs `overwrite=true` for existing outputs; even then it cannot overwrite any input/subtitle source or its file aliases.
8
+
5
9
  ## Current improvements
6
10
 
7
11
  Relative paths resolve against the current session workspace. Explicit start / duration values must be valid; invalid values never silently fall back to full-video processing.
@@ -71,7 +75,7 @@ ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
71
75
 
72
76
  ```bash
73
77
  pnpm install
74
- pnpm test # build + 110 tests
78
+ pnpm test # build + 115 tests
75
79
  ```
76
80
 
77
81
  ## License
package/docs/USAGE.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  [返回简介](../README.md) · [更新记录](../CHANGELOG.md) · [验证记录](VALIDATION.md)
4
4
 
5
+ ## 抽帧与源文件保护
6
+
7
+ 默认帧序列写入 `<输入名>-frames/run-*/`,每次使用新目录。显式指定序列时,已有输出需要 `overwrite=true`;即使允许覆盖,也不能覆盖任何输入或字幕源及其文件别名。
8
+
5
9
  ## 本次改进
6
10
 
7
11
  相对文件路径以当前会话工作区为基准。提供 start / duration 时必须是合法时间,非法输入不会退回默认值或意外处理整段视频。
@@ -75,7 +79,7 @@ ffmpeg_adjust { input: E:\videos\raw.mp4, volume: +2dB }
75
79
 
76
80
  ```bash
77
81
  pnpm install
78
- pnpm test # 构建 + 110 个测试
82
+ pnpm test # 构建 + 115 个测试
79
83
  ```
80
84
 
81
85
  ## License
@@ -1,5 +1,9 @@
1
1
  # dsh-ffmpeg 验证记录
2
2
 
3
+ ## 2026-10-26:0.5.1
4
+
5
+ [本次修复、回归与未验收范围](validation-0.5.1.md)。
6
+
3
7
  本页整理原 README 的历史验证说明,保留当时的版本、日期与范围。自动测试、启动检查、浏览器操作和真实服务验收分别记录,不能相互替代。更详细的版本验收文件仍保留在仓库中。
4
8
 
5
9
  ## 原中文记录
@@ -0,0 +1,19 @@
1
+ # 0.5.1 验证记录
2
+
3
+ 日期:2026-10-26。官方 Windows 宿主:DSH 0.2.1-rc.3。
4
+
5
+ ## 行为变化
6
+
7
+ 抽帧默认使用每次独立的 run 目录;显式帧序列检查实际文件冲突。输出保护覆盖所有拼接输入、字幕源、符号链接及硬链接别名。
8
+
9
+ ## 验证与限制
10
+
11
+ - Windows 本地:115 项,114 通过、1 跳过;没有把跳过计为通过。
12
+ - 官方 RC.3 调用真实 FFmpeg 连续抽帧两次,目录互不覆盖,原有帧哨兵保持不变。Windows 跳过 1 项 POSIX 大小写检查,Linux 结果以 CI 为准。
13
+ - GitHub 的跨平台 CI 和 npm 发布完整性以该版本工作流及 registry 为准;本记录不把本地测试等同于已发布。
14
+
15
+ ## English
16
+
17
+ Frame extraction uses a separate run directory by default. Explicit frame sequences check existing output names. Output protection covers every concat input, subtitle sources and symlink/hard-link aliases.
18
+
19
+ Runtime/account limits are listed above. Synthetic peers and model adapters do not establish live account delivery or model quality.
package/lib/paths.d.ts CHANGED
@@ -2,8 +2,12 @@
2
2
  export declare function assertInputFile(input: string, cwd?: string): string;
3
3
  /** 清洗文件名中的危险字符。 */
4
4
  export declare function sanitizeName(name: string): string;
5
+ /** Compare every source, resolving junctions/symlinks and detecting hard-link aliases. */
6
+ export declare function assertOutputNotSource(inputs: readonly string[], target: string, cwd?: string): void;
7
+ /** image2's -n does not protect expanded sequence files. Check a supported pattern first. */
8
+ export declare function resolveFramePattern(input: string, explicit: string | undefined, overwrite: boolean, cwd?: string): string;
5
9
  /**
6
10
  * 决定输出路径:缺省时放在输入同目录,名字 = 输入名 + suffix + ext。
7
11
  * 目标已存在且不允许覆写时自动追加 _1/_2…。
8
12
  */
9
- export declare function resolveOutputPath(input: string, explicit: string | undefined, suffix: string, ext: string, overwrite: boolean, cwd?: string): string;
13
+ export declare function resolveOutputPath(input: string | readonly string[], explicit: string | undefined, suffix: string, ext: string, overwrite: boolean, cwd?: string): string;
package/lib/paths.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * @module dsh-ffmpeg/paths
5
5
  */
6
- import { existsSync, statSync } from 'node:fs';
6
+ import { existsSync, statSync, lstatSync, realpathSync, mkdirSync, mkdtempSync, readdirSync } from 'node:fs';
7
7
  import { basename, dirname, extname, join, resolve } from 'node:path';
8
8
  /** 校验输入文件存在且是文件;返回绝对路径。 */
9
9
  export function assertInputFile(input, cwd = process.cwd()) {
@@ -21,22 +21,96 @@ export function sanitizeName(name) {
21
21
  const cleaned = name.trim().replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/\s+/g, '_').slice(0, 120);
22
22
  return cleaned === '' ? 'output' : cleaned;
23
23
  }
24
+ function canonicalPath(file) {
25
+ let parent = resolve(file);
26
+ const missing = [];
27
+ while (!existsSync(parent)) {
28
+ if (lstatSync(parent, { throwIfNoEntry: false })?.isSymbolicLink())
29
+ throw new Error('输出路径包含悬空链接,已拒绝:' + file);
30
+ const next = dirname(parent);
31
+ if (next === parent)
32
+ throw new Error('无法解析输出路径:' + file);
33
+ missing.unshift(basename(parent));
34
+ parent = next;
35
+ }
36
+ const result = join(realpathSync(parent), ...missing);
37
+ return process.platform === 'win32' ? result.toLowerCase() : result;
38
+ }
39
+ /** Compare every source, resolving junctions/symlinks and detecting hard-link aliases. */
40
+ export function assertOutputNotSource(inputs, target, cwd = process.cwd()) {
41
+ const absolute = resolve(cwd, target), canonical = canonicalPath(absolute);
42
+ const targetStat = existsSync(absolute) ? statSync(absolute, { bigint: true }) : undefined;
43
+ for (const input of inputs) {
44
+ const source = resolve(cwd, input), sourceStat = existsSync(source) ? statSync(source, { bigint: true }) : undefined;
45
+ if (canonicalPath(source) === canonical || (targetStat && sourceStat && sourceStat.ino !== 0n && targetStat.ino === sourceStat.ino && targetStat.dev === sourceStat.dev)) {
46
+ throw new Error('输出路径与输入文件相同或指向同一文件,已拒绝(避免覆盖源文件)。');
47
+ }
48
+ }
49
+ }
50
+ /** image2's -n does not protect expanded sequence files. Check a supported pattern first. */
51
+ export function resolveFramePattern(input, explicit, overwrite, cwd = process.cwd()) {
52
+ if (!explicit?.trim()) {
53
+ const directory = join(dirname(input), sanitizeName(basename(input, extname(input))) + '-frames');
54
+ mkdirSync(directory, { recursive: true });
55
+ return join(mkdtempSync(join(directory, 'run-')), sanitizeName(basename(input, extname(input))) + '-%03d.png');
56
+ }
57
+ let pattern = resolve(cwd, explicit.trim());
58
+ if (!basename(pattern).includes('%')) {
59
+ const ext = extname(pattern);
60
+ pattern = ext ? pattern.slice(0, -ext.length) + '-%03d' + ext : pattern + '-%03d.png';
61
+ }
62
+ if (dirname(pattern).includes('%'))
63
+ throw new Error('序列占位符只能放在输出文件名中,不能放在目录名中。');
64
+ const filename = basename(pattern);
65
+ let source = '^', count = 0;
66
+ const literal = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
67
+ for (let i = 0; i < filename.length;) {
68
+ if (filename[i] !== '%') {
69
+ source += literal(filename[i++]);
70
+ continue;
71
+ }
72
+ if (filename[i + 1] === '%') {
73
+ source += '%';
74
+ i += 2;
75
+ continue;
76
+ }
77
+ const marker = /^%(?:0([1-9][0-9]?))?d/.exec(filename.slice(i));
78
+ if (!marker)
79
+ throw new Error('抽帧序列文件名只支持 %d 或 %0Nd(例如 %03d),字面的 % 请写成 %%。');
80
+ count++;
81
+ source += '[0-9]{' + (marker[1] ? Number(marker[1]) : 1) + ',}';
82
+ i += marker[0].length;
83
+ }
84
+ if (count !== 1)
85
+ throw new Error('抽帧序列文件名必须包含且只包含一个 %d 或 %0Nd。');
86
+ const matcher = new RegExp(source + '$', process.platform === 'win32' ? 'i' : '');
87
+ const directory = dirname(pattern);
88
+ mkdirSync(directory, { recursive: true });
89
+ const existing = readdirSync(directory).filter(name => matcher.test(name));
90
+ for (const name of existing)
91
+ assertOutputNotSource([input], join(directory, name), cwd);
92
+ if (!overwrite && existing.length)
93
+ throw new Error('抽帧输出已存在(' + existing.length + ' 个同名序列文件),已拒绝覆盖;请更换输出目录或文件名前缀。');
94
+ return pattern;
95
+ }
24
96
  /**
25
97
  * 决定输出路径:缺省时放在输入同目录,名字 = 输入名 + suffix + ext。
26
98
  * 目标已存在且不允许覆写时自动追加 _1/_2…。
27
99
  */
28
100
  export function resolveOutputPath(input, explicit, suffix, ext, overwrite, cwd = process.cwd()) {
101
+ const inputs = typeof input === 'string' ? [input] : input;
102
+ if (!inputs.length)
103
+ throw new Error('至少需要一个输入源文件。');
104
+ const primary = resolve(cwd, inputs[0]);
29
105
  let target;
30
106
  if (explicit !== undefined && explicit.trim() !== '') {
31
107
  target = resolve(cwd, explicit.trim());
32
108
  }
33
109
  else {
34
- const base = basename(input, extname(input));
35
- target = join(dirname(input), sanitizeName(base) + suffix + ext);
36
- }
37
- if (target.toLowerCase() === resolve(input).toLowerCase()) {
38
- throw new Error('输出路径与输入文件相同,已拒绝(避免覆盖源文件)。');
110
+ const base = basename(primary, extname(primary));
111
+ target = join(dirname(primary), sanitizeName(base) + suffix + ext);
39
112
  }
113
+ assertOutputNotSource(inputs, target, cwd);
40
114
  if (overwrite || !existsSync(target))
41
115
  return target;
42
116
  const directory = dirname(target);
package/lib/tools.js CHANGED
@@ -10,7 +10,7 @@ import { tmpdir } from 'node:os';
10
10
  import { basename, dirname, extname, join, resolve } from 'node:path';
11
11
  import { adjustArgs, concatArgs, concatListContent, cutArgs, ENCODE_PRESETS, encodeArgs, extractArgs, fmtSeconds, frameAtArgs, gifPaletteArgs, gifUseArgs, probeArgs, subtitleArgs, } from './args.js';
12
12
  import { parseProbeJson } from './ffprobe.js';
13
- import { assertInputFile, resolveOutputPath, sanitizeName } from './paths.js';
13
+ import { assertInputFile, resolveOutputPath, resolveFramePattern, sanitizeName } from './paths.js';
14
14
  import { parseTimeArg } from './config.js';
15
15
  function executionCwd(exec) { return exec?.agent?.session?.header?.cwd || process.cwd(); }
16
16
  /** 编译作者 DSL 为原始 JSON Schema(正是 defineTool 存为 definition.parameters 的值)。 */
@@ -282,7 +282,7 @@ export function buildFfmpegTools(config, runner) {
282
282
  if (requestedFps !== undefined && (!Number.isFinite(requestedFps) || requestedFps < 1 || requestedFps > 240))
283
283
  throw new Error('fps 必须在 1-240 之间。');
284
284
  const firstExt = extname(absolute[0]) || '.mp4';
285
- const output = resolveOutputPath(absolute[0], optionalString(args, 'output'), '.concat', firstExt, cfg.overwrite, executionCwd(exec));
285
+ const output = resolveOutputPath(absolute, optionalString(args, 'output'), '.concat', firstExt, cfg.overwrite, executionCwd(exec));
286
286
  let normalized;
287
287
  if (!reencode) {
288
288
  const listPath = join(tmpdir(), 'dsh-ffmpeg-concat-' + Date.now() + '-' + randomUUID().slice(0, 8) + '.txt');
@@ -391,7 +391,7 @@ export function buildFfmpegTools(config, runner) {
391
391
  const args = asRecord(rawArgs);
392
392
  const input = assertInputFile(requiredString(args, 'input', '输入视频'), executionCwd(exec));
393
393
  const subtitlePath = assertInputFile(requiredString(args, 'subtitle', '字幕文件'), executionCwd(exec));
394
- const output = resolveOutputPath(input, optionalString(args, 'output'), '.sub', extname(input) || '.mp4', cfg.overwrite, executionCwd(exec));
394
+ const output = resolveOutputPath([input, subtitlePath], optionalString(args, 'output'), '.sub', extname(input) || '.mp4', cfg.overwrite, executionCwd(exec));
395
395
  await runChecked(runner, subtitleArgs(cfg.ffmpegPath, { input, subtitle: subtitlePath, output, overwrite: cfg.overwrite }), timeout, 'ffmpeg 字幕', exec?.signal);
396
396
  return { output, mode: 'burn' };
397
397
  },
@@ -438,20 +438,7 @@ export function buildFfmpegTools(config, runner) {
438
438
  output = resolveOutputPath(input, optionalString(args, 'output'), '.subtitle', '.srt', cfg.overwrite, executionCwd(exec));
439
439
  }
440
440
  else {
441
- const rawOutput = optionalString(args, 'output');
442
- const explicit = rawOutput === undefined ? undefined : resolve(executionCwd(exec), rawOutput);
443
- if (explicit !== undefined) {
444
- if (explicit.includes('%')) {
445
- output = explicit;
446
- }
447
- else {
448
- const explicitExt = extname(explicit);
449
- output = explicitExt === '' ? explicit + '-%03d.png' : explicit.slice(0, -explicitExt.length) + '-%03d' + explicitExt;
450
- }
451
- }
452
- else {
453
- output = join(dirname(input), sanitizeName(basename(input, extname(input))) + '-%03d.png');
454
- }
441
+ output = resolveFramePattern(input, optionalString(args, 'output'), cfg.overwrite, executionCwd(exec));
455
442
  }
456
443
  const spec = { input, what: what, output, overwrite: cfg.overwrite, start, duration, fps, streamIndex };
457
444
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ffmpeg",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "用自然语言调用 FFmpeg,处理已有音视频文件。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",