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 +5 -0
- package/docs/USAGE.en.md +5 -1
- package/docs/USAGE.md +5 -1
- package/docs/VALIDATION.md +4 -0
- package/docs/validation-0.5.1.md +19 -0
- package/lib/paths.d.ts +5 -1
- package/lib/paths.js +80 -6
- package/lib/tools.js +4 -17
- package/package.json +1 -1
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 +
|
|
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 # 构建 +
|
|
82
|
+
pnpm test # 构建 + 115 个测试
|
|
79
83
|
```
|
|
80
84
|
|
|
81
85
|
## License
|
package/docs/VALIDATION.md
CHANGED
|
@@ -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(
|
|
35
|
-
target = join(dirname(
|
|
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
|
|
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
|
-
|
|
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 {
|