@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
- 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,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
- /** 根据格式与可用编码器构建 ffmpeg 参数数组 */
60
- 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) {
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
- /** 使用 spawn 执行 ffmpeg 转码,解析 stderr 并回调 onProgress */
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 args = buildTranscodeArgs(inputPath, outputPath, format, encoders);
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
- 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
- });
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
- child.on("error", (err) => reject(err));
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
- await getThumbnail(validatedPath);
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.63",
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": "466588a53ef8fa8bc6562a9933f1c9d707e978e5",
28
+ "gitHead": "fca213dab54ebc6913dfe46030566a30d613f456",
29
29
  "publishConfig": {
30
30
  "access": "public",
31
31
  "registry": "https://registry.npmjs.org/"