@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
- const singleWidth = Math.floor(outputWidth / columns);
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
- const singleHeight = Math.floor(singleWidth / aspectRatio);
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: singleWidth * columns,
95
- height: singleHeight * rows,
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
- /** 根据格式与可用编码器构建 ffmpeg 参数数组 */
63
- function buildTranscodeArgs(inputPath, outputPath, format, encoders) {
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
- /** 使用 spawn 执行 ffmpeg 转码,解析 stderr 并回调 onProgress */
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 args = buildTranscodeArgs(inputPath, outputPath, format, encoders);
94
- log("ffmpeg spawn", { totalDuration, format, outputPath });
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
- const currentTime = parseTimeFromStderr(line);
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 was parsed from stderr (progress callbacks may not have fired)");
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.64",
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": "483df3f50a54f6d66b461c64bf05c77c68d368ad",
28
+ "gitHead": "fca213dab54ebc6913dfe46030566a30d613f456",
29
29
  "publishConfig": {
30
30
  "access": "public",
31
31
  "registry": "https://registry.npmjs.org/"