@xiping/node-utils 1.0.63 → 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,9 +1,13 @@
|
|
|
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";
|
|
6
7
|
import { getThumbnail } from "./getThumbnail.js";
|
|
8
|
+
const log = (message, data) => {
|
|
9
|
+
console.log("[Transcode]", message, data !== undefined ? data : "");
|
|
10
|
+
};
|
|
7
11
|
const defaultConfig = {
|
|
8
12
|
generateThumbnail: true,
|
|
9
13
|
format: "hevc",
|
|
@@ -56,8 +60,24 @@ function parseTimeFromStderr(line) {
|
|
|
56
60
|
const [, h, m, s] = match;
|
|
57
61
|
return parseInt(h, 10) * 3600 + parseInt(m, 10) * 60 + parseFloat(s);
|
|
58
62
|
}
|
|
59
|
-
/**
|
|
60
|
-
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) {
|
|
61
81
|
const args = ["-i", inputPath];
|
|
62
82
|
if (format === "av1") {
|
|
63
83
|
if (encoders.av1Nvenc) {
|
|
@@ -81,45 +101,91 @@ function buildTranscodeArgs(inputPath, outputPath, format, encoders) {
|
|
|
81
101
|
throw new Error("没有可用的 HEVC 编码器");
|
|
82
102
|
}
|
|
83
103
|
}
|
|
104
|
+
if (progressPath) {
|
|
105
|
+
args.push("-progress", progressPath);
|
|
106
|
+
}
|
|
84
107
|
args.push("-y", outputPath);
|
|
85
108
|
return args;
|
|
86
109
|
}
|
|
87
|
-
|
|
110
|
+
const PROGRESS_POLL_INTERVAL_MS = 400;
|
|
111
|
+
/** 使用 spawn 执行 ffmpeg 转码。进度通过 -progress 文件轮询获取,避免 stderr 管道缓冲导致 0 直接跳到 99。 */
|
|
88
112
|
function runTranscodeWithProgress(inputPath, outputPath, format, encoders, totalDuration, onProgress) {
|
|
89
113
|
return new Promise((resolve, reject) => {
|
|
90
|
-
const
|
|
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 });
|
|
91
117
|
const child = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
92
118
|
let lastPercent = -1;
|
|
119
|
+
let hasReportedProgress = false;
|
|
120
|
+
let hasReceivedStderrData = false;
|
|
121
|
+
let loggedUnparsedTime = false;
|
|
93
122
|
const report = (progress) => {
|
|
94
123
|
if (progress.percent !== lastPercent) {
|
|
95
124
|
lastPercent = progress.percent;
|
|
125
|
+
hasReportedProgress = true;
|
|
126
|
+
if (progress.percent === 0 || progress.percent === 50 || progress.percent === 99 || progress.phase === "done") {
|
|
127
|
+
log("progress", { phase: progress.phase, percent: progress.percent, currentTime: progress.currentTime, duration: progress.duration });
|
|
128
|
+
}
|
|
96
129
|
onProgress(progress);
|
|
97
130
|
}
|
|
98
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);
|
|
99
154
|
if (child.stderr) {
|
|
100
155
|
let buffer = "";
|
|
101
156
|
child.stderr.setEncoding("utf8");
|
|
102
157
|
child.stderr.on("data", (chunk) => {
|
|
158
|
+
hasReceivedStderrData = true;
|
|
103
159
|
buffer += chunk;
|
|
104
160
|
const lines = buffer.split(/\r?\n/);
|
|
105
161
|
buffer = lines.pop() ?? "";
|
|
106
162
|
for (const line of lines) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
report({
|
|
111
|
-
phase: "encoding",
|
|
112
|
-
percent,
|
|
113
|
-
message: "正在转码...",
|
|
114
|
-
currentTime,
|
|
115
|
-
duration: totalDuration,
|
|
116
|
-
});
|
|
163
|
+
if (/time=\d/.test(line) && !loggedUnparsedTime && parseTimeFromStderr(line) == null) {
|
|
164
|
+
loggedUnparsedTime = true;
|
|
165
|
+
log("stderr time line not parsed (check locale/format)", { line: line.trim() });
|
|
117
166
|
}
|
|
118
167
|
}
|
|
119
168
|
});
|
|
120
169
|
}
|
|
121
|
-
|
|
170
|
+
else {
|
|
171
|
+
log("ffmpeg stderr is null");
|
|
172
|
+
}
|
|
173
|
+
child.on("error", (err) => {
|
|
174
|
+
clearInterval(progressTimer);
|
|
175
|
+
try {
|
|
176
|
+
fs.unlinkSync(progressPath);
|
|
177
|
+
}
|
|
178
|
+
catch { /* ignore */ }
|
|
179
|
+
log("ffmpeg error", err.message);
|
|
180
|
+
reject(err);
|
|
181
|
+
});
|
|
122
182
|
child.on("close", (code, signal) => {
|
|
183
|
+
clearInterval(progressTimer);
|
|
184
|
+
try {
|
|
185
|
+
fs.unlinkSync(progressPath);
|
|
186
|
+
}
|
|
187
|
+
catch { /* ignore */ }
|
|
188
|
+
log("ffmpeg close", { code, signal, hasReportedProgress });
|
|
123
189
|
if (signal) {
|
|
124
190
|
reject(new Error(`FFmpeg killed by signal: ${signal}`));
|
|
125
191
|
return;
|
|
@@ -128,6 +194,9 @@ function runTranscodeWithProgress(inputPath, outputPath, format, encoders, total
|
|
|
128
194
|
reject(new Error(`FFmpeg exited with code ${code}`));
|
|
129
195
|
return;
|
|
130
196
|
}
|
|
197
|
+
if (!hasReportedProgress && !child.stderr) {
|
|
198
|
+
log("no encoding progress: ffmpeg stderr was null");
|
|
199
|
+
}
|
|
131
200
|
report({
|
|
132
201
|
phase: "done",
|
|
133
202
|
percent: 100,
|
|
@@ -155,6 +224,7 @@ export async function transcoding(inputPath, config = {}) {
|
|
|
155
224
|
const encoders = getAvailableEncoders();
|
|
156
225
|
const format = finalConfig.format;
|
|
157
226
|
const duration = getDurationInSeconds(validatedPath);
|
|
227
|
+
log("start", { inputPath: validatedPath, format, durationSeconds: duration, hasOnProgress: !!finalConfig.onProgress });
|
|
158
228
|
const dir = path.dirname(validatedPath);
|
|
159
229
|
const baseName = path.basename(validatedPath, path.extname(validatedPath));
|
|
160
230
|
const suffix = format === "av1" ? "_av1" : "_hevc";
|
|
@@ -165,7 +235,8 @@ export async function transcoding(inputPath, config = {}) {
|
|
|
165
235
|
};
|
|
166
236
|
await runTranscodeWithProgress(validatedPath, tempOutputPath, format, encoders, duration, reportProgress);
|
|
167
237
|
if (finalConfig.generateThumbnail) {
|
|
168
|
-
|
|
238
|
+
log("generating thumbnail", finalConfig.thumbnailOptions ?? {});
|
|
239
|
+
await getThumbnail(validatedPath, finalConfig.thumbnailOptions ?? {});
|
|
169
240
|
}
|
|
170
241
|
let resultPath;
|
|
171
242
|
if (finalConfig.replaceOriginal) {
|
|
@@ -177,5 +248,6 @@ export async function transcoding(inputPath, config = {}) {
|
|
|
177
248
|
else {
|
|
178
249
|
resultPath = tempOutputPath;
|
|
179
250
|
}
|
|
251
|
+
log("done", { outputPath: resultPath });
|
|
180
252
|
return { outputPath: resultPath };
|
|
181
253
|
}
|
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/"
|