@xiping/node-utils 1.0.64 → 1.0.65
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.
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 视频缩略图生成器
|
|
3
|
-
* 使用 ffmpeg 提取视频帧,使用 sharp 合成缩略图
|
|
4
|
-
*/
|
|
5
1
|
/** 进度信息,用于 onProgress 回调 */
|
|
6
2
|
export interface ThumbnailProgress {
|
|
7
3
|
/** 当前阶段 */
|
|
@@ -15,17 +11,26 @@ export interface ThumbnailProgress {
|
|
|
15
11
|
/** 可读描述 */
|
|
16
12
|
message?: string;
|
|
17
13
|
}
|
|
18
|
-
interface ThumbnailOptions {
|
|
14
|
+
export interface ThumbnailOptions {
|
|
15
|
+
/** 参与合成的帧数(实际提取 frames - 1 张),默认 60 */
|
|
19
16
|
frames?: number;
|
|
17
|
+
/** 输出图宽度,默认 3840 */
|
|
20
18
|
outputWidth?: number;
|
|
19
|
+
/** 每行列数,默认 4 */
|
|
21
20
|
columns?: number;
|
|
21
|
+
/** 输出文件名,默认 thumbnail.avif */
|
|
22
22
|
outputFileName?: string;
|
|
23
|
+
/** 输出质量 1–100,默认 80 */
|
|
23
24
|
quality?: number;
|
|
25
|
+
/** 输出格式,默认 avif */
|
|
24
26
|
format?: 'avif' | 'webp' | 'jpeg' | 'png';
|
|
27
|
+
/** 批处理大小,默认 10 */
|
|
25
28
|
batchSize?: number;
|
|
29
|
+
/** 最大并发数,默认 4 */
|
|
26
30
|
maxConcurrency?: number;
|
|
31
|
+
/** 临时目录 */
|
|
27
32
|
tempDir?: string;
|
|
28
|
-
/**
|
|
33
|
+
/** 进度回调 */
|
|
29
34
|
onProgress?: (progress: ThumbnailProgress) => void;
|
|
30
35
|
}
|
|
31
36
|
interface ProcessingResult {
|
|
@@ -4,6 +4,12 @@ import * as fs from "fs";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import { checkFFmpegAvailability } from "./check.js";
|
|
7
|
+
/**
|
|
8
|
+
* 视频缩略图生成器
|
|
9
|
+
* 使用 ffmpeg 提取视频帧,使用 sharp 合成缩略图
|
|
10
|
+
*/
|
|
11
|
+
/** HEIF/AVIF 编码器常见的单边最大像素数(libheif 安全限制) */
|
|
12
|
+
const HEIF_MAX_DIMENSION = 16384;
|
|
7
13
|
// 日志函数
|
|
8
14
|
const log = (message, data) => {
|
|
9
15
|
console.log(`[ThumbnailGenerator] ${message}`, data ? JSON.stringify(data, null, 2) : '');
|
|
@@ -77,11 +83,28 @@ async function createThumbnail(frameFiles, tempDir, options, progressCallbacks)
|
|
|
77
83
|
log('Creating thumbnail', { frameCount: frameFiles.length, columns, outputWidth });
|
|
78
84
|
// 计算尺寸
|
|
79
85
|
const rows = Math.ceil(frameFiles.length / columns);
|
|
80
|
-
|
|
86
|
+
let singleWidth = Math.floor(outputWidth / columns);
|
|
81
87
|
// 获取第一帧的元数据来计算宽高比
|
|
82
88
|
const firstFrame = await Sharp(path.join(tempDir, frameFiles[0])).metadata();
|
|
83
89
|
const aspectRatio = (firstFrame.width || 1) / (firstFrame.height || 1);
|
|
84
|
-
|
|
90
|
+
let singleHeight = Math.floor(singleWidth / aspectRatio);
|
|
91
|
+
let totalWidth = singleWidth * columns;
|
|
92
|
+
let totalHeight = singleHeight * rows;
|
|
93
|
+
// AVIF/HEIF 编码有单边尺寸上限,超过会在 macOS 等环境报 "Processed image is too large for the HEIF format"
|
|
94
|
+
if (totalWidth > HEIF_MAX_DIMENSION || totalHeight > HEIF_MAX_DIMENSION) {
|
|
95
|
+
const scale = Math.min(HEIF_MAX_DIMENSION / totalWidth, HEIF_MAX_DIMENSION / totalHeight);
|
|
96
|
+
singleWidth = Math.floor(singleWidth * scale);
|
|
97
|
+
singleHeight = Math.floor(singleHeight * scale);
|
|
98
|
+
totalWidth = singleWidth * columns;
|
|
99
|
+
totalHeight = singleHeight * rows;
|
|
100
|
+
log('Capped dimensions for HEIF/AVIF limit', {
|
|
101
|
+
scale: scale.toFixed(3),
|
|
102
|
+
totalWidth,
|
|
103
|
+
totalHeight,
|
|
104
|
+
singleWidth,
|
|
105
|
+
singleHeight
|
|
106
|
+
});
|
|
107
|
+
}
|
|
85
108
|
log('Calculated dimensions', {
|
|
86
109
|
rows,
|
|
87
110
|
singleWidth,
|
|
@@ -91,8 +114,8 @@ async function createThumbnail(frameFiles, tempDir, options, progressCallbacks)
|
|
|
91
114
|
// 创建合成图像
|
|
92
115
|
const composite = Sharp({
|
|
93
116
|
create: {
|
|
94
|
-
width:
|
|
95
|
-
height:
|
|
117
|
+
width: totalWidth,
|
|
118
|
+
height: totalHeight,
|
|
96
119
|
channels: 3,
|
|
97
120
|
background: { r: 0, g: 0, b: 0 },
|
|
98
121
|
},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ThumbnailOptions } from "./getThumbnail.js";
|
|
1
2
|
/** 转码进度信息,用于 onProgress 回调 */
|
|
2
3
|
export interface TranscodeProgress {
|
|
3
4
|
/** 当前阶段 */
|
|
@@ -14,6 +15,8 @@ export interface TranscodeProgress {
|
|
|
14
15
|
export interface TranscodingConfig {
|
|
15
16
|
/** 是否生成缩略图,默认 true */
|
|
16
17
|
generateThumbnail?: boolean;
|
|
18
|
+
/** 缩略图生成参数,不传则使用 getThumbnail 默认值(如 frames=60, outputWidth=3840, columns=4, format=avif 等) */
|
|
19
|
+
thumbnailOptions?: Partial<ThumbnailOptions>;
|
|
17
20
|
/** 目标编码格式,默认 'hevc' */
|
|
18
21
|
format?: "av1" | "hevc";
|
|
19
22
|
/** 是否替换原文件(删除原文件并将输出重命名为原路径),默认 false */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import { runSync } from "./runSync.js";
|
|
5
6
|
import { checkFFmpegAvailability } from "./check.js";
|
|
@@ -59,8 +60,24 @@ function parseTimeFromStderr(line) {
|
|
|
59
60
|
const [, h, m, s] = match;
|
|
60
61
|
return parseInt(h, 10) * 3600 + parseInt(m, 10) * 60 + parseFloat(s);
|
|
61
62
|
}
|
|
62
|
-
/**
|
|
63
|
-
function
|
|
63
|
+
/** 从 -progress 文件内容中解析当前输出时间(秒)。out_time_ms/out_time_us 均为微秒。 */
|
|
64
|
+
function parseOutTimeFromProgressFile(content) {
|
|
65
|
+
let lastUs = null;
|
|
66
|
+
for (const line of content.split(/\r?\n/)) {
|
|
67
|
+
const msMatch = line.match(/^out_time_ms=(\d+)/);
|
|
68
|
+
if (msMatch) {
|
|
69
|
+
lastUs = parseInt(msMatch[1], 10);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const usMatch = line.match(/^out_time_us=(\d+)/);
|
|
73
|
+
if (usMatch) {
|
|
74
|
+
lastUs = parseInt(usMatch[1], 10);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return lastUs != null ? lastUs / 1e6 : null;
|
|
78
|
+
}
|
|
79
|
+
/** 根据格式与可用编码器构建 ffmpeg 参数数组;progressPath 用于 -progress 以获取实时进度(避免 stderr 缓冲导致 0 直接跳到 99) */
|
|
80
|
+
function buildTranscodeArgs(inputPath, outputPath, format, encoders, progressPath) {
|
|
64
81
|
const args = ["-i", inputPath];
|
|
65
82
|
if (format === "av1") {
|
|
66
83
|
if (encoders.av1Nvenc) {
|
|
@@ -84,17 +101,23 @@ function buildTranscodeArgs(inputPath, outputPath, format, encoders) {
|
|
|
84
101
|
throw new Error("没有可用的 HEVC 编码器");
|
|
85
102
|
}
|
|
86
103
|
}
|
|
104
|
+
if (progressPath) {
|
|
105
|
+
args.push("-progress", progressPath);
|
|
106
|
+
}
|
|
87
107
|
args.push("-y", outputPath);
|
|
88
108
|
return args;
|
|
89
109
|
}
|
|
90
|
-
|
|
110
|
+
const PROGRESS_POLL_INTERVAL_MS = 400;
|
|
111
|
+
/** 使用 spawn 执行 ffmpeg 转码。进度通过 -progress 文件轮询获取,避免 stderr 管道缓冲导致 0 直接跳到 99。 */
|
|
91
112
|
function runTranscodeWithProgress(inputPath, outputPath, format, encoders, totalDuration, onProgress) {
|
|
92
113
|
return new Promise((resolve, reject) => {
|
|
93
|
-
const
|
|
94
|
-
|
|
114
|
+
const progressPath = path.join(os.tmpdir(), `ffmpeg-progress-${process.pid}-${Date.now()}`);
|
|
115
|
+
const args = buildTranscodeArgs(inputPath, outputPath, format, encoders, progressPath);
|
|
116
|
+
log("ffmpeg spawn", { totalDuration, format, outputPath, progressPath });
|
|
95
117
|
const child = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
96
118
|
let lastPercent = -1;
|
|
97
119
|
let hasReportedProgress = false;
|
|
120
|
+
let hasReceivedStderrData = false;
|
|
98
121
|
let loggedUnparsedTime = false;
|
|
99
122
|
const report = (progress) => {
|
|
100
123
|
if (progress.percent !== lastPercent) {
|
|
@@ -106,37 +129,62 @@ function runTranscodeWithProgress(inputPath, outputPath, format, encoders, total
|
|
|
106
129
|
onProgress(progress);
|
|
107
130
|
}
|
|
108
131
|
};
|
|
132
|
+
const pollProgress = () => {
|
|
133
|
+
try {
|
|
134
|
+
if (!fs.existsSync(progressPath))
|
|
135
|
+
return;
|
|
136
|
+
const content = fs.readFileSync(progressPath, "utf8");
|
|
137
|
+
const currentTime = parseOutTimeFromProgressFile(content);
|
|
138
|
+
if (currentTime != null && totalDuration > 0) {
|
|
139
|
+
const percent = Math.min(99, Math.round((currentTime / totalDuration) * 100));
|
|
140
|
+
report({
|
|
141
|
+
phase: "encoding",
|
|
142
|
+
percent,
|
|
143
|
+
message: "正在转码...",
|
|
144
|
+
currentTime,
|
|
145
|
+
duration: totalDuration,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// 文件可能正在被 ffmpeg 写入,忽略读错误
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const progressTimer = setInterval(pollProgress, PROGRESS_POLL_INTERVAL_MS);
|
|
109
154
|
if (child.stderr) {
|
|
110
155
|
let buffer = "";
|
|
111
156
|
child.stderr.setEncoding("utf8");
|
|
112
157
|
child.stderr.on("data", (chunk) => {
|
|
158
|
+
hasReceivedStderrData = true;
|
|
113
159
|
buffer += chunk;
|
|
114
160
|
const lines = buffer.split(/\r?\n/);
|
|
115
161
|
buffer = lines.pop() ?? "";
|
|
116
162
|
for (const line of lines) {
|
|
117
|
-
|
|
118
|
-
if (/time=\d/.test(line) && !loggedUnparsedTime && currentTime == null) {
|
|
163
|
+
if (/time=\d/.test(line) && !loggedUnparsedTime && parseTimeFromStderr(line) == null) {
|
|
119
164
|
loggedUnparsedTime = true;
|
|
120
165
|
log("stderr time line not parsed (check locale/format)", { line: line.trim() });
|
|
121
166
|
}
|
|
122
|
-
if (currentTime != null && totalDuration > 0) {
|
|
123
|
-
const percent = Math.min(99, Math.round((currentTime / totalDuration) * 100));
|
|
124
|
-
report({
|
|
125
|
-
phase: "encoding",
|
|
126
|
-
percent,
|
|
127
|
-
message: "正在转码...",
|
|
128
|
-
currentTime,
|
|
129
|
-
duration: totalDuration,
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
167
|
}
|
|
133
168
|
});
|
|
134
169
|
}
|
|
170
|
+
else {
|
|
171
|
+
log("ffmpeg stderr is null");
|
|
172
|
+
}
|
|
135
173
|
child.on("error", (err) => {
|
|
174
|
+
clearInterval(progressTimer);
|
|
175
|
+
try {
|
|
176
|
+
fs.unlinkSync(progressPath);
|
|
177
|
+
}
|
|
178
|
+
catch { /* ignore */ }
|
|
136
179
|
log("ffmpeg error", err.message);
|
|
137
180
|
reject(err);
|
|
138
181
|
});
|
|
139
182
|
child.on("close", (code, signal) => {
|
|
183
|
+
clearInterval(progressTimer);
|
|
184
|
+
try {
|
|
185
|
+
fs.unlinkSync(progressPath);
|
|
186
|
+
}
|
|
187
|
+
catch { /* ignore */ }
|
|
140
188
|
log("ffmpeg close", { code, signal, hasReportedProgress });
|
|
141
189
|
if (signal) {
|
|
142
190
|
reject(new Error(`FFmpeg killed by signal: ${signal}`));
|
|
@@ -146,8 +194,8 @@ function runTranscodeWithProgress(inputPath, outputPath, format, encoders, total
|
|
|
146
194
|
reject(new Error(`FFmpeg exited with code ${code}`));
|
|
147
195
|
return;
|
|
148
196
|
}
|
|
149
|
-
if (!hasReportedProgress) {
|
|
150
|
-
log("no encoding progress
|
|
197
|
+
if (!hasReportedProgress && !child.stderr) {
|
|
198
|
+
log("no encoding progress: ffmpeg stderr was null");
|
|
151
199
|
}
|
|
152
200
|
report({
|
|
153
201
|
phase: "done",
|
|
@@ -187,8 +235,8 @@ export async function transcoding(inputPath, config = {}) {
|
|
|
187
235
|
};
|
|
188
236
|
await runTranscodeWithProgress(validatedPath, tempOutputPath, format, encoders, duration, reportProgress);
|
|
189
237
|
if (finalConfig.generateThumbnail) {
|
|
190
|
-
log("generating thumbnail");
|
|
191
|
-
await getThumbnail(validatedPath);
|
|
238
|
+
log("generating thumbnail", finalConfig.thumbnailOptions ?? {});
|
|
239
|
+
await getThumbnail(validatedPath, finalConfig.thumbnailOptions ?? {});
|
|
192
240
|
}
|
|
193
241
|
let resultPath;
|
|
194
242
|
if (finalConfig.replaceOriginal) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiping/node-utils",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.65",
|
|
4
4
|
"description": "node-utils",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "The-End-Hero <527409987@qq.com>",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"bugs": {
|
|
26
26
|
"url": "https://github.com/The-End-Hero/xiping/issues"
|
|
27
27
|
},
|
|
28
|
-
"gitHead": "
|
|
28
|
+
"gitHead": "fca213dab54ebc6913dfe46030566a30d613f456",
|
|
29
29
|
"publishConfig": {
|
|
30
30
|
"access": "public",
|
|
31
31
|
"registry": "https://registry.npmjs.org/"
|