@xiping/node-utils 1.0.60 → 1.0.61

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.
@@ -0,0 +1,371 @@
1
+ # FFmpeg 工具模块
2
+
3
+ 基于 FFmpeg/FFprobe 的 Node.js 视频处理工具集,提供视频信息获取、缩略图生成、视频截取、音频提取及运行环境检查等功能。
4
+
5
+ 本模块由 `@xiping/node-utils` 包统一导出,使用时从包根路径导入即可:`import { ... } from '@xiping/node-utils'`。
6
+
7
+ ## 功能概览
8
+
9
+ | 功能 | 说明 | 依赖 |
10
+ |------------|--------------------------|-------------|
11
+ | 视频信息 | 时长、分辨率、编码、比特率等 | ffprobe |
12
+ | 缩略图生成 | 多帧合成预览图(AVIF/WebP/JPEG/PNG) | ffmpeg + sharp |
13
+ | 视频截取 | 按时间范围截取,流复制不重编码 | ffmpeg |
14
+ | 提取音频 | 从视频中提取音轨为 mp3/m4a/wav | ffmpeg |
15
+ | 环境检查 | 检测 ffmpeg/ffprobe 是否可用 | - |
16
+
17
+ ## 安装要求
18
+
19
+ ### 依赖包
20
+
21
+ - Node.js
22
+ - [shelljs](https://www.npmjs.com/package/shelljs)
23
+ - [sharp](https://www.npmjs.com/package/sharp)(仅缩略图功能需要)
24
+
25
+ ### 系统要求
26
+
27
+ - **ffprobe**:视频信息相关接口需要(通常随 ffmpeg 一起安装)
28
+ - **ffmpeg**:缩略图、视频截取需要
29
+
30
+ ### 安装 FFmpeg / FFprobe
31
+
32
+ **macOS:**
33
+
34
+ ```bash
35
+ brew install ffmpeg
36
+ ```
37
+
38
+ **Ubuntu/Debian:**
39
+
40
+ ```bash
41
+ sudo apt update
42
+ sudo apt install ffmpeg
43
+ ```
44
+
45
+ **CentOS/RHEL:**
46
+
47
+ ```bash
48
+ sudo yum install ffmpeg
49
+ ```
50
+
51
+ **Windows:**
52
+
53
+ 从 [FFmpeg 官网](https://ffmpeg.org/download.html) 下载并将可执行文件加入 PATH,或使用 Chocolatey:
54
+
55
+ ```bash
56
+ choco install ffmpeg
57
+ ```
58
+
59
+ ---
60
+
61
+ ## 1. 视频信息(getVideoInfo)
62
+
63
+ 使用 ffprobe 获取视频元数据:时长、分辨率、编码、比特率、帧率、文件大小等。
64
+
65
+ ### 基本使用
66
+
67
+ ```typescript
68
+ import { getVideoInfo } from '@xiping/node-utils';
69
+
70
+ const videoInfo = getVideoInfo('/path/to/video.mp4');
71
+ console.log(videoInfo.durationFormatted, videoInfo.width, videoInfo.height);
72
+ ```
73
+
74
+ ### 检查 ffprobe 是否可用
75
+
76
+ ```typescript
77
+ import { isFfprobeAvailable } from '@xiping/node-utils';
78
+
79
+ if (isFfprobeAvailable()) {
80
+ console.log('ffprobe 可用');
81
+ }
82
+ ```
83
+
84
+ ### 批量获取
85
+
86
+ ```typescript
87
+ import { getMultipleVideoInfo } from '@xiping/node-utils';
88
+
89
+ const infos = getMultipleVideoInfo(['/path/video1.mp4', '/path/video2.mp4']);
90
+ ```
91
+
92
+ ### 获取详细元数据(含章节等)
93
+
94
+ ```typescript
95
+ import { getDetailedVideoInfo } from '@xiping/node-utils';
96
+
97
+ const detailed = getDetailedVideoInfo('/path/to/video.mp4');
98
+ ```
99
+
100
+ ### VideoInfo 结构
101
+
102
+ ```typescript
103
+ interface VideoInfo {
104
+ path: string;
105
+ duration: number; // 秒
106
+ durationFormatted: string;
107
+ width: number;
108
+ height: number;
109
+ videoCodec: string;
110
+ audioCodec: string;
111
+ bitrate: number;
112
+ fps: number;
113
+ fileSize: number; // 字节
114
+ fileSizeFormatted: string;
115
+ rawInfo: string; // 原始 ffprobe JSON
116
+ }
117
+ ```
118
+
119
+ ---
120
+
121
+ ## 2. 缩略图生成(getThumbnail)
122
+
123
+ 从视频中等间隔提取多帧,用 sharp 合成为一张缩略图,支持 AVIF/WebP/JPEG/PNG。
124
+
125
+ ### 基本使用
126
+
127
+ ```typescript
128
+ import { getThumbnail } from '@xiping/node-utils';
129
+
130
+ const result = await getThumbnail('/path/to/video.mp4', {
131
+ frames: 60, // 提取帧数(默认 60)
132
+ columns: 4, // 每行列数(默认 4)
133
+ outputWidth: 3840, // 输出宽度(默认 3840)
134
+ format: 'avif', // 'avif' | 'webp' | 'jpeg' | 'png'
135
+ quality: 80, // 1–100
136
+ outputFileName: 'thumbnail.avif',
137
+ });
138
+
139
+ console.log(result.outputPath);
140
+ console.log(result.metadata);
141
+ ```
142
+
143
+ ### 进度回调
144
+
145
+ ```typescript
146
+ const result = await getThumbnail('/path/to/video.mp4', {
147
+ frames: 30,
148
+ onProgress(progress) {
149
+ console.log(progress.phase, progress.percent, progress.message);
150
+ // phase: 'analyzing' | 'extracting' | 'composing' | 'encoding' | 'done'
151
+ },
152
+ });
153
+ ```
154
+
155
+ ### 选项说明
156
+
157
+ | 选项 | 类型 | 默认值 | 说明 |
158
+ |-----------------|----------|----------------|----------------|
159
+ | frames | number | 60 | 提取的帧数 |
160
+ | columns | number | 4 | 缩略图列数 |
161
+ | outputWidth | number | 3840 | 输出图宽度 |
162
+ | outputFileName | string | thumbnail.avif | 输出文件名 |
163
+ | format | string | 'avif' | avif/webp/jpeg/png |
164
+ | quality | number | 80 | 1–100 |
165
+ | batchSize | number | 10 | 合成时每批帧数 |
166
+ | maxConcurrency | number | 4 | 提取帧并发数 |
167
+ | tempDir | string | - | 自定义临时目录 |
168
+ | onProgress | function | - | 进度回调 |
169
+
170
+ ### 返回值
171
+
172
+ ```typescript
173
+ {
174
+ buffer: Buffer;
175
+ outputPath: string;
176
+ metadata: {
177
+ frames: number;
178
+ duration: number;
179
+ outputSize: { width: number; height: number };
180
+ };
181
+ }
182
+ ```
183
+
184
+ **注意**:输入路径需为**绝对路径**;使用前请确保已安装 ffmpeg(可用下方「环境检查」接口检测)。
185
+
186
+ ---
187
+
188
+ ## 3. 视频截取(cutVideo)
189
+
190
+ 按开始时间、时长或结束时间截取片段,使用流复制(`-c copy`),不重新编码。
191
+
192
+ ### 基本使用
193
+
194
+ ```typescript
195
+ import { cutVideo, cutVideoByTimeRange, cutVideoByDuration, cutVideoFromStart } from '@xiping/node-utils';
196
+
197
+ // 从 30 秒开始截取 60 秒
198
+ const result = await cutVideo('/path/to/video.mp4', {
199
+ startTime: '00:00:30',
200
+ duration: '00:01:00',
201
+ outputFormat: 'mp4',
202
+ overwrite: true,
203
+ });
204
+
205
+ // 按时间范围:1:30 到 3:45
206
+ const r2 = await cutVideoByTimeRange('/path/to/video.mp4', '00:01:30', '00:03:45');
207
+
208
+ // 从 2 分钟开始截取 90 秒
209
+ const r3 = await cutVideoByDuration('/path/to/video.mp4', '00:02:00', '00:01:30');
210
+
211
+ // 只取前 30 秒
212
+ const r4 = await cutVideoFromStart('/path/to/video.mp4', 30);
213
+ ```
214
+
215
+ ### CutVideoOptions
216
+
217
+ ```typescript
218
+ {
219
+ startTime?: string; // 开始时间,如 '00:00:30' 或秒数
220
+ duration?: string; // 持续时间
221
+ endTime?: string; // 结束时间(与 duration 二选一)
222
+ outputFileName?: string;
223
+ outputFormat?: string; // 默认 'mp4'
224
+ tempDir?: string;
225
+ overwrite?: boolean; // 是否覆盖已存在文件
226
+ }
227
+ ```
228
+
229
+ ### CutVideoResult
230
+
231
+ ```typescript
232
+ {
233
+ outputPath: string;
234
+ metadata: {
235
+ originalDuration: number;
236
+ cutDuration: number;
237
+ startTime: string;
238
+ endTime: string;
239
+ fileSize: number;
240
+ processingTime: number;
241
+ };
242
+ }
243
+ ```
244
+
245
+ 时间格式支持:`HH:MM:SS`、`MM:SS` 或纯秒数。输入路径需为**绝对路径**。更多说明见 [README_cutVideo.md](./README_cutVideo.md)。
246
+
247
+ ---
248
+
249
+ ## 4. 提取音频(extractAudio)
250
+
251
+ 从视频文件中仅提取音频轨,输出为独立音频文件(mp3、m4a、wav)。
252
+
253
+ ### 基本使用
254
+
255
+ ```typescript
256
+ import { extractAudio } from '@xiping/node-utils';
257
+
258
+ const result = await extractAudio('/path/to/video.mp4', {
259
+ outputFormat: 'mp3', // 'mp3' | 'm4a' | 'wav',默认 'mp3'
260
+ outputFileName: 'audio.mp3',
261
+ overwrite: true,
262
+ });
263
+
264
+ console.log(result.outputPath);
265
+ console.log(result.metadata.duration, result.metadata.fileSize);
266
+ ```
267
+
268
+ ### 进度回调
269
+
270
+ ```typescript
271
+ const result = await extractAudio('/path/to/video.mp4', {
272
+ outputFormat: 'mp3',
273
+ onProgress(progress) {
274
+ console.log(progress.phase, progress.percent, progress.message);
275
+ // phase: 'preparing' | 'encoding' | 'done'
276
+ if (progress.currentTime != null && progress.duration != null) {
277
+ console.log(`已处理 ${progress.currentTime}/${progress.duration} 秒`);
278
+ }
279
+ },
280
+ });
281
+ ```
282
+
283
+ ### 选项说明
284
+
285
+ | 选项 | 类型 | 默认值 | 说明 |
286
+ |-----------------|----------|----------|------------------------------|
287
+ | outputFileName | string | 源文件名.格式 | 输出文件名 |
288
+ | outputFormat | string | 'mp3' | mp3 / m4a / wav |
289
+ | overwrite | boolean | false | 是否覆盖已存在文件 |
290
+ | tempDir | string | - | 自定义临时目录 |
291
+ | copyStream | boolean | true | 尽量流复制不重编码(格式兼容时) |
292
+ | onProgress | function | - | 进度回调 |
293
+
294
+ ### 返回值
295
+
296
+ ```typescript
297
+ {
298
+ outputPath: string;
299
+ metadata: {
300
+ duration: number; // 音频时长(秒)
301
+ fileSize: number; // 输出文件大小(字节)
302
+ processingTime: number; // 处理耗时(毫秒)
303
+ };
304
+ }
305
+ ```
306
+
307
+ 输入路径需为**绝对路径**;若视频无音轨将抛错。使用前请确保已安装 ffmpeg。
308
+
309
+ ---
310
+
311
+ ## 5. 环境检查(check)
312
+
313
+ ```typescript
314
+ import { checkFFmpegAvailability, isFfprobeAvailable } from '@xiping/node-utils';
315
+
316
+ if (checkFFmpegAvailability()) {
317
+ console.log('ffmpeg 可用');
318
+ }
319
+ if (isFfprobeAvailable()) {
320
+ console.log('ffprobe 可用');
321
+ }
322
+ ```
323
+
324
+ - **checkFFmpegAvailability()**:用于缩略图、视频截取、提取音频前检查。
325
+ - **isFfprobeAvailable()**:用于视频信息接口前检查。
326
+
327
+ ---
328
+
329
+ ## 错误处理
330
+
331
+ 各函数在以下情况会抛出错误,建议用 try/catch 包裹:
332
+
333
+ - 文件不存在或路径无效
334
+ - 未安装 ffmpeg/ffprobe 或不可用
335
+ - 格式/参数不支持(如时间超出视频时长、视频无音轨)
336
+ - 权限或磁盘空间不足
337
+
338
+ ```typescript
339
+ try {
340
+ const info = getVideoInfo('/path/to/video.mp4');
341
+ } catch (err) {
342
+ console.error(err.message);
343
+ }
344
+ ```
345
+
346
+ ---
347
+
348
+ ## 支持格式
349
+
350
+ ffprobe/ffmpeg 支持常见容器与编码,例如:
351
+
352
+ - 容器:MP4, AVI, MOV, MKV, FLV, WMV, WebM, OGV, 3GP 等
353
+ - 具体支持以系统安装的 FFmpeg 版本为准
354
+
355
+ ---
356
+
357
+ ## API 速览
358
+
359
+ | 接口 | 说明 |
360
+ |--------------------------|------|
361
+ | `getVideoInfo(path)` | 获取视频基本信息 |
362
+ | `getMultipleVideoInfo(paths)` | 批量获取视频信息 |
363
+ | `getDetailedVideoInfo(path)` | 获取详细元数据(含章节等) |
364
+ | `getThumbnail(path, options)` | 生成多帧缩略图(异步) |
365
+ | `cutVideo(path, options)` | 按选项截取视频(异步) |
366
+ | `cutVideoByTimeRange(path, start, end, options)` | 按起止时间截取 |
367
+ | `cutVideoByDuration(path, start, duration, options)` | 按起始+时长截取 |
368
+ | `cutVideoFromStart(path, durationSeconds, options)` | 从开头截取 N 秒 |
369
+ | `extractAudio(path, options)` | 从视频提取音频(异步) |
370
+ | `isFfprobeAvailable()` | 检测 ffprobe 是否可用 |
371
+ | `checkFFmpegAvailability()` | 检测 ffmpeg 是否可用 |
@@ -0,0 +1,220 @@
1
+ # Video Cutter (视频截取器)
2
+
3
+ 一个基于 FFmpeg 的 Node.js 视频截取工具,支持精确的时间控制和高性能的视频处理。
4
+
5
+ ## 功能特性
6
+
7
+ - 🎯 **精确时间控制** - 支持多种时间格式 (HH:MM:SS, 秒数)
8
+ - ⚡ **高性能处理** - 使用 FFmpeg 的 copy 模式,避免重新编码
9
+ - 🛡️ **输入验证** - 完整的参数验证和错误处理
10
+ - 📁 **灵活输出** - 支持自定义输出格式和文件名
11
+ - 🔄 **临时文件管理** - 自动清理临时文件
12
+ - 📊 **详细元数据** - 返回处理结果和文件信息
13
+
14
+ ## 安装依赖
15
+
16
+ 确保系统已安装 FFmpeg:
17
+
18
+ ```bash
19
+ # macOS
20
+ brew install ffmpeg
21
+
22
+ # Ubuntu/Debian
23
+ sudo apt update
24
+ sudo apt install ffmpeg
25
+
26
+ # Windows
27
+ # 下载并安装 FFmpeg,或使用 chocolatey: choco install ffmpeg
28
+ ```
29
+
30
+ ## API 参考
31
+
32
+ ### 主要函数
33
+
34
+ #### `cutVideo(videoPath, options)`
35
+
36
+ 主要的视频截取函数。
37
+
38
+ **参数:**
39
+ - `videoPath` (string): 输入视频文件的绝对路径
40
+ - `options` (CutVideoOptions): 截取选项
41
+
42
+ **返回值:**
43
+ - `Promise<CutVideoResult>`: 包含输出路径和元数据的对象
44
+
45
+ #### `cutVideoByTimeRange(videoPath, startTime, endTime, options)`
46
+
47
+ 按时间范围截取视频的便捷函数。
48
+
49
+ #### `cutVideoByDuration(videoPath, startTime, duration, options)`
50
+
51
+ 按持续时间截取视频的便捷函数。
52
+
53
+ #### `cutVideoFromStart(videoPath, durationSeconds, options)`
54
+
55
+ 从视频开头截取指定秒数的便捷函数。
56
+
57
+ ### 类型定义
58
+
59
+ #### CutVideoOptions
60
+
61
+ ```typescript
62
+ interface CutVideoOptions {
63
+ startTime?: string; // 开始时间 (格式: HH:MM:SS 或秒数)
64
+ duration?: string; // 持续时间 (格式: HH:MM:SS 或秒数)
65
+ endTime?: string; // 结束时间 (格式: HH:MM:SS 或秒数)
66
+ outputFileName?: string; // 输出文件名
67
+ outputFormat?: string; // 输出格式 (mp4, avi, mov, etc.)
68
+ tempDir?: string; // 临时目录
69
+ overwrite?: boolean; // 是否覆盖已存在的文件
70
+ }
71
+ ```
72
+
73
+ #### CutVideoResult
74
+
75
+ ```typescript
76
+ interface CutVideoResult {
77
+ outputPath: string;
78
+ metadata: {
79
+ originalDuration: number; // 原视频时长(秒)
80
+ cutDuration: number; // 截取时长(秒)
81
+ startTime: string; // 开始时间
82
+ endTime: string; // 结束时间
83
+ fileSize: number; // 输出文件大小(字节)
84
+ processingTime: number; // 处理时间(毫秒)
85
+ };
86
+ }
87
+ ```
88
+
89
+ ## 使用示例
90
+
91
+ ### 基本用法
92
+
93
+ ```typescript
94
+ import { cutVideo } from './cutVideo.js';
95
+
96
+ // 从第30秒开始截取60秒
97
+ const result = await cutVideo('/path/to/video.mp4', {
98
+ startTime: '00:00:30',
99
+ duration: '00:01:00'
100
+ });
101
+
102
+ console.log('输出文件:', result.outputPath);
103
+ console.log('文件大小:', result.metadata.fileSize);
104
+ ```
105
+
106
+ ### 按时间范围截取
107
+
108
+ ```typescript
109
+ import { cutVideoByTimeRange } from './cutVideo.js';
110
+
111
+ // 截取 1:30 到 3:45 之间的片段
112
+ const result = await cutVideoByTimeRange(
113
+ '/path/to/video.mp4',
114
+ '00:01:30',
115
+ '00:03:45'
116
+ );
117
+ ```
118
+
119
+ ### 按持续时间截取
120
+
121
+ ```typescript
122
+ import { cutVideoByDuration } from './cutVideo.js';
123
+
124
+ // 从第2分钟开始截取90秒
125
+ const result = await cutVideoByDuration(
126
+ '/path/to/video.mp4',
127
+ '00:02:00',
128
+ '00:01:30'
129
+ );
130
+ ```
131
+
132
+ ### 从头开始截取
133
+
134
+ ```typescript
135
+ import { cutVideoFromStart } from './cutVideo.js';
136
+
137
+ // 截取视频前30秒
138
+ const result = await cutVideoFromStart(
139
+ '/path/to/video.mp4',
140
+ 30
141
+ );
142
+ ```
143
+
144
+ ### 自定义输出选项
145
+
146
+ ```typescript
147
+ const result = await cutVideo('/path/to/video.mp4', {
148
+ startTime: '00:01:00',
149
+ duration: '00:02:00',
150
+ outputFileName: 'my_cut_video.mp4',
151
+ outputFormat: 'mp4',
152
+ overwrite: true
153
+ });
154
+ ```
155
+
156
+ ### 使用自定义临时目录
157
+
158
+ ```typescript
159
+ const result = await cutVideo('/path/to/video.mp4', {
160
+ startTime: '00:00:30',
161
+ duration: '00:01:00',
162
+ tempDir: '/custom/temp/directory'
163
+ });
164
+ ```
165
+
166
+ ## 时间格式支持
167
+
168
+ 支持多种时间格式:
169
+
170
+ - `HH:MM:SS` - 标准时间格式 (如: `01:30:45`)
171
+ - `MM:SS` - 分钟:秒格式 (如: `30:45`)
172
+ - 秒数 - 纯数字格式 (如: `90.5`)
173
+
174
+ ## 错误处理
175
+
176
+ 函数会抛出以下类型的错误:
177
+
178
+ - **文件不存在**: 输入视频文件不存在
179
+ - **FFmpeg 未安装**: 系统未安装 FFmpeg
180
+ - **时间参数无效**: 开始时间大于等于视频时长
181
+ - **输出文件已存在**: 输出文件存在且未设置覆盖选项
182
+ - **FFmpeg 执行失败**: 视频处理过程中出现错误
183
+
184
+ ```typescript
185
+ try {
186
+ const result = await cutVideo('/path/to/video.mp4', {
187
+ startTime: '00:00:30',
188
+ duration: '00:01:00'
189
+ });
190
+ } catch (error) {
191
+ console.error('视频截取失败:', error.message);
192
+ }
193
+ ```
194
+
195
+ ## 性能优化
196
+
197
+ - 使用 FFmpeg 的 `-c copy` 参数避免重新编码
198
+ - 自动管理临时文件,处理完成后自动清理
199
+ - 支持自定义临时目录以优化 I/O 性能
200
+
201
+ ## 注意事项
202
+
203
+ 1. **路径要求**: 输入路径必须是绝对路径
204
+ 2. **文件权限**: 确保对输入和输出目录有读写权限
205
+ 3. **磁盘空间**: 确保有足够的磁盘空间存储输出文件
206
+ 4. **FFmpeg 依赖**: 必须安装 FFmpeg 才能使用此模块
207
+
208
+ ## 日志输出
209
+
210
+ 模块会输出详细的处理日志,包括:
211
+ - 参数验证结果
212
+ - FFmpeg 命令执行
213
+ - 处理进度和结果
214
+ - 错误信息
215
+
216
+ 日志格式:`[VideoCutter] 消息内容`
217
+
218
+ ## 许可证
219
+
220
+ MIT License
@@ -0,0 +1,36 @@
1
+ /** 提取音频进度信息,用于 onProgress 回调 */
2
+ export interface ExtractAudioProgress {
3
+ /** 当前阶段 */
4
+ phase: "preparing" | "encoding" | "done";
5
+ /** 总进度 0–100 */
6
+ percent: number;
7
+ /** 可读描述 */
8
+ message?: string;
9
+ /** 当前已处理时长(秒) */
10
+ currentTime?: number;
11
+ /** 视频总时长(秒) */
12
+ duration?: number;
13
+ }
14
+ export interface ExtractAudioOptions {
15
+ /** 输出文件名 */
16
+ outputFileName?: string;
17
+ /** 输出格式,默认 mp3 */
18
+ outputFormat?: "mp3" | "m4a" | "wav";
19
+ /** 是否覆盖已存在文件 */
20
+ overwrite?: boolean;
21
+ /** 自定义临时目录 */
22
+ tempDir?: string;
23
+ /** 是否尽量流复制不重编码(仅当格式兼容时有效,如 m4a 保留 aac) */
24
+ copyStream?: boolean;
25
+ /** 进度回调 */
26
+ onProgress?: (progress: ExtractAudioProgress) => void;
27
+ }
28
+ export interface ExtractAudioResult {
29
+ outputPath: string;
30
+ metadata: {
31
+ duration: number;
32
+ fileSize: number;
33
+ processingTime: number;
34
+ };
35
+ }
36
+ export declare function extractAudio(videoPath: string, options?: ExtractAudioOptions): Promise<ExtractAudioResult>;
@@ -0,0 +1,186 @@
1
+ import shell from "shelljs";
2
+ import { spawn } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import * as os from "node:os";
6
+ import { checkFFmpegAvailability } from "./check.js";
7
+ import { getVideoInfo } from "./getVideoInfo.js";
8
+ const log = (message, data) => {
9
+ console.log(`[ExtractAudio] ${message}`, data ? JSON.stringify(data, null, 2) : "");
10
+ };
11
+ function validateAndSanitizePath(inputPath) {
12
+ if (!inputPath || typeof inputPath !== "string") {
13
+ throw new Error("Invalid path provided");
14
+ }
15
+ if (!path.isAbsolute(inputPath)) {
16
+ throw new Error("Path must be absolute");
17
+ }
18
+ return inputPath;
19
+ }
20
+ function getDurationInSeconds(videoPath) {
21
+ const result = shell.exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${videoPath}"`, { silent: true });
22
+ if (result.code !== 0) {
23
+ throw new Error(`FFprobe failed to get duration: ${result.stderr}`);
24
+ }
25
+ const duration = parseFloat(result.stdout);
26
+ if (isNaN(duration) || duration <= 0) {
27
+ throw new Error("Invalid video duration");
28
+ }
29
+ return duration;
30
+ }
31
+ /** 解析 ffmpeg stderr 中的 time=HH:MM:SS.xx 为秒数 */
32
+ function parseTimeFromStderr(line) {
33
+ const match = line.match(/time=(\d{2}):(\d{2}):(\d{2}\.\d+)/);
34
+ if (!match)
35
+ return null;
36
+ const [, h, m, s] = match;
37
+ return parseInt(h, 10) * 3600 + parseInt(m, 10) * 60 + parseFloat(s);
38
+ }
39
+ /** 根据格式和是否流复制,构建 ffmpeg 参数(不含输入输出路径) */
40
+ function buildFfmpegArgs(outputPath, outputFormat, copyStream, audioCodec) {
41
+ const args = ["-i", ""]; // input 占位,调用方填入
42
+ args.push("-vn"); // 不要视频
43
+ if (outputFormat === "mp3") {
44
+ args.push("-acodec", "libmp3lame");
45
+ }
46
+ else if (outputFormat === "m4a") {
47
+ if (copyStream && (audioCodec === "aac" || audioCodec === "aac_latm")) {
48
+ args.push("-acodec", "copy");
49
+ }
50
+ else {
51
+ args.push("-acodec", "aac");
52
+ }
53
+ }
54
+ else if (outputFormat === "wav") {
55
+ args.push("-acodec", "pcm_s16le");
56
+ }
57
+ args.push("-y", outputPath);
58
+ return args;
59
+ }
60
+ /** 执行 ffmpeg(shell.exec),无进度 */
61
+ function runFfmpegSync(inputPath, outputPath, outputFormat, copyStream, audioCodec) {
62
+ const args = buildFfmpegArgs(outputPath, outputFormat, copyStream, audioCodec);
63
+ args[1] = inputPath;
64
+ const command = `ffmpeg ${args.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" ")}`;
65
+ log("Executing FFmpeg command", { command });
66
+ const result = shell.exec(command, { silent: true });
67
+ if (result.code !== 0) {
68
+ throw new Error(`FFmpeg failed: ${result.stderr}`);
69
+ }
70
+ }
71
+ /** 执行 ffmpeg(spawn),解析 stderr 并回调 onProgress */
72
+ function runFfmpegWithProgress(inputPath, outputPath, outputFormat, copyStream, audioCodec, totalDuration, onProgress) {
73
+ return new Promise((resolve, reject) => {
74
+ const args = buildFfmpegArgs(outputPath, outputFormat, copyStream, audioCodec);
75
+ args[1] = inputPath;
76
+ const child = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
77
+ let lastPercent = -1;
78
+ const report = (progress) => {
79
+ if (progress.percent !== lastPercent) {
80
+ lastPercent = progress.percent;
81
+ onProgress(progress);
82
+ }
83
+ };
84
+ if (child.stderr) {
85
+ let buffer = "";
86
+ child.stderr.setEncoding("utf8");
87
+ child.stderr.on("data", (chunk) => {
88
+ buffer += chunk;
89
+ const lines = buffer.split(/\r?\n/);
90
+ buffer = lines.pop() ?? "";
91
+ for (const line of lines) {
92
+ const currentTime = parseTimeFromStderr(line);
93
+ if (currentTime != null && totalDuration > 0) {
94
+ const percent = Math.min(99, Math.round((currentTime / totalDuration) * 100));
95
+ report({
96
+ phase: "encoding",
97
+ percent,
98
+ message: "正在提取音频...",
99
+ currentTime,
100
+ duration: totalDuration,
101
+ });
102
+ }
103
+ }
104
+ });
105
+ }
106
+ child.on("error", (err) => reject(err));
107
+ child.on("close", (code, signal) => {
108
+ if (signal) {
109
+ reject(new Error(`FFmpeg killed by signal: ${signal}`));
110
+ return;
111
+ }
112
+ if (code !== 0) {
113
+ reject(new Error(`FFmpeg exited with code ${code}`));
114
+ return;
115
+ }
116
+ report({
117
+ phase: "done",
118
+ percent: 100,
119
+ message: "完成",
120
+ currentTime: totalDuration,
121
+ duration: totalDuration,
122
+ });
123
+ resolve();
124
+ });
125
+ });
126
+ }
127
+ export async function extractAudio(videoPath, options = {}) {
128
+ const startTime = Date.now();
129
+ const validatedPath = validateAndSanitizePath(videoPath);
130
+ if (!fs.existsSync(validatedPath)) {
131
+ throw new Error(`Video file not found: ${validatedPath}`);
132
+ }
133
+ if (!checkFFmpegAvailability()) {
134
+ throw new Error("FFmpeg is not available. Please install FFmpeg first.");
135
+ }
136
+ const { outputFileName, outputFormat = "mp3", overwrite = false, tempDir: customTempDir, copyStream = true, onProgress, } = options;
137
+ const videoInfo = getVideoInfo(validatedPath);
138
+ if (!videoInfo.audioCodec) {
139
+ throw new Error("Video has no audio stream.");
140
+ }
141
+ const duration = getDurationInSeconds(validatedPath);
142
+ const videoDir = path.dirname(validatedPath);
143
+ const baseName = path.basename(validatedPath, path.extname(validatedPath));
144
+ const defaultOutputName = `${baseName}.${outputFormat}`;
145
+ const outputFileNameFinal = outputFileName ?? defaultOutputName;
146
+ const outputPath = path.join(videoDir, outputFileNameFinal);
147
+ if (fs.existsSync(outputPath) && !overwrite) {
148
+ throw new Error(`Output file already exists: ${outputPath}. Use overwrite option to force overwrite.`);
149
+ }
150
+ const reportProgress = (progress) => {
151
+ onProgress?.(progress);
152
+ };
153
+ reportProgress({
154
+ phase: "preparing",
155
+ percent: 0,
156
+ message: "准备提取音频...",
157
+ duration,
158
+ });
159
+ const tempDir = customTempDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "extract-audio-"));
160
+ const tempOutputPath = path.join(tempDir, `temp_audio.${outputFormat}`);
161
+ try {
162
+ if (onProgress) {
163
+ await runFfmpegWithProgress(validatedPath, tempOutputPath, outputFormat, copyStream, videoInfo.audioCodec, duration, reportProgress);
164
+ }
165
+ else {
166
+ runFfmpegSync(validatedPath, tempOutputPath, outputFormat, copyStream, videoInfo.audioCodec);
167
+ }
168
+ fs.copyFileSync(tempOutputPath, outputPath);
169
+ log("Extract audio complete", { outputPath });
170
+ }
171
+ finally {
172
+ if (!customTempDir && fs.existsSync(tempDir)) {
173
+ fs.rmSync(tempDir, { recursive: true, force: true });
174
+ }
175
+ }
176
+ const outputStats = fs.statSync(outputPath);
177
+ const processingTime = Date.now() - startTime;
178
+ return {
179
+ outputPath,
180
+ metadata: {
181
+ duration,
182
+ fileSize: outputStats.size,
183
+ processingTime,
184
+ },
185
+ };
186
+ }
@@ -2,3 +2,4 @@ export * from "./getThumbnail.js";
2
2
  export * from "./check.js";
3
3
  export * from "./cutVideo.js";
4
4
  export * from "./getVideoInfo.js";
5
+ export * from "./extractAudio.js";
@@ -2,3 +2,4 @@ export * from "./getThumbnail.js";
2
2
  export * from "./check.js";
3
3
  export * from "./cutVideo.js";
4
4
  export * from "./getVideoInfo.js";
5
+ export * from "./extractAudio.js";
@@ -0,0 +1,230 @@
1
+ # 图片格式转换工具
2
+
3
+ 基于 Sharp 的高性能图片格式转换工具,支持多种图片格式之间的转换,包括 JPG、PNG、WebP、AVIF 等。
4
+
5
+ ## 功能特性
6
+
7
+ - 🚀 高性能图片格式转换
8
+ - 📐 支持尺寸调整和缩放
9
+ - 🎨 支持质量控制和压缩选项
10
+ - 📦 批量转换支持
11
+ - 🖼️ 缩略图生成
12
+ - 📊 详细的转换统计信息
13
+ - 🔍 图片信息获取
14
+
15
+ ## 支持的格式
16
+
17
+ ### 输入格式
18
+ - JPEG/JPG
19
+ - PNG
20
+ - WebP
21
+ - AVIF
22
+ - TIFF
23
+ - GIF
24
+
25
+ ### 输出格式
26
+ - JPEG/JPG
27
+ - PNG
28
+ - WebP
29
+ - AVIF
30
+ - TIFF
31
+
32
+ ## 安装依赖
33
+
34
+ 确保项目中已安装 Sharp:
35
+
36
+ ```bash
37
+ npm install sharp
38
+ ```
39
+
40
+ ## 基本用法
41
+
42
+ ### 1. 单张图片转换
43
+
44
+ ```typescript
45
+ import { convertImage } from '@xiping/node-utils';
46
+
47
+ // 将 JPG 转换为 WebP
48
+ const result = await convertImage(
49
+ './input/sample.jpg',
50
+ './output/sample.webp',
51
+ 'webp',
52
+ {
53
+ quality: 85,
54
+ width: 800,
55
+ height: 600
56
+ }
57
+ );
58
+
59
+ console.log('转换结果:', result);
60
+ ```
61
+
62
+ ### 2. 批量转换
63
+
64
+ ```typescript
65
+ import { batchConvert } from '@xiping/node-utils';
66
+
67
+ const files = [
68
+ {
69
+ inputPath: './input/image1.jpg',
70
+ outputPath: './output/image1.webp',
71
+ format: 'webp'
72
+ },
73
+ {
74
+ inputPath: './input/image2.png',
75
+ outputPath: './output/image2.avif',
76
+ format: 'avif'
77
+ }
78
+ ];
79
+
80
+ const results = await batchConvert(files, {
81
+ quality: 80,
82
+ width: 1200
83
+ });
84
+ ```
85
+
86
+ ### 3. 创建缩略图
87
+
88
+ ```typescript
89
+ import { createThumbnail } from '@xiping/node-utils';
90
+
91
+ const result = await createThumbnail(
92
+ './input/large-image.jpg',
93
+ './output/thumbnail.jpg',
94
+ 200,
95
+ 200,
96
+ 'jpeg',
97
+ { quality: 90 }
98
+ );
99
+ ```
100
+
101
+ ### 4. 获取图片信息
102
+
103
+ ```typescript
104
+ import { getImageInfo } from '@xiping/node-utils';
105
+
106
+ const info = await getImageInfo('./input/sample.jpg');
107
+ console.log('图片信息:', info);
108
+ ```
109
+
110
+ ## 高级用法
111
+
112
+ ### 使用 ImageConverter 类
113
+
114
+ ```typescript
115
+ import { ImageConverter } from '@xiping/node-utils';
116
+
117
+ // 转换为 WebP(推荐用于 Web)
118
+ await ImageConverter.toWebP('./input.jpg', './output.webp', 85);
119
+
120
+ // 转换为 AVIF(最新格式,压缩率更高)
121
+ await ImageConverter.toAVIF('./input.jpg', './output.avif', 80);
122
+
123
+ // 转换为 PNG(保持透明度)
124
+ await ImageConverter.toPNG('./input.jpg', './output.png', true);
125
+
126
+ // 转换为 JPEG(通用格式)
127
+ await ImageConverter.toJPEG('./input.png', './output.jpg', 90);
128
+ ```
129
+
130
+ ### 创建响应式图片
131
+
132
+ ```typescript
133
+ import { ImageConverter } from '@xiping/node-utils';
134
+
135
+ const sizes = [
136
+ { width: 320, height: 240, suffix: 'small' },
137
+ { width: 640, height: 480, suffix: 'medium' },
138
+ { width: 1280, height: 960, suffix: 'large' }
139
+ ];
140
+
141
+ const results = await ImageConverter.createResponsiveImages(
142
+ './input/hero-image.jpg',
143
+ './output/',
144
+ 'hero',
145
+ sizes
146
+ );
147
+ ```
148
+
149
+ ## 转换选项
150
+
151
+ ```typescript
152
+ interface ConvertOptions {
153
+ /** 输出质量 (1-100),仅对 JPEG、WebP、AVIF 有效 */
154
+ quality?: number;
155
+ /** 是否保持透明度,仅对 PNG、WebP 有效 */
156
+ keepTransparency?: boolean;
157
+ /** 输出宽度,保持宽高比 */
158
+ width?: number;
159
+ /** 输出高度,保持宽高比 */
160
+ height?: number;
161
+ /** 是否强制调整尺寸(不保持宽高比) */
162
+ forceResize?: boolean;
163
+ /** 压缩级别 (0-9),仅对 PNG 有效 */
164
+ compressionLevel?: number;
165
+ /** 是否渐进式编码,仅对 JPEG 有效 */
166
+ progressive?: boolean;
167
+ }
168
+ ```
169
+
170
+ ## 转换结果
171
+
172
+ ```typescript
173
+ interface ConvertResult {
174
+ /** 输入文件路径 */
175
+ inputPath: string;
176
+ /** 输出文件路径 */
177
+ outputPath: string;
178
+ /** 原始文件大小(字节) */
179
+ originalSize: number;
180
+ /** 转换后文件大小(字节) */
181
+ convertedSize: number;
182
+ /** 压缩率 */
183
+ compressionRatio: number;
184
+ /** 转换耗时(毫秒) */
185
+ processingTime: number;
186
+ }
187
+ ```
188
+
189
+ ## 格式推荐
190
+
191
+ ### Web 应用
192
+ - **WebP**: 现代浏览器支持,压缩率高,推荐用于 Web
193
+ - **AVIF**: 最新格式,压缩率最高,但浏览器支持有限
194
+ - **JPEG**: 通用格式,兼容性最好
195
+
196
+ ### 移动应用
197
+ - **WebP**: Android 原生支持,iOS 14+ 支持
198
+ - **JPEG**: 通用兼容性
199
+
200
+ ### 打印/专业用途
201
+ - **PNG**: 无损压缩,支持透明度
202
+ - **TIFF**: 专业格式,支持多种压缩算法
203
+
204
+ ## 性能优化建议
205
+
206
+ 1. **批量处理**: 使用 `batchConvert` 进行批量转换
207
+ 2. **质量设置**: 根据用途调整质量参数(Web 用 80-85,打印用 90-95)
208
+ 3. **尺寸优化**: 根据显示需求设置合适的输出尺寸
209
+ 4. **格式选择**: 优先使用 WebP 或 AVIF 以获得更好的压缩率
210
+
211
+ ## 错误处理
212
+
213
+ 所有函数都会抛出详细的错误信息,建议使用 try-catch 进行错误处理:
214
+
215
+ ```typescript
216
+ try {
217
+ const result = await convertImage(inputPath, outputPath, 'webp');
218
+ console.log('转换成功:', result);
219
+ } catch (error) {
220
+ console.error('转换失败:', error.message);
221
+ }
222
+ ```
223
+
224
+ ## 注意事项
225
+
226
+ 1. 确保输入文件存在且可读
227
+ 2. 输出目录会自动创建
228
+ 3. GIF 格式暂不支持输出
229
+ 4. 大文件转换可能需要较长时间,建议在后台处理
230
+ 5. 某些格式转换可能不支持所有选项(如透明度)
@@ -0,0 +1,189 @@
1
+ # SRT到VTT转换器
2
+
3
+ 这个模块提供了在Node.js环境中将SRT字幕文件转换为WebVTT格式的功能。
4
+
5
+ ## 功能特性
6
+
7
+ - ✅ 从文件读取SRT并转换为VTT
8
+ - ✅ 支持批量转换多个SRT文件
9
+ - ✅ 支持字符串内容直接转换
10
+ - ✅ 自动验证SRT格式
11
+ - ✅ 完整的错误处理
12
+ - ✅ TypeScript支持
13
+
14
+ ## 安装
15
+
16
+ ```bash
17
+ npm install @xiping/node-utils
18
+ ```
19
+
20
+ ## 使用方法
21
+
22
+ ### 1. 从文件读取SRT并转换为VTT字符串
23
+
24
+ ```typescript
25
+ import { convertSrtFileToVtt } from '@xiping/node-utils/src/srt-to-vtt';
26
+
27
+ try {
28
+ const vttContent = convertSrtFileToVtt('./subtitles.srt');
29
+ console.log('转换后的VTT内容:', vttContent);
30
+ } catch (error) {
31
+ console.error('转换失败:', error);
32
+ }
33
+ ```
34
+
35
+ ### 2. 从文件读取SRT并保存为VTT文件
36
+
37
+ ```typescript
38
+ import { convertSrtFileToVttFile } from '@xiping/node-utils/src/srt-to-vtt';
39
+
40
+ try {
41
+ // 指定输出路径
42
+ const outputPath = convertSrtFileToVttFile('./subtitles.srt', './output/subtitles.vtt');
43
+ console.log('VTT文件已保存到:', outputPath);
44
+
45
+ // 或者使用默认输出路径(同目录,扩展名改为.vtt)
46
+ const defaultOutputPath = convertSrtFileToVttFile('./subtitles.srt');
47
+ console.log('默认输出路径:', defaultOutputPath);
48
+ } catch (error) {
49
+ console.error('文件转换失败:', error);
50
+ }
51
+ ```
52
+
53
+ ### 3. 批量转换多个SRT文件
54
+
55
+ ```typescript
56
+ import { batchConvertSrtToVtt } from '@xiping/node-utils/src/srt-to-vtt';
57
+
58
+ try {
59
+ const srtFiles = [
60
+ './subtitles1.srt',
61
+ './subtitles2.srt',
62
+ './subtitles3.srt'
63
+ ];
64
+
65
+ const results = batchConvertSrtToVtt(srtFiles, './output');
66
+
67
+ results.forEach(result => {
68
+ if (result.success) {
69
+ console.log(`✅ ${result.input} -> ${result.output}`);
70
+ } else {
71
+ console.error(`❌ ${result.input}: ${result.error}`);
72
+ }
73
+ });
74
+ } catch (error) {
75
+ console.error('批量转换失败:', error);
76
+ }
77
+ ```
78
+
79
+ ### 4. 从字符串内容直接转换
80
+
81
+ ```typescript
82
+ import { convertSrtStringToVtt } from '@xiping/node-utils/src/srt-to-vtt';
83
+
84
+ try {
85
+ const srtContent = `1
86
+ 00:00:00,000 --> 00:00:04,000
87
+ 这是第一行字幕
88
+
89
+ 2
90
+ 00:00:04,000 --> 00:00:08,000
91
+ 这是第二行字幕`;
92
+
93
+ const vttContent = convertSrtStringToVtt(srtContent);
94
+ console.log('转换后的VTT内容:', vttContent);
95
+ } catch (error) {
96
+ console.error('字符串转换失败:', error);
97
+ }
98
+ ```
99
+
100
+ ## API参考
101
+
102
+ ### convertSrtFileToVtt(srtFilePath: string): string
103
+
104
+ 从文件读取SRT内容并转换为VTT格式字符串。
105
+
106
+ **参数:**
107
+ - `srtFilePath`: SRT文件路径
108
+
109
+ **返回值:**
110
+ - 转换后的VTT内容字符串
111
+
112
+ **异常:**
113
+ - 文件不存在时抛出错误
114
+ - 文件格式无效时抛出错误
115
+
116
+ ### convertSrtFileToVttFile(srtFilePath: string, outputPath?: string): string
117
+
118
+ 从文件读取SRT内容并转换为VTT格式,同时保存到文件。
119
+
120
+ **参数:**
121
+ - `srtFilePath`: SRT文件路径
122
+ - `outputPath`: 输出VTT文件路径(可选)
123
+
124
+ **返回值:**
125
+ - 输出文件路径
126
+
127
+ ### batchConvertSrtToVtt(srtFilePaths: string[], outputDir?: string): Array<{ input: string; output: string; success: boolean; error?: string }>
128
+
129
+ 批量转换多个SRT文件到VTT格式。
130
+
131
+ **参数:**
132
+ - `srtFilePaths`: SRT文件路径数组
133
+ - `outputDir`: 输出目录(可选)
134
+
135
+ **返回值:**
136
+ - 转换结果数组,包含每个文件的转换状态
137
+
138
+ ### convertSrtStringToVtt(srtContent: string): string
139
+
140
+ 从字符串内容直接转换为VTT格式。
141
+
142
+ **参数:**
143
+ - `srtContent`: SRT内容字符串
144
+
145
+ **返回值:**
146
+ - VTT格式字符串
147
+
148
+ ## 格式说明
149
+
150
+ ### SRT格式
151
+ ```
152
+ 1
153
+ 00:00:00,000 --> 00:00:04,000
154
+ 字幕内容
155
+
156
+ 2
157
+ 00:00:04,000 --> 00:00:08,000
158
+ 字幕内容
159
+ ```
160
+
161
+ ### WebVTT格式
162
+ ```
163
+ WEBVTT
164
+
165
+ 00:00:00.000 --> 00:00:04.000
166
+ 字幕内容
167
+
168
+ 00:00:04.000 --> 00:00:08.000
169
+ 字幕内容
170
+ ```
171
+
172
+ ## 主要差异
173
+
174
+ 1. **时间格式**: SRT使用逗号分隔毫秒,VTT使用点分隔
175
+ 2. **头部**: VTT需要`WEBVTT`头部
176
+ 3. **索引**: SRT包含序号,VTT不包含
177
+
178
+ ## 错误处理
179
+
180
+ 所有函数都包含完整的错误处理:
181
+
182
+ - 文件不存在
183
+ - 文件格式错误
184
+ - 权限问题
185
+ - 编码问题
186
+
187
+ ## 示例
188
+
189
+ 查看 `example.ts` 文件获取完整的使用示例。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiping/node-utils",
3
- "version": "1.0.60",
3
+ "version": "1.0.61",
4
4
  "description": "node-utils",
5
5
  "type": "module",
6
6
  "author": "The-End-Hero <527409987@qq.com>",
@@ -20,12 +20,12 @@
20
20
  },
21
21
  "scripts": {
22
22
  "test": "echo \"Error: run tests from root\" && exit 1",
23
- "build": "tsc"
23
+ "build": "tsc && node scripts/copy-readmes.js"
24
24
  },
25
25
  "bugs": {
26
26
  "url": "https://github.com/The-End-Hero/wang-ping/issues"
27
27
  },
28
- "gitHead": "ccc3b005287f15abceac6210833bf8c8ace3fbaf",
28
+ "gitHead": "6cb2ac8c64a780abb18ec793978d3adfff71fba7",
29
29
  "publishConfig": {
30
30
  "access": "public",
31
31
  "registry": "https://registry.npmjs.org/"