@xiping/node-utils 1.0.61 → 1.0.63

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 ADDED
@@ -0,0 +1,45 @@
1
+ # @xiping/node-utils
2
+
3
+ Node.js 通用工具库,提供目录树、路径、SRT→VTT 字幕转换、FFmpeg 视频处理、文件信息与图片处理等能力。
4
+
5
+ ```bash
6
+ npm install @xiping/node-utils
7
+ ```
8
+
9
+ ## 使用案例
10
+
11
+ ```typescript
12
+ import {
13
+ buildDirectoryTree,
14
+ getParentDirectory,
15
+ convertSrtFileToVttFile,
16
+ getVideoInfo,
17
+ getThumbnail,
18
+ getFileConfig,
19
+ convertImage,
20
+ checkFFmpegAvailability,
21
+ } from '@xiping/node-utils';
22
+
23
+ // 目录树
24
+ const tree = buildDirectoryTree('/path/to/dir');
25
+
26
+ // 路径
27
+ const parent = getParentDirectory('/Users/documents/folder');
28
+
29
+ // SRT 转 VTT
30
+ convertSrtFileToVttFile('/path/to/subtitle.srt');
31
+
32
+ // 视频信息与缩略图(需系统安装 ffmpeg)
33
+ if (checkFFmpegAvailability()) {
34
+ const info = getVideoInfo('/path/to/video.mp4');
35
+ const thumb = await getThumbnail('/path/to/video.mp4', { frames: 30 });
36
+ }
37
+
38
+ // 文件配置(含视频元数据)
39
+ const config = getFileConfig('/path/to/file.mp4');
40
+
41
+ // 图片格式转换
42
+ await convertImage(inputPath, outputPath, 'webp', { quality: 80 });
43
+ ```
44
+
45
+ 更多说明见 [src/ffmpeg/README.md](./src/ffmpeg/README.md)、[src/image/README.md](./src/image/README.md)、[src/srt-to-vtt/README.md](./src/srt-to-vtt/README.md)。
@@ -12,14 +12,14 @@
12
12
  | 缩略图生成 | 多帧合成预览图(AVIF/WebP/JPEG/PNG) | ffmpeg + sharp |
13
13
  | 视频截取 | 按时间范围截取,流复制不重编码 | ffmpeg |
14
14
  | 提取音频 | 从视频中提取音轨为 mp3/m4a/wav | ffmpeg |
15
+ | 视频转码 | HEVC/AV1 转码,支持转换进度回调 | ffmpeg |
15
16
  | 环境检查 | 检测 ffmpeg/ffprobe 是否可用 | - |
16
17
 
17
18
  ## 安装要求
18
19
 
19
20
  ### 依赖包
20
21
 
21
- - Node.js
22
- - [shelljs](https://www.npmjs.com/package/shelljs)
22
+ - Node.js(使用内置 `child_process.spawnSync` 调用 ffmpeg/ffprobe,兼容 Electron、跨平台)
23
23
  - [sharp](https://www.npmjs.com/package/sharp)(仅缩略图功能需要)
24
24
 
25
25
  ### 系统要求
@@ -308,7 +308,79 @@ const result = await extractAudio('/path/to/video.mp4', {
308
308
 
309
309
  ---
310
310
 
311
- ## 5. 环境检查(check
311
+ ## 5. 视频转码(transcoding
312
+
313
+ 将视频转码为 HEVC 或 AV1,不依赖 shelljs,仅使用 Node 内置 `child_process`(spawn)与 `fs`。默认生成新文件(如 `{原名}_hevc.mp4` 或 `{原名}_av1.mp4`),不替换原文件;可选开启“替换原文件”。支持转换进度回调。
314
+
315
+ ### 基本使用
316
+
317
+ ```typescript
318
+ import { transcoding, getAvailableEncoders } from '@xiping/node-utils';
319
+
320
+ // 转码为 HEVC(默认),生成新文件
321
+ const result = await transcoding('/path/to/video.mp4');
322
+ console.log(result.outputPath); // 如 /path/to/video_hevc.mp4
323
+
324
+ // 指定 AV1、不生成缩略图
325
+ const r2 = await transcoding('/path/to/video.mp4', {
326
+ format: 'av1',
327
+ generateThumbnail: false,
328
+ });
329
+
330
+ // 替换原文件(删除原文件并将输出重命名为原路径)
331
+ const r3 = await transcoding('/path/to/video.mp4', {
332
+ format: 'hevc',
333
+ replaceOriginal: true,
334
+ });
335
+ ```
336
+
337
+ ### 转换进度(onProgress)
338
+
339
+ ```typescript
340
+ const result = await transcoding('/path/to/video.mp4', {
341
+ onProgress(progress) {
342
+ console.log(progress.phase, progress.percent, progress.message);
343
+ // phase: 'encoding' | 'done'
344
+ if (progress.currentTime != null && progress.duration != null) {
345
+ console.log(`已处理 ${progress.currentTime}/${progress.duration} 秒`);
346
+ }
347
+ },
348
+ });
349
+ ```
350
+
351
+ ### TranscodingConfig
352
+
353
+ | 选项 | 类型 | 默认值 | 说明 |
354
+ |--------------------|----------|----------|------|
355
+ | format | string | 'hevc' | 目标编码:'hevc' \| 'av1' |
356
+ | generateThumbnail | boolean | true | 是否生成缩略图 |
357
+ | replaceOriginal | boolean | false | 是否替换原文件(删除原文件并将输出重命名为原路径) |
358
+ | onProgress | function | - | 转换进度回调 |
359
+
360
+ ### TranscodeProgress(onProgress 回调参数)
361
+
362
+ | 字段 | 类型 | 说明 |
363
+ |-------------|--------|------|
364
+ | phase | string | 阶段:'encoding' \| 'done' |
365
+ | percent | number | 总进度 0–100 |
366
+ | message | string | 可读描述 |
367
+ | currentTime | number | 当前已处理时长(秒) |
368
+ | duration | number | 视频总时长(秒) |
369
+
370
+ ### 编码器检测
371
+
372
+ ```typescript
373
+ import { getAvailableEncoders } from '@xiping/node-utils';
374
+
375
+ const encoders = getAvailableEncoders();
376
+ // { av1Nvenc, libaomAv1, hevcNvenc, libx265 }
377
+ ```
378
+
379
+ HEVC 优先使用 hevc_nvenc(NVIDIA),否则使用 libx265;AV1 优先使用 av1_nvenc,否则使用 libaom-av1。输入路径需为**绝对路径**。
380
+
381
+ ---
382
+
383
+ ## 6. 环境检查(check)
312
384
 
313
385
  ```typescript
314
386
  import { checkFFmpegAvailability, isFfprobeAvailable } from '@xiping/node-utils';
@@ -321,7 +393,7 @@ if (isFfprobeAvailable()) {
321
393
  }
322
394
  ```
323
395
 
324
- - **checkFFmpegAvailability()**:用于缩略图、视频截取、提取音频前检查。
396
+ - **checkFFmpegAvailability()**:用于缩略图、视频截取、提取音频、视频转码前检查。
325
397
  - **isFfprobeAvailable()**:用于视频信息接口前检查。
326
398
 
327
399
  ---
@@ -367,5 +439,7 @@ ffprobe/ffmpeg 支持常见容器与编码,例如:
367
439
  | `cutVideoByDuration(path, start, duration, options)` | 按起始+时长截取 |
368
440
  | `cutVideoFromStart(path, durationSeconds, options)` | 从开头截取 N 秒 |
369
441
  | `extractAudio(path, options)` | 从视频提取音频(异步) |
442
+ | `transcoding(path, options)` | 视频转码为 HEVC/AV1(异步,支持进度) |
443
+ | `getAvailableEncoders()` | 检测可用编码器(av1_nvenc、libaom-av1、hevc_nvenc、libx265) |
370
444
  | `isFfprobeAvailable()` | 检测 ffprobe 是否可用 |
371
445
  | `checkFFmpegAvailability()` | 检测 ffmpeg 是否可用 |
@@ -1,7 +1,7 @@
1
- import shell from "shelljs";
1
+ import { runSync } from "./runSync.js";
2
2
  // 检查 ffmpeg 可用性
3
3
  export function checkFFmpegAvailability() {
4
- const result = shell.exec('ffmpeg -version', { silent: true });
4
+ const result = runSync("ffmpeg", ["-version"]);
5
5
  return result.code === 0;
6
6
  }
7
7
  /**
@@ -9,6 +9,6 @@ export function checkFFmpegAvailability() {
9
9
  * @returns 是否可用
10
10
  */
11
11
  export function isFfprobeAvailable() {
12
- const result = shell.exec("ffprobe -version", { silent: true });
12
+ const result = runSync("ffprobe", ["-version"]);
13
13
  return result.code === 0;
14
14
  }
@@ -1,5 +1,5 @@
1
- import shell from "shelljs";
2
1
  import * as fs from "fs";
2
+ import { runSync } from "./runSync.js";
3
3
  import * as path from "node:path";
4
4
  import * as os from "node:os";
5
5
  import { checkFFmpegAvailability } from "./check.js";
@@ -40,7 +40,12 @@ function normalizeTimeFormat(time) {
40
40
  // 获取视频时长
41
41
  function getDurationInSeconds(videoPath) {
42
42
  log('Getting video duration', { videoPath });
43
- const result = shell.exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${videoPath}"`, { silent: true });
43
+ const result = runSync("ffprobe", [
44
+ "-v", "error",
45
+ "-show_entries", "format=duration",
46
+ "-of", "default=noprint_wrappers=1:nokey=1",
47
+ videoPath,
48
+ ]);
44
49
  if (result.code !== 0) {
45
50
  throw new Error(`FFprobe failed to get duration: ${result.stderr}`);
46
51
  }
@@ -104,20 +109,6 @@ function timeToSeconds(timeStr) {
104
109
  }
105
110
  throw new Error(`Invalid time format: ${timeStr}`);
106
111
  }
107
- // 构建 FFmpeg 命令
108
- function buildFFmpegCommand(inputPath, outputPath, startTime, duration, options) {
109
- const { overwrite = false } = options;
110
- // 使用 copy 模式,保持原视频编码和质量
111
- let command = `ffmpeg -ss ${startTime} -i "${inputPath}" -t ${duration}`;
112
- // 复制视频和音频流,不重新编码
113
- command += ' -c copy';
114
- // 其他设置
115
- command += ' -avoid_negative_ts make_zero';
116
- command += ' -y'; // 自动覆盖输出文件
117
- // 输出文件
118
- command += ` "${outputPath}"`;
119
- return command;
120
- }
121
112
  // 临时目录管理
122
113
  async function withTempDir(fn, customTempDir) {
123
114
  const tempDir = customTempDir || fs.mkdtempSync(path.join(os.tmpdir(), "video-cut-"));
@@ -169,11 +160,16 @@ export const cutVideo = async (videoPath, options = {}) => {
169
160
  // 使用临时目录处理
170
161
  await withTempDir(async (tempDir) => {
171
162
  const tempOutputPath = path.join(tempDir, `temp_output.${outputFormat}`);
172
- // 构建 FFmpeg 命令
173
- const command = buildFFmpegCommand(validatedPath, tempOutputPath, timeParams.startTime, timeParams.duration, options);
174
- log('Executing FFmpeg command', { command });
175
- // 执行 FFmpeg 命令
176
- const result = shell.exec(command, { silent: true });
163
+ log('Executing FFmpeg command', { startTime: timeParams.startTime, duration: timeParams.duration });
164
+ const result = runSync("ffmpeg", [
165
+ "-ss", timeParams.startTime,
166
+ "-i", validatedPath,
167
+ "-t", timeParams.duration,
168
+ "-c", "copy",
169
+ "-avoid_negative_ts", "make_zero",
170
+ "-y",
171
+ tempOutputPath,
172
+ ]);
177
173
  if (result.code !== 0) {
178
174
  throw new Error(`FFmpeg failed: ${result.stderr}`);
179
175
  }
@@ -1,5 +1,5 @@
1
- import shell from "shelljs";
2
1
  import { spawn } from "node:child_process";
2
+ import { runSync } from "./runSync.js";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import * as os from "node:os";
@@ -18,7 +18,12 @@ function validateAndSanitizePath(inputPath) {
18
18
  return inputPath;
19
19
  }
20
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 });
21
+ const result = runSync("ffprobe", [
22
+ "-v", "error",
23
+ "-show_entries", "format=duration",
24
+ "-of", "default=noprint_wrappers=1:nokey=1",
25
+ videoPath,
26
+ ]);
22
27
  if (result.code !== 0) {
23
28
  throw new Error(`FFprobe failed to get duration: ${result.stderr}`);
24
29
  }
@@ -57,13 +62,12 @@ function buildFfmpegArgs(outputPath, outputFormat, copyStream, audioCodec) {
57
62
  args.push("-y", outputPath);
58
63
  return args;
59
64
  }
60
- /** 执行 ffmpeg(shell.exec),无进度 */
65
+ /** 执行 ffmpeg(runSync),无进度 */
61
66
  function runFfmpegSync(inputPath, outputPath, outputFormat, copyStream, audioCodec) {
62
67
  const args = buildFfmpegArgs(outputPath, outputFormat, copyStream, audioCodec);
63
68
  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 });
69
+ log("Executing FFmpeg command", { inputPath, outputPath });
70
+ const result = runSync("ffmpeg", args);
67
71
  if (result.code !== 0) {
68
72
  throw new Error(`FFmpeg failed: ${result.stderr}`);
69
73
  }
@@ -1,5 +1,5 @@
1
- import shell from "shelljs";
2
1
  import Sharp from "sharp";
2
+ import { runSync } from "./runSync.js";
3
3
  import * as fs from "fs";
4
4
  import * as path from "node:path";
5
5
  import * as os from "node:os";
@@ -22,7 +22,12 @@ function validateAndSanitizePath(inputPath) {
22
22
  // 获取视频时长
23
23
  function getDurationInSeconds(videoPath) {
24
24
  log('Getting video duration', { videoPath });
25
- const result = shell.exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${videoPath}"`, { silent: true });
25
+ const result = runSync("ffprobe", [
26
+ "-v", "error",
27
+ "-show_entries", "format=duration",
28
+ "-of", "default=noprint_wrappers=1:nokey=1",
29
+ videoPath,
30
+ ]);
26
31
  if (result.code !== 0) {
27
32
  throw new Error(`FFprobe failed to get duration: ${result.stderr}`);
28
33
  }
@@ -40,7 +45,15 @@ async function extractFrames(videoPath, tempDir, frames, interval, maxConcurrenc
40
45
  const t = interval * i;
41
46
  const framePath = path.join(tempDir, `frame-${t}.jpg`);
42
47
  return new Promise((resolve, reject) => {
43
- const result = shell.exec(`ffmpeg -ss ${t} -i "${videoPath}" -vframes 1 -q:v 10 -an -threads 4 "${framePath}"`, { silent: true });
48
+ const result = runSync("ffmpeg", [
49
+ "-ss", String(t),
50
+ "-i", videoPath,
51
+ "-vframes", "1",
52
+ "-q:v", "10",
53
+ "-an",
54
+ "-threads", "4",
55
+ framePath,
56
+ ]);
44
57
  if (result.code === 0) {
45
58
  log(`Frame ${i + 1}/${total} extracted`, { time: t });
46
59
  resolve();
@@ -1,4 +1,5 @@
1
- import shell from "shelljs";
1
+ import * as fs from "node:fs";
2
+ import { runSync } from "./runSync.js";
2
3
  /**
3
4
  * 格式化文件大小
4
5
  * @param bytes 字节数
@@ -32,12 +33,16 @@ function formatDuration(seconds) {
32
33
  * @returns 视频信息对象
33
34
  */
34
35
  export function getVideoInfo(videoPath) {
35
- // 检查文件是否存在
36
- if (!shell.test("-f", videoPath)) {
36
+ if (!fs.existsSync(videoPath)) {
37
37
  throw new Error(`视频文件不存在: ${videoPath}`);
38
38
  }
39
- // 使用 ffprobe 获取 JSON 格式的视频信息
40
- const result = shell.exec(`ffprobe -v quiet -print_format json -show_format -show_streams "${videoPath}"`, { silent: true });
39
+ const result = runSync("ffprobe", [
40
+ "-v", "quiet",
41
+ "-print_format", "json",
42
+ "-show_format",
43
+ "-show_streams",
44
+ videoPath,
45
+ ]);
41
46
  if (result.code !== 0) {
42
47
  throw new Error(`获取视频信息失败: ${result.stderr}`);
43
48
  }
@@ -98,10 +103,18 @@ export function getMultipleVideoInfo(videoPaths) {
98
103
  * @returns 详细的视频信息
99
104
  */
100
105
  export function getDetailedVideoInfo(videoPath) {
101
- if (!shell.test("-f", videoPath)) {
106
+ if (!fs.existsSync(videoPath)) {
102
107
  throw new Error(`视频文件不存在: ${videoPath}`);
103
108
  }
104
- const result = shell.exec(`ffprobe -v quiet -print_format json -show_format -show_streams -show_chapters -show_private_data "${videoPath}"`, { silent: true });
109
+ const result = runSync("ffprobe", [
110
+ "-v", "quiet",
111
+ "-print_format", "json",
112
+ "-show_format",
113
+ "-show_streams",
114
+ "-show_chapters",
115
+ "-show_private_data",
116
+ videoPath,
117
+ ]);
105
118
  if (result.code !== 0) {
106
119
  throw new Error(`获取详细视频信息失败: ${result.stderr}`);
107
120
  }
@@ -3,3 +3,4 @@ export * from "./check.js";
3
3
  export * from "./cutVideo.js";
4
4
  export * from "./getVideoInfo.js";
5
5
  export * from "./extractAudio.js";
6
+ export * from "./transcode.js";
@@ -3,3 +3,4 @@ export * from "./check.js";
3
3
  export * from "./cutVideo.js";
4
4
  export * from "./getVideoInfo.js";
5
5
  export * from "./extractAudio.js";
6
+ export * from "./transcode.js";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 同步执行外部命令(跨平台、兼容 Electron,不依赖 shell)。
3
+ * 使用 spawnSync + 参数数组,避免 shell 解析和路径/空格问题。
4
+ *
5
+ * @param command 可执行文件名(如 ffmpeg、ffprobe)
6
+ * @param args 参数列表(路径无需额外引号)
7
+ * @returns 与 shelljs.exec 兼容的 { code, stdout, stderr }
8
+ */
9
+ export declare function runSync(command: string, args: string[], options?: {
10
+ encoding?: BufferEncoding;
11
+ }): {
12
+ code: number;
13
+ stdout: string;
14
+ stderr: string;
15
+ };
@@ -0,0 +1,20 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /**
3
+ * 同步执行外部命令(跨平台、兼容 Electron,不依赖 shell)。
4
+ * 使用 spawnSync + 参数数组,避免 shell 解析和路径/空格问题。
5
+ *
6
+ * @param command 可执行文件名(如 ffmpeg、ffprobe)
7
+ * @param args 参数列表(路径无需额外引号)
8
+ * @returns 与 shelljs.exec 兼容的 { code, stdout, stderr }
9
+ */
10
+ export function runSync(command, args, options) {
11
+ const encoding = options?.encoding ?? "utf8";
12
+ const result = spawnSync(command, args, {
13
+ encoding,
14
+ windowsHide: true,
15
+ });
16
+ const stdout = (result.stdout ?? "");
17
+ const stderr = (result.stderr ?? "");
18
+ const code = result.status ?? (result.signal ? -1 : 0);
19
+ return { code, stdout, stderr };
20
+ }
@@ -0,0 +1,40 @@
1
+ /** 转码进度信息,用于 onProgress 回调 */
2
+ export interface TranscodeProgress {
3
+ /** 当前阶段 */
4
+ phase: "encoding" | "done";
5
+ /** 总进度 0–100 */
6
+ percent: number;
7
+ /** 可读描述 */
8
+ message?: string;
9
+ /** 当前已处理时长(秒) */
10
+ currentTime?: number;
11
+ /** 视频总时长(秒) */
12
+ duration?: number;
13
+ }
14
+ export interface TranscodingConfig {
15
+ /** 是否生成缩略图,默认 true */
16
+ generateThumbnail?: boolean;
17
+ /** 目标编码格式,默认 'hevc' */
18
+ format?: "av1" | "hevc";
19
+ /** 是否替换原文件(删除原文件并将输出重命名为原路径),默认 false */
20
+ replaceOriginal?: boolean;
21
+ /** 转换进度回调 */
22
+ onProgress?: (progress: TranscodeProgress) => void;
23
+ }
24
+ export interface AvailableEncoders {
25
+ av1Nvenc: boolean;
26
+ libaomAv1: boolean;
27
+ hevcNvenc: boolean;
28
+ libx265: boolean;
29
+ }
30
+ /** 同步检测可用编码器 */
31
+ export declare function getAvailableEncoders(): AvailableEncoders;
32
+ export interface TranscodingResult {
33
+ /** 转码输出文件路径(替换原文件时为原路径,否则为 {base}_hevc.mp4 或 {base}_av1.mp4) */
34
+ outputPath: string;
35
+ }
36
+ /**
37
+ * 将视频转码为 HEVC 或 AV1,不依赖 shelljs,使用 Node 内置 child_process 与 fs。
38
+ * 支持转换进度回调;默认生成新文件,不替换原文件。
39
+ */
40
+ export declare function transcoding(inputPath: string, config?: TranscodingConfig): Promise<TranscodingResult>;
@@ -0,0 +1,181 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { runSync } from "./runSync.js";
5
+ import { checkFFmpegAvailability } from "./check.js";
6
+ import { getThumbnail } from "./getThumbnail.js";
7
+ const defaultConfig = {
8
+ generateThumbnail: true,
9
+ format: "hevc",
10
+ replaceOriginal: false,
11
+ };
12
+ /** 同步检测可用编码器 */
13
+ export function getAvailableEncoders() {
14
+ const result = runSync("ffmpeg", ["-encoders"]);
15
+ const stdout = result.code === 0 ? result.stdout : "";
16
+ return {
17
+ av1Nvenc: stdout.includes("av1_nvenc"),
18
+ libaomAv1: stdout.includes("libaom-av1"),
19
+ hevcNvenc: stdout.includes("hevc_nvenc"),
20
+ libx265: stdout.includes("libx265"),
21
+ };
22
+ }
23
+ function validateAndSanitizePath(inputPath) {
24
+ if (!inputPath || typeof inputPath !== "string") {
25
+ throw new Error("Invalid path provided");
26
+ }
27
+ if (!path.isAbsolute(inputPath)) {
28
+ throw new Error("Path must be absolute");
29
+ }
30
+ return inputPath;
31
+ }
32
+ function getDurationInSeconds(videoPath) {
33
+ const result = runSync("ffprobe", [
34
+ "-v",
35
+ "error",
36
+ "-show_entries",
37
+ "format=duration",
38
+ "-of",
39
+ "default=noprint_wrappers=1:nokey=1",
40
+ videoPath,
41
+ ]);
42
+ if (result.code !== 0) {
43
+ throw new Error(`FFprobe failed to get duration: ${result.stderr}`);
44
+ }
45
+ const duration = parseFloat(result.stdout);
46
+ if (isNaN(duration) || duration <= 0) {
47
+ throw new Error("Invalid video duration");
48
+ }
49
+ return duration;
50
+ }
51
+ /** 解析 ffmpeg stderr 中的 time=HH:MM:SS.xx 为秒数 */
52
+ function parseTimeFromStderr(line) {
53
+ const match = line.match(/time=(\d{2}):(\d{2}):(\d{2}\.\d+)/);
54
+ if (!match)
55
+ return null;
56
+ const [, h, m, s] = match;
57
+ return parseInt(h, 10) * 3600 + parseInt(m, 10) * 60 + parseFloat(s);
58
+ }
59
+ /** 根据格式与可用编码器构建 ffmpeg 参数数组 */
60
+ function buildTranscodeArgs(inputPath, outputPath, format, encoders) {
61
+ const args = ["-i", inputPath];
62
+ if (format === "av1") {
63
+ if (encoders.av1Nvenc) {
64
+ args.push("-c:v", "av1_nvenc", "-preset", "slow", "-rc", "constqp", "-qp", "28");
65
+ }
66
+ else if (encoders.libaomAv1) {
67
+ args.push("-c:v", "libaom-av1", "-crf", "30", "-b:v", "0");
68
+ }
69
+ else {
70
+ throw new Error("没有可用的 AV1 编码器");
71
+ }
72
+ }
73
+ else {
74
+ if (encoders.hevcNvenc) {
75
+ args.push("-c:v", "hevc_nvenc", "-preset", "slow", "-rc", "constqp", "-qp", "28");
76
+ }
77
+ else if (encoders.libx265) {
78
+ args.push("-c:v", "libx265", "-crf", "28");
79
+ }
80
+ else {
81
+ throw new Error("没有可用的 HEVC 编码器");
82
+ }
83
+ }
84
+ args.push("-y", outputPath);
85
+ return args;
86
+ }
87
+ /** 使用 spawn 执行 ffmpeg 转码,解析 stderr 并回调 onProgress */
88
+ function runTranscodeWithProgress(inputPath, outputPath, format, encoders, totalDuration, onProgress) {
89
+ return new Promise((resolve, reject) => {
90
+ const args = buildTranscodeArgs(inputPath, outputPath, format, encoders);
91
+ const child = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
92
+ let lastPercent = -1;
93
+ const report = (progress) => {
94
+ if (progress.percent !== lastPercent) {
95
+ lastPercent = progress.percent;
96
+ onProgress(progress);
97
+ }
98
+ };
99
+ if (child.stderr) {
100
+ let buffer = "";
101
+ child.stderr.setEncoding("utf8");
102
+ child.stderr.on("data", (chunk) => {
103
+ buffer += chunk;
104
+ const lines = buffer.split(/\r?\n/);
105
+ buffer = lines.pop() ?? "";
106
+ for (const line of lines) {
107
+ const currentTime = parseTimeFromStderr(line);
108
+ if (currentTime != null && totalDuration > 0) {
109
+ const percent = Math.min(99, Math.round((currentTime / totalDuration) * 100));
110
+ report({
111
+ phase: "encoding",
112
+ percent,
113
+ message: "正在转码...",
114
+ currentTime,
115
+ duration: totalDuration,
116
+ });
117
+ }
118
+ }
119
+ });
120
+ }
121
+ child.on("error", (err) => reject(err));
122
+ child.on("close", (code, signal) => {
123
+ if (signal) {
124
+ reject(new Error(`FFmpeg killed by signal: ${signal}`));
125
+ return;
126
+ }
127
+ if (code !== 0) {
128
+ reject(new Error(`FFmpeg exited with code ${code}`));
129
+ return;
130
+ }
131
+ report({
132
+ phase: "done",
133
+ percent: 100,
134
+ message: "完成",
135
+ currentTime: totalDuration,
136
+ duration: totalDuration,
137
+ });
138
+ resolve();
139
+ });
140
+ });
141
+ }
142
+ /**
143
+ * 将视频转码为 HEVC 或 AV1,不依赖 shelljs,使用 Node 内置 child_process 与 fs。
144
+ * 支持转换进度回调;默认生成新文件,不替换原文件。
145
+ */
146
+ export async function transcoding(inputPath, config = {}) {
147
+ const finalConfig = { ...defaultConfig, ...config };
148
+ if (!checkFFmpegAvailability()) {
149
+ throw new Error("FFmpeg is not available. Please install FFmpeg first.");
150
+ }
151
+ const validatedPath = validateAndSanitizePath(inputPath);
152
+ if (!fs.existsSync(validatedPath)) {
153
+ throw new Error(`Video file not found: ${validatedPath}`);
154
+ }
155
+ const encoders = getAvailableEncoders();
156
+ const format = finalConfig.format;
157
+ const duration = getDurationInSeconds(validatedPath);
158
+ const dir = path.dirname(validatedPath);
159
+ const baseName = path.basename(validatedPath, path.extname(validatedPath));
160
+ const suffix = format === "av1" ? "_av1" : "_hevc";
161
+ const outputFileName = `${baseName}${suffix}.mp4`;
162
+ const tempOutputPath = path.join(dir, outputFileName);
163
+ const reportProgress = (progress) => {
164
+ finalConfig.onProgress?.(progress);
165
+ };
166
+ await runTranscodeWithProgress(validatedPath, tempOutputPath, format, encoders, duration, reportProgress);
167
+ if (finalConfig.generateThumbnail) {
168
+ await getThumbnail(validatedPath);
169
+ }
170
+ let resultPath;
171
+ if (finalConfig.replaceOriginal) {
172
+ fs.unlinkSync(validatedPath);
173
+ const originalPath = path.join(dir, path.basename(validatedPath));
174
+ fs.renameSync(tempOutputPath, originalPath);
175
+ resultPath = originalPath;
176
+ }
177
+ else {
178
+ resultPath = tempOutputPath;
179
+ }
180
+ return { outputPath: resultPath };
181
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiping/node-utils",
3
- "version": "1.0.61",
3
+ "version": "1.0.63",
4
4
  "description": "node-utils",
5
5
  "type": "module",
6
6
  "author": "The-End-Hero <527409987@qq.com>",
@@ -16,16 +16,16 @@
16
16
  ],
17
17
  "repository": {
18
18
  "type": "git",
19
- "url": "git+https://github.com/The-End-Hero/wang-ping.git"
19
+ "url": "git+https://github.com/The-End-Hero/xiping.git"
20
20
  },
21
21
  "scripts": {
22
22
  "test": "echo \"Error: run tests from root\" && exit 1",
23
23
  "build": "tsc && node scripts/copy-readmes.js"
24
24
  },
25
25
  "bugs": {
26
- "url": "https://github.com/The-End-Hero/wang-ping/issues"
26
+ "url": "https://github.com/The-End-Hero/xiping/issues"
27
27
  },
28
- "gitHead": "6cb2ac8c64a780abb18ec793978d3adfff71fba7",
28
+ "gitHead": "466588a53ef8fa8bc6562a9933f1c9d707e978e5",
29
29
  "publishConfig": {
30
30
  "access": "public",
31
31
  "registry": "https://registry.npmjs.org/"
@@ -33,14 +33,12 @@
33
33
  "dependencies": {
34
34
  "@xiping/subtitle": "1.0.52",
35
35
  "chalk": "^5.6.2",
36
- "fs-extra": "^11.3.3",
37
- "sharp": "^0.34.4",
38
- "shelljs": "0.10.0",
36
+ "sharp": "^0.34.5",
39
37
  "srt-parser-2": "^1.2.3"
40
38
  },
41
39
  "devDependencies": {
42
- "@types/node": "^24.10.4",
40
+ "@types/node": "^24.10.13",
43
41
  "tslib": "^2.8.1",
44
- "typescript": "^5.9.2"
42
+ "typescript": "^5.9.3"
45
43
  }
46
44
  }