@xiping/node-utils 1.0.60 → 1.0.62
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 +45 -0
- package/lib/src/ffmpeg/README.md +370 -0
- package/lib/src/ffmpeg/README_cutVideo.md +220 -0
- package/lib/src/ffmpeg/check.js +3 -3
- package/lib/src/ffmpeg/cutVideo.js +17 -21
- package/lib/src/ffmpeg/extractAudio.d.ts +36 -0
- package/lib/src/ffmpeg/extractAudio.js +190 -0
- package/lib/src/ffmpeg/getThumbnail.js +16 -3
- package/lib/src/ffmpeg/getVideoInfo.js +20 -7
- package/lib/src/ffmpeg/index.d.ts +1 -0
- package/lib/src/ffmpeg/index.js +1 -0
- package/lib/src/ffmpeg/runSync.d.ts +15 -0
- package/lib/src/ffmpeg/runSync.js +20 -0
- package/lib/src/image/README.md +230 -0
- package/lib/src/srt-to-vtt/README.md +189 -0
- package/package.json +8 -10
|
@@ -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 =
|
|
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
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
}
|
|
@@ -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,190 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { runSync } from "./runSync.js";
|
|
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 = runSync("ffprobe", [
|
|
22
|
+
"-v", "error",
|
|
23
|
+
"-show_entries", "format=duration",
|
|
24
|
+
"-of", "default=noprint_wrappers=1:nokey=1",
|
|
25
|
+
videoPath,
|
|
26
|
+
]);
|
|
27
|
+
if (result.code !== 0) {
|
|
28
|
+
throw new Error(`FFprobe failed to get duration: ${result.stderr}`);
|
|
29
|
+
}
|
|
30
|
+
const duration = parseFloat(result.stdout);
|
|
31
|
+
if (isNaN(duration) || duration <= 0) {
|
|
32
|
+
throw new Error("Invalid video duration");
|
|
33
|
+
}
|
|
34
|
+
return duration;
|
|
35
|
+
}
|
|
36
|
+
/** 解析 ffmpeg stderr 中的 time=HH:MM:SS.xx 为秒数 */
|
|
37
|
+
function parseTimeFromStderr(line) {
|
|
38
|
+
const match = line.match(/time=(\d{2}):(\d{2}):(\d{2}\.\d+)/);
|
|
39
|
+
if (!match)
|
|
40
|
+
return null;
|
|
41
|
+
const [, h, m, s] = match;
|
|
42
|
+
return parseInt(h, 10) * 3600 + parseInt(m, 10) * 60 + parseFloat(s);
|
|
43
|
+
}
|
|
44
|
+
/** 根据格式和是否流复制,构建 ffmpeg 参数(不含输入输出路径) */
|
|
45
|
+
function buildFfmpegArgs(outputPath, outputFormat, copyStream, audioCodec) {
|
|
46
|
+
const args = ["-i", ""]; // input 占位,调用方填入
|
|
47
|
+
args.push("-vn"); // 不要视频
|
|
48
|
+
if (outputFormat === "mp3") {
|
|
49
|
+
args.push("-acodec", "libmp3lame");
|
|
50
|
+
}
|
|
51
|
+
else if (outputFormat === "m4a") {
|
|
52
|
+
if (copyStream && (audioCodec === "aac" || audioCodec === "aac_latm")) {
|
|
53
|
+
args.push("-acodec", "copy");
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
args.push("-acodec", "aac");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else if (outputFormat === "wav") {
|
|
60
|
+
args.push("-acodec", "pcm_s16le");
|
|
61
|
+
}
|
|
62
|
+
args.push("-y", outputPath);
|
|
63
|
+
return args;
|
|
64
|
+
}
|
|
65
|
+
/** 执行 ffmpeg(runSync),无进度 */
|
|
66
|
+
function runFfmpegSync(inputPath, outputPath, outputFormat, copyStream, audioCodec) {
|
|
67
|
+
const args = buildFfmpegArgs(outputPath, outputFormat, copyStream, audioCodec);
|
|
68
|
+
args[1] = inputPath;
|
|
69
|
+
log("Executing FFmpeg command", { inputPath, outputPath });
|
|
70
|
+
const result = runSync("ffmpeg", args);
|
|
71
|
+
if (result.code !== 0) {
|
|
72
|
+
throw new Error(`FFmpeg failed: ${result.stderr}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** 执行 ffmpeg(spawn),解析 stderr 并回调 onProgress */
|
|
76
|
+
function runFfmpegWithProgress(inputPath, outputPath, outputFormat, copyStream, audioCodec, totalDuration, onProgress) {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const args = buildFfmpegArgs(outputPath, outputFormat, copyStream, audioCodec);
|
|
79
|
+
args[1] = inputPath;
|
|
80
|
+
const child = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
81
|
+
let lastPercent = -1;
|
|
82
|
+
const report = (progress) => {
|
|
83
|
+
if (progress.percent !== lastPercent) {
|
|
84
|
+
lastPercent = progress.percent;
|
|
85
|
+
onProgress(progress);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
if (child.stderr) {
|
|
89
|
+
let buffer = "";
|
|
90
|
+
child.stderr.setEncoding("utf8");
|
|
91
|
+
child.stderr.on("data", (chunk) => {
|
|
92
|
+
buffer += chunk;
|
|
93
|
+
const lines = buffer.split(/\r?\n/);
|
|
94
|
+
buffer = lines.pop() ?? "";
|
|
95
|
+
for (const line of lines) {
|
|
96
|
+
const currentTime = parseTimeFromStderr(line);
|
|
97
|
+
if (currentTime != null && totalDuration > 0) {
|
|
98
|
+
const percent = Math.min(99, Math.round((currentTime / totalDuration) * 100));
|
|
99
|
+
report({
|
|
100
|
+
phase: "encoding",
|
|
101
|
+
percent,
|
|
102
|
+
message: "正在提取音频...",
|
|
103
|
+
currentTime,
|
|
104
|
+
duration: totalDuration,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
child.on("error", (err) => reject(err));
|
|
111
|
+
child.on("close", (code, signal) => {
|
|
112
|
+
if (signal) {
|
|
113
|
+
reject(new Error(`FFmpeg killed by signal: ${signal}`));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (code !== 0) {
|
|
117
|
+
reject(new Error(`FFmpeg exited with code ${code}`));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
report({
|
|
121
|
+
phase: "done",
|
|
122
|
+
percent: 100,
|
|
123
|
+
message: "完成",
|
|
124
|
+
currentTime: totalDuration,
|
|
125
|
+
duration: totalDuration,
|
|
126
|
+
});
|
|
127
|
+
resolve();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
export async function extractAudio(videoPath, options = {}) {
|
|
132
|
+
const startTime = Date.now();
|
|
133
|
+
const validatedPath = validateAndSanitizePath(videoPath);
|
|
134
|
+
if (!fs.existsSync(validatedPath)) {
|
|
135
|
+
throw new Error(`Video file not found: ${validatedPath}`);
|
|
136
|
+
}
|
|
137
|
+
if (!checkFFmpegAvailability()) {
|
|
138
|
+
throw new Error("FFmpeg is not available. Please install FFmpeg first.");
|
|
139
|
+
}
|
|
140
|
+
const { outputFileName, outputFormat = "mp3", overwrite = false, tempDir: customTempDir, copyStream = true, onProgress, } = options;
|
|
141
|
+
const videoInfo = getVideoInfo(validatedPath);
|
|
142
|
+
if (!videoInfo.audioCodec) {
|
|
143
|
+
throw new Error("Video has no audio stream.");
|
|
144
|
+
}
|
|
145
|
+
const duration = getDurationInSeconds(validatedPath);
|
|
146
|
+
const videoDir = path.dirname(validatedPath);
|
|
147
|
+
const baseName = path.basename(validatedPath, path.extname(validatedPath));
|
|
148
|
+
const defaultOutputName = `${baseName}.${outputFormat}`;
|
|
149
|
+
const outputFileNameFinal = outputFileName ?? defaultOutputName;
|
|
150
|
+
const outputPath = path.join(videoDir, outputFileNameFinal);
|
|
151
|
+
if (fs.existsSync(outputPath) && !overwrite) {
|
|
152
|
+
throw new Error(`Output file already exists: ${outputPath}. Use overwrite option to force overwrite.`);
|
|
153
|
+
}
|
|
154
|
+
const reportProgress = (progress) => {
|
|
155
|
+
onProgress?.(progress);
|
|
156
|
+
};
|
|
157
|
+
reportProgress({
|
|
158
|
+
phase: "preparing",
|
|
159
|
+
percent: 0,
|
|
160
|
+
message: "准备提取音频...",
|
|
161
|
+
duration,
|
|
162
|
+
});
|
|
163
|
+
const tempDir = customTempDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "extract-audio-"));
|
|
164
|
+
const tempOutputPath = path.join(tempDir, `temp_audio.${outputFormat}`);
|
|
165
|
+
try {
|
|
166
|
+
if (onProgress) {
|
|
167
|
+
await runFfmpegWithProgress(validatedPath, tempOutputPath, outputFormat, copyStream, videoInfo.audioCodec, duration, reportProgress);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
runFfmpegSync(validatedPath, tempOutputPath, outputFormat, copyStream, videoInfo.audioCodec);
|
|
171
|
+
}
|
|
172
|
+
fs.copyFileSync(tempOutputPath, outputPath);
|
|
173
|
+
log("Extract audio complete", { outputPath });
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
if (!customTempDir && fs.existsSync(tempDir)) {
|
|
177
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const outputStats = fs.statSync(outputPath);
|
|
181
|
+
const processingTime = Date.now() - startTime;
|
|
182
|
+
return {
|
|
183
|
+
outputPath,
|
|
184
|
+
metadata: {
|
|
185
|
+
duration,
|
|
186
|
+
fileSize: outputStats.size,
|
|
187
|
+
processingTime,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
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 (!
|
|
106
|
+
if (!fs.existsSync(videoPath)) {
|
|
102
107
|
throw new Error(`视频文件不存在: ${videoPath}`);
|
|
103
108
|
}
|
|
104
|
-
const result =
|
|
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
|
}
|
package/lib/src/ffmpeg/index.js
CHANGED
|
@@ -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,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. 某些格式转换可能不支持所有选项(如透明度)
|