@xiping/node-utils 1.0.53 → 1.0.61
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/lib/src/ffmpeg/README.md +371 -0
- package/lib/src/ffmpeg/README_cutVideo.md +220 -0
- package/lib/src/ffmpeg/extractAudio.d.ts +36 -0
- package/lib/src/ffmpeg/extractAudio.js +186 -0
- package/lib/src/ffmpeg/getThumbnail.d.ts +15 -0
- package/lib/src/ffmpeg/getThumbnail.js +64 -10
- package/lib/src/ffmpeg/getVideoInfo.d.ts +47 -0
- package/lib/src/ffmpeg/getVideoInfo.js +109 -0
- package/lib/src/ffmpeg/index.d.ts +2 -47
- package/lib/src/ffmpeg/index.js +2 -109
- package/lib/src/image/README.md +230 -0
- package/lib/src/srt-to-vtt/README.md +189 -0
- package/package.json +3 -3
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import shell from "shelljs";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
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 = shell.exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${videoPath}"`, { silent: true });
|
|
22
|
+
if (result.code !== 0) {
|
|
23
|
+
throw new Error(`FFprobe failed to get duration: ${result.stderr}`);
|
|
24
|
+
}
|
|
25
|
+
const duration = parseFloat(result.stdout);
|
|
26
|
+
if (isNaN(duration) || duration <= 0) {
|
|
27
|
+
throw new Error("Invalid video duration");
|
|
28
|
+
}
|
|
29
|
+
return duration;
|
|
30
|
+
}
|
|
31
|
+
/** 解析 ffmpeg stderr 中的 time=HH:MM:SS.xx 为秒数 */
|
|
32
|
+
function parseTimeFromStderr(line) {
|
|
33
|
+
const match = line.match(/time=(\d{2}):(\d{2}):(\d{2}\.\d+)/);
|
|
34
|
+
if (!match)
|
|
35
|
+
return null;
|
|
36
|
+
const [, h, m, s] = match;
|
|
37
|
+
return parseInt(h, 10) * 3600 + parseInt(m, 10) * 60 + parseFloat(s);
|
|
38
|
+
}
|
|
39
|
+
/** 根据格式和是否流复制,构建 ffmpeg 参数(不含输入输出路径) */
|
|
40
|
+
function buildFfmpegArgs(outputPath, outputFormat, copyStream, audioCodec) {
|
|
41
|
+
const args = ["-i", ""]; // input 占位,调用方填入
|
|
42
|
+
args.push("-vn"); // 不要视频
|
|
43
|
+
if (outputFormat === "mp3") {
|
|
44
|
+
args.push("-acodec", "libmp3lame");
|
|
45
|
+
}
|
|
46
|
+
else if (outputFormat === "m4a") {
|
|
47
|
+
if (copyStream && (audioCodec === "aac" || audioCodec === "aac_latm")) {
|
|
48
|
+
args.push("-acodec", "copy");
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
args.push("-acodec", "aac");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else if (outputFormat === "wav") {
|
|
55
|
+
args.push("-acodec", "pcm_s16le");
|
|
56
|
+
}
|
|
57
|
+
args.push("-y", outputPath);
|
|
58
|
+
return args;
|
|
59
|
+
}
|
|
60
|
+
/** 执行 ffmpeg(shell.exec),无进度 */
|
|
61
|
+
function runFfmpegSync(inputPath, outputPath, outputFormat, copyStream, audioCodec) {
|
|
62
|
+
const args = buildFfmpegArgs(outputPath, outputFormat, copyStream, audioCodec);
|
|
63
|
+
args[1] = inputPath;
|
|
64
|
+
const command = `ffmpeg ${args.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" ")}`;
|
|
65
|
+
log("Executing FFmpeg command", { command });
|
|
66
|
+
const result = shell.exec(command, { silent: true });
|
|
67
|
+
if (result.code !== 0) {
|
|
68
|
+
throw new Error(`FFmpeg failed: ${result.stderr}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** 执行 ffmpeg(spawn),解析 stderr 并回调 onProgress */
|
|
72
|
+
function runFfmpegWithProgress(inputPath, outputPath, outputFormat, copyStream, audioCodec, totalDuration, onProgress) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const args = buildFfmpegArgs(outputPath, outputFormat, copyStream, audioCodec);
|
|
75
|
+
args[1] = inputPath;
|
|
76
|
+
const child = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
77
|
+
let lastPercent = -1;
|
|
78
|
+
const report = (progress) => {
|
|
79
|
+
if (progress.percent !== lastPercent) {
|
|
80
|
+
lastPercent = progress.percent;
|
|
81
|
+
onProgress(progress);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
if (child.stderr) {
|
|
85
|
+
let buffer = "";
|
|
86
|
+
child.stderr.setEncoding("utf8");
|
|
87
|
+
child.stderr.on("data", (chunk) => {
|
|
88
|
+
buffer += chunk;
|
|
89
|
+
const lines = buffer.split(/\r?\n/);
|
|
90
|
+
buffer = lines.pop() ?? "";
|
|
91
|
+
for (const line of lines) {
|
|
92
|
+
const currentTime = parseTimeFromStderr(line);
|
|
93
|
+
if (currentTime != null && totalDuration > 0) {
|
|
94
|
+
const percent = Math.min(99, Math.round((currentTime / totalDuration) * 100));
|
|
95
|
+
report({
|
|
96
|
+
phase: "encoding",
|
|
97
|
+
percent,
|
|
98
|
+
message: "正在提取音频...",
|
|
99
|
+
currentTime,
|
|
100
|
+
duration: totalDuration,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
child.on("error", (err) => reject(err));
|
|
107
|
+
child.on("close", (code, signal) => {
|
|
108
|
+
if (signal) {
|
|
109
|
+
reject(new Error(`FFmpeg killed by signal: ${signal}`));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (code !== 0) {
|
|
113
|
+
reject(new Error(`FFmpeg exited with code ${code}`));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
report({
|
|
117
|
+
phase: "done",
|
|
118
|
+
percent: 100,
|
|
119
|
+
message: "完成",
|
|
120
|
+
currentTime: totalDuration,
|
|
121
|
+
duration: totalDuration,
|
|
122
|
+
});
|
|
123
|
+
resolve();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
export async function extractAudio(videoPath, options = {}) {
|
|
128
|
+
const startTime = Date.now();
|
|
129
|
+
const validatedPath = validateAndSanitizePath(videoPath);
|
|
130
|
+
if (!fs.existsSync(validatedPath)) {
|
|
131
|
+
throw new Error(`Video file not found: ${validatedPath}`);
|
|
132
|
+
}
|
|
133
|
+
if (!checkFFmpegAvailability()) {
|
|
134
|
+
throw new Error("FFmpeg is not available. Please install FFmpeg first.");
|
|
135
|
+
}
|
|
136
|
+
const { outputFileName, outputFormat = "mp3", overwrite = false, tempDir: customTempDir, copyStream = true, onProgress, } = options;
|
|
137
|
+
const videoInfo = getVideoInfo(validatedPath);
|
|
138
|
+
if (!videoInfo.audioCodec) {
|
|
139
|
+
throw new Error("Video has no audio stream.");
|
|
140
|
+
}
|
|
141
|
+
const duration = getDurationInSeconds(validatedPath);
|
|
142
|
+
const videoDir = path.dirname(validatedPath);
|
|
143
|
+
const baseName = path.basename(validatedPath, path.extname(validatedPath));
|
|
144
|
+
const defaultOutputName = `${baseName}.${outputFormat}`;
|
|
145
|
+
const outputFileNameFinal = outputFileName ?? defaultOutputName;
|
|
146
|
+
const outputPath = path.join(videoDir, outputFileNameFinal);
|
|
147
|
+
if (fs.existsSync(outputPath) && !overwrite) {
|
|
148
|
+
throw new Error(`Output file already exists: ${outputPath}. Use overwrite option to force overwrite.`);
|
|
149
|
+
}
|
|
150
|
+
const reportProgress = (progress) => {
|
|
151
|
+
onProgress?.(progress);
|
|
152
|
+
};
|
|
153
|
+
reportProgress({
|
|
154
|
+
phase: "preparing",
|
|
155
|
+
percent: 0,
|
|
156
|
+
message: "准备提取音频...",
|
|
157
|
+
duration,
|
|
158
|
+
});
|
|
159
|
+
const tempDir = customTempDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "extract-audio-"));
|
|
160
|
+
const tempOutputPath = path.join(tempDir, `temp_audio.${outputFormat}`);
|
|
161
|
+
try {
|
|
162
|
+
if (onProgress) {
|
|
163
|
+
await runFfmpegWithProgress(validatedPath, tempOutputPath, outputFormat, copyStream, videoInfo.audioCodec, duration, reportProgress);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
runFfmpegSync(validatedPath, tempOutputPath, outputFormat, copyStream, videoInfo.audioCodec);
|
|
167
|
+
}
|
|
168
|
+
fs.copyFileSync(tempOutputPath, outputPath);
|
|
169
|
+
log("Extract audio complete", { outputPath });
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
if (!customTempDir && fs.existsSync(tempDir)) {
|
|
173
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const outputStats = fs.statSync(outputPath);
|
|
177
|
+
const processingTime = Date.now() - startTime;
|
|
178
|
+
return {
|
|
179
|
+
outputPath,
|
|
180
|
+
metadata: {
|
|
181
|
+
duration,
|
|
182
|
+
fileSize: outputStats.size,
|
|
183
|
+
processingTime,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
* 视频缩略图生成器
|
|
3
3
|
* 使用 ffmpeg 提取视频帧,使用 sharp 合成缩略图
|
|
4
4
|
*/
|
|
5
|
+
/** 进度信息,用于 onProgress 回调 */
|
|
6
|
+
export interface ThumbnailProgress {
|
|
7
|
+
/** 当前阶段 */
|
|
8
|
+
phase: "analyzing" | "extracting" | "composing" | "encoding" | "done";
|
|
9
|
+
/** 总进度 0–100 */
|
|
10
|
+
percent: number;
|
|
11
|
+
/** 当前阶段已完成数(如已提取帧数) */
|
|
12
|
+
current?: number;
|
|
13
|
+
/** 当前阶段总数 */
|
|
14
|
+
total?: number;
|
|
15
|
+
/** 可读描述 */
|
|
16
|
+
message?: string;
|
|
17
|
+
}
|
|
5
18
|
interface ThumbnailOptions {
|
|
6
19
|
frames?: number;
|
|
7
20
|
outputWidth?: number;
|
|
@@ -12,6 +25,8 @@ interface ThumbnailOptions {
|
|
|
12
25
|
batchSize?: number;
|
|
13
26
|
maxConcurrency?: number;
|
|
14
27
|
tempDir?: string;
|
|
28
|
+
/** 进度回调,按阶段与完成比例调用 */
|
|
29
|
+
onProgress?: (progress: ThumbnailProgress) => void;
|
|
15
30
|
}
|
|
16
31
|
interface ProcessingResult {
|
|
17
32
|
buffer: Buffer;
|
|
@@ -33,15 +33,16 @@ function getDurationInSeconds(videoPath) {
|
|
|
33
33
|
return duration;
|
|
34
34
|
}
|
|
35
35
|
// 并行提取视频帧
|
|
36
|
-
async function extractFrames(videoPath, tempDir, frames, interval, maxConcurrency = 4) {
|
|
36
|
+
async function extractFrames(videoPath, tempDir, frames, interval, maxConcurrency = 4, onProgress) {
|
|
37
|
+
const total = frames - 1;
|
|
37
38
|
log('Starting frame extraction', { frames, interval, maxConcurrency });
|
|
38
|
-
const framePromises = Array.from({ length:
|
|
39
|
+
const framePromises = Array.from({ length: total }, (_, i) => {
|
|
39
40
|
const t = interval * i;
|
|
40
41
|
const framePath = path.join(tempDir, `frame-${t}.jpg`);
|
|
41
42
|
return new Promise((resolve, reject) => {
|
|
42
43
|
const result = shell.exec(`ffmpeg -ss ${t} -i "${videoPath}" -vframes 1 -q:v 10 -an -threads 4 "${framePath}"`, { silent: true });
|
|
43
44
|
if (result.code === 0) {
|
|
44
|
-
log(`Frame ${i + 1}/${
|
|
45
|
+
log(`Frame ${i + 1}/${total} extracted`, { time: t });
|
|
45
46
|
resolve();
|
|
46
47
|
}
|
|
47
48
|
else {
|
|
@@ -49,14 +50,16 @@ async function extractFrames(videoPath, tempDir, frames, interval, maxConcurrenc
|
|
|
49
50
|
}
|
|
50
51
|
});
|
|
51
52
|
});
|
|
52
|
-
//
|
|
53
|
+
// 分批执行以控制并发数,每批完成后上报进度
|
|
53
54
|
for (let i = 0; i < framePromises.length; i += maxConcurrency) {
|
|
54
55
|
const batch = framePromises.slice(i, i + maxConcurrency);
|
|
55
56
|
await Promise.all(batch);
|
|
57
|
+
const current = Math.min(i + maxConcurrency, total);
|
|
58
|
+
onProgress?.(current, total);
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
61
|
// 创建缩略图
|
|
59
|
-
async function createThumbnail(frameFiles, tempDir, options) {
|
|
62
|
+
async function createThumbnail(frameFiles, tempDir, options, progressCallbacks) {
|
|
60
63
|
const { columns = 4, outputWidth = 3840, format = 'avif', quality = 80 } = options;
|
|
61
64
|
log('Creating thumbnail', { frameCount: frameFiles.length, columns, outputWidth });
|
|
62
65
|
// 计算尺寸
|
|
@@ -84,6 +87,7 @@ async function createThumbnail(frameFiles, tempDir, options) {
|
|
|
84
87
|
// 分批处理帧以避免内存问题
|
|
85
88
|
const batchSize = options.batchSize || 10;
|
|
86
89
|
const overlays = [];
|
|
90
|
+
const totalBatches = Math.ceil(frameFiles.length / batchSize);
|
|
87
91
|
for (let i = 0; i < frameFiles.length; i += batchSize) {
|
|
88
92
|
const batch = frameFiles.slice(i, i + batchSize);
|
|
89
93
|
const batchOverlays = await Promise.all(batch.map(async (file, batchIndex) => {
|
|
@@ -100,7 +104,9 @@ async function createThumbnail(frameFiles, tempDir, options) {
|
|
|
100
104
|
};
|
|
101
105
|
}));
|
|
102
106
|
overlays.push(...batchOverlays);
|
|
103
|
-
|
|
107
|
+
const currentBatch = Math.floor(i / batchSize) + 1;
|
|
108
|
+
progressCallbacks?.onComposingProgress?.(currentBatch, totalBatches);
|
|
109
|
+
log(`Processed batch ${currentBatch}/${totalBatches}`);
|
|
104
110
|
}
|
|
105
111
|
// 生成最终图像
|
|
106
112
|
let finalImage = composite.composite(overlays);
|
|
@@ -121,7 +127,10 @@ async function createThumbnail(frameFiles, tempDir, options) {
|
|
|
121
127
|
default:
|
|
122
128
|
finalImage = finalImage.avif({ quality });
|
|
123
129
|
}
|
|
124
|
-
|
|
130
|
+
progressCallbacks?.onEncodingStart?.();
|
|
131
|
+
const buffer = await finalImage.toBuffer();
|
|
132
|
+
progressCallbacks?.onEncodingEnd?.();
|
|
133
|
+
return buffer;
|
|
125
134
|
}
|
|
126
135
|
// 临时目录管理
|
|
127
136
|
async function withTempDir(fn, customTempDir) {
|
|
@@ -150,7 +159,7 @@ export const getThumbnail = async (videoPath, options = {}) => {
|
|
|
150
159
|
throw new Error('FFmpeg is not available. Please install FFmpeg first.');
|
|
151
160
|
}
|
|
152
161
|
// 参数设置和验证
|
|
153
|
-
const { frames = 60, outputWidth = 3840, columns = 4, outputFileName = "thumbnail.avif", quality = 80, format = 'avif', batchSize = 10, maxConcurrency = 4, tempDir: customTempDir } = options;
|
|
162
|
+
const { frames = 60, outputWidth = 3840, columns = 4, outputFileName = "thumbnail.avif", quality = 80, format = 'avif', batchSize = 10, maxConcurrency = 4, tempDir: customTempDir, onProgress } = options;
|
|
154
163
|
// 参数验证
|
|
155
164
|
if (frames <= 0 || outputWidth <= 0 || columns <= 0) {
|
|
156
165
|
throw new Error('Invalid parameters: frames, outputWidth, and columns must be positive');
|
|
@@ -158,14 +167,26 @@ export const getThumbnail = async (videoPath, options = {}) => {
|
|
|
158
167
|
if (quality < 1 || quality > 100) {
|
|
159
168
|
throw new Error('Quality must be between 1 and 100');
|
|
160
169
|
}
|
|
170
|
+
const report = (progress) => {
|
|
171
|
+
onProgress?.(progress);
|
|
172
|
+
};
|
|
161
173
|
log('Starting thumbnail generation', {
|
|
162
174
|
videoPath: validatedPath,
|
|
163
175
|
options: { frames, outputWidth, columns, format, quality }
|
|
164
176
|
});
|
|
177
|
+
report({ phase: 'analyzing', percent: 0, message: '分析视频...' });
|
|
165
178
|
// 获取视频信息
|
|
166
179
|
const durationInSeconds = getDurationInSeconds(validatedPath);
|
|
167
180
|
const interval = durationInSeconds / frames;
|
|
168
181
|
log('Video analysis complete', { durationInSeconds, interval });
|
|
182
|
+
const extractTotal = frames - 1;
|
|
183
|
+
report({
|
|
184
|
+
phase: 'extracting',
|
|
185
|
+
percent: 5,
|
|
186
|
+
current: 0,
|
|
187
|
+
total: extractTotal,
|
|
188
|
+
message: `提取帧 0/${extractTotal}`
|
|
189
|
+
});
|
|
169
190
|
// 准备输出路径
|
|
170
191
|
const videoDir = path.dirname(validatedPath);
|
|
171
192
|
const fileNameWithoutExt = path.basename(validatedPath, path.extname(validatedPath));
|
|
@@ -173,7 +194,16 @@ export const getThumbnail = async (videoPath, options = {}) => {
|
|
|
173
194
|
// 使用临时目录处理
|
|
174
195
|
const result = await withTempDir(async (tempDir) => {
|
|
175
196
|
// 提取帧
|
|
176
|
-
await extractFrames(validatedPath, tempDir, frames, interval, maxConcurrency)
|
|
197
|
+
await extractFrames(validatedPath, tempDir, frames, interval, maxConcurrency, (current, total) => {
|
|
198
|
+
const percent = total > 0 ? 5 + 45 * (current / total) : 5;
|
|
199
|
+
report({
|
|
200
|
+
phase: 'extracting',
|
|
201
|
+
percent: Math.round(percent),
|
|
202
|
+
current,
|
|
203
|
+
total,
|
|
204
|
+
message: `提取帧 ${current}/${total}`
|
|
205
|
+
});
|
|
206
|
+
});
|
|
177
207
|
// 获取所有帧文件并排序
|
|
178
208
|
const frameFiles = fs
|
|
179
209
|
.readdirSync(tempDir)
|
|
@@ -187,12 +217,36 @@ export const getThumbnail = async (videoPath, options = {}) => {
|
|
|
187
217
|
throw new Error('No frames were extracted from the video');
|
|
188
218
|
}
|
|
189
219
|
log('Frame extraction complete', { extractedFrames: frameFiles.length });
|
|
220
|
+
report({
|
|
221
|
+
phase: 'composing',
|
|
222
|
+
percent: 50,
|
|
223
|
+
message: '合成缩略图...'
|
|
224
|
+
});
|
|
190
225
|
// 创建缩略图
|
|
191
|
-
const
|
|
226
|
+
const totalComposeBatches = Math.ceil(frameFiles.length / (batchSize || 10));
|
|
227
|
+
const buffer = await createThumbnail(frameFiles, tempDir, options, {
|
|
228
|
+
onComposingProgress: (current, total) => {
|
|
229
|
+
const percent = total > 0 ? 50 + 45 * (current / total) : 50;
|
|
230
|
+
report({
|
|
231
|
+
phase: 'composing',
|
|
232
|
+
percent: Math.round(percent),
|
|
233
|
+
current,
|
|
234
|
+
total,
|
|
235
|
+
message: `合成 ${current}/${total}`
|
|
236
|
+
});
|
|
237
|
+
},
|
|
238
|
+
onEncodingStart: () => {
|
|
239
|
+
report({ phase: 'encoding', percent: 95, message: '编码输出...' });
|
|
240
|
+
},
|
|
241
|
+
onEncodingEnd: () => {
|
|
242
|
+
report({ phase: 'encoding', percent: 100, message: '编码完成' });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
192
245
|
return buffer;
|
|
193
246
|
}, customTempDir);
|
|
194
247
|
// 保存文件
|
|
195
248
|
fs.writeFileSync(outputPath, result);
|
|
249
|
+
report({ phase: 'done', percent: 100, message: '完成' });
|
|
196
250
|
const processingTime = Date.now() - startTime;
|
|
197
251
|
log('Thumbnail generation complete', {
|
|
198
252
|
outputPath,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 获取视频信息的接口定义
|
|
3
|
+
*/
|
|
4
|
+
export interface VideoInfo {
|
|
5
|
+
/** 视频文件路径 */
|
|
6
|
+
path: string;
|
|
7
|
+
/** 视频时长(秒) */
|
|
8
|
+
duration: number;
|
|
9
|
+
/** 视频时长格式化字符串 */
|
|
10
|
+
durationFormatted: string;
|
|
11
|
+
/** 视频宽度 */
|
|
12
|
+
width: number;
|
|
13
|
+
/** 视频高度 */
|
|
14
|
+
height: number;
|
|
15
|
+
/** 视频编码格式 */
|
|
16
|
+
videoCodec: string;
|
|
17
|
+
/** 音频编码格式 */
|
|
18
|
+
audioCodec: string;
|
|
19
|
+
/** 视频比特率 */
|
|
20
|
+
bitrate: number;
|
|
21
|
+
/** 帧率 */
|
|
22
|
+
fps: number;
|
|
23
|
+
/** 文件大小(字节) */
|
|
24
|
+
fileSize: number;
|
|
25
|
+
/** 文件大小格式化字符串 */
|
|
26
|
+
fileSizeFormatted: string;
|
|
27
|
+
/** 原始 ffprobe 输出信息 */
|
|
28
|
+
rawInfo: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 获取视频信息(使用 ffprobe)
|
|
32
|
+
* @param videoPath 视频文件路径
|
|
33
|
+
* @returns 视频信息对象
|
|
34
|
+
*/
|
|
35
|
+
export declare function getVideoInfo(videoPath: string): VideoInfo;
|
|
36
|
+
/**
|
|
37
|
+
* 批量获取视频信息
|
|
38
|
+
* @param videoPaths 视频文件路径数组
|
|
39
|
+
* @returns 视频信息对象数组
|
|
40
|
+
*/
|
|
41
|
+
export declare function getMultipleVideoInfo(videoPaths: string[]): VideoInfo[];
|
|
42
|
+
/**
|
|
43
|
+
* 获取详细的视频信息(包含更多元数据)
|
|
44
|
+
* @param videoPath 视频文件路径
|
|
45
|
+
* @returns 详细的视频信息
|
|
46
|
+
*/
|
|
47
|
+
export declare function getDetailedVideoInfo(videoPath: string): any;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import shell from "shelljs";
|
|
2
|
+
/**
|
|
3
|
+
* 格式化文件大小
|
|
4
|
+
* @param bytes 字节数
|
|
5
|
+
* @returns 格式化后的文件大小字符串
|
|
6
|
+
*/
|
|
7
|
+
function formatFileSize(bytes) {
|
|
8
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
9
|
+
let size = bytes;
|
|
10
|
+
let unitIndex = 0;
|
|
11
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
12
|
+
size /= 1024;
|
|
13
|
+
unitIndex++;
|
|
14
|
+
}
|
|
15
|
+
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 格式化时长
|
|
19
|
+
* @param seconds 秒数
|
|
20
|
+
* @returns 格式化后的时长字符串
|
|
21
|
+
*/
|
|
22
|
+
function formatDuration(seconds) {
|
|
23
|
+
const hours = Math.floor(seconds / 3600);
|
|
24
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
25
|
+
const secs = Math.floor(seconds % 60);
|
|
26
|
+
const ms = Math.floor((seconds % 1) * 100);
|
|
27
|
+
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}.${ms.toString().padStart(2, "0")}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 获取视频信息(使用 ffprobe)
|
|
31
|
+
* @param videoPath 视频文件路径
|
|
32
|
+
* @returns 视频信息对象
|
|
33
|
+
*/
|
|
34
|
+
export function getVideoInfo(videoPath) {
|
|
35
|
+
// 检查文件是否存在
|
|
36
|
+
if (!shell.test("-f", videoPath)) {
|
|
37
|
+
throw new Error(`视频文件不存在: ${videoPath}`);
|
|
38
|
+
}
|
|
39
|
+
// 使用 ffprobe 获取 JSON 格式的视频信息
|
|
40
|
+
const result = shell.exec(`ffprobe -v quiet -print_format json -show_format -show_streams "${videoPath}"`, { silent: true });
|
|
41
|
+
if (result.code !== 0) {
|
|
42
|
+
throw new Error(`获取视频信息失败: ${result.stderr}`);
|
|
43
|
+
}
|
|
44
|
+
let probeData;
|
|
45
|
+
try {
|
|
46
|
+
probeData = JSON.parse(result.stdout);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
throw new Error(`解析 ffprobe 输出失败: ${error}`);
|
|
50
|
+
}
|
|
51
|
+
const info = {
|
|
52
|
+
path: videoPath,
|
|
53
|
+
rawInfo: JSON.stringify(probeData, null, 2),
|
|
54
|
+
};
|
|
55
|
+
// 从 format 信息中获取时长和文件大小
|
|
56
|
+
if (probeData.format) {
|
|
57
|
+
if (probeData.format.duration) {
|
|
58
|
+
info.duration = parseFloat(probeData.format.duration);
|
|
59
|
+
info.durationFormatted = formatDuration(info.duration);
|
|
60
|
+
}
|
|
61
|
+
if (probeData.format.size) {
|
|
62
|
+
info.fileSize = parseInt(probeData.format.size);
|
|
63
|
+
info.fileSizeFormatted = formatFileSize(info.fileSize);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// 从视频流中获取信息
|
|
67
|
+
const videoStream = probeData.streams?.find((stream) => stream.codec_type === "video");
|
|
68
|
+
if (videoStream) {
|
|
69
|
+
info.videoCodec = videoStream.codec_name || "unknown";
|
|
70
|
+
info.width = videoStream.width || 0;
|
|
71
|
+
info.height = videoStream.height || 0;
|
|
72
|
+
if (videoStream.r_frame_rate) {
|
|
73
|
+
const [num, den] = videoStream.r_frame_rate.split("/");
|
|
74
|
+
info.fps = parseInt(num) / parseInt(den);
|
|
75
|
+
}
|
|
76
|
+
if (videoStream.bit_rate) {
|
|
77
|
+
info.bitrate = parseInt(videoStream.bit_rate);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// 从音频流中获取信息
|
|
81
|
+
const audioStream = probeData.streams?.find((stream) => stream.codec_type === "audio");
|
|
82
|
+
if (audioStream) {
|
|
83
|
+
info.audioCodec = audioStream.codec_name || "unknown";
|
|
84
|
+
}
|
|
85
|
+
return info;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 批量获取视频信息
|
|
89
|
+
* @param videoPaths 视频文件路径数组
|
|
90
|
+
* @returns 视频信息对象数组
|
|
91
|
+
*/
|
|
92
|
+
export function getMultipleVideoInfo(videoPaths) {
|
|
93
|
+
return videoPaths.map((path) => getVideoInfo(path));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 获取详细的视频信息(包含更多元数据)
|
|
97
|
+
* @param videoPath 视频文件路径
|
|
98
|
+
* @returns 详细的视频信息
|
|
99
|
+
*/
|
|
100
|
+
export function getDetailedVideoInfo(videoPath) {
|
|
101
|
+
if (!shell.test("-f", videoPath)) {
|
|
102
|
+
throw new Error(`视频文件不存在: ${videoPath}`);
|
|
103
|
+
}
|
|
104
|
+
const result = shell.exec(`ffprobe -v quiet -print_format json -show_format -show_streams -show_chapters -show_private_data "${videoPath}"`, { silent: true });
|
|
105
|
+
if (result.code !== 0) {
|
|
106
|
+
throw new Error(`获取详细视频信息失败: ${result.stderr}`);
|
|
107
|
+
}
|
|
108
|
+
return JSON.parse(result.stdout);
|
|
109
|
+
}
|
|
@@ -1,50 +1,5 @@
|
|
|
1
1
|
export * from "./getThumbnail.js";
|
|
2
2
|
export * from "./check.js";
|
|
3
3
|
export * from "./cutVideo.js";
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
*/
|
|
7
|
-
export interface VideoInfo {
|
|
8
|
-
/** 视频文件路径 */
|
|
9
|
-
path: string;
|
|
10
|
-
/** 视频时长(秒) */
|
|
11
|
-
duration: number;
|
|
12
|
-
/** 视频时长格式化字符串 */
|
|
13
|
-
durationFormatted: string;
|
|
14
|
-
/** 视频宽度 */
|
|
15
|
-
width: number;
|
|
16
|
-
/** 视频高度 */
|
|
17
|
-
height: number;
|
|
18
|
-
/** 视频编码格式 */
|
|
19
|
-
videoCodec: string;
|
|
20
|
-
/** 音频编码格式 */
|
|
21
|
-
audioCodec: string;
|
|
22
|
-
/** 视频比特率 */
|
|
23
|
-
bitrate: number;
|
|
24
|
-
/** 帧率 */
|
|
25
|
-
fps: number;
|
|
26
|
-
/** 文件大小(字节) */
|
|
27
|
-
fileSize: number;
|
|
28
|
-
/** 文件大小格式化字符串 */
|
|
29
|
-
fileSizeFormatted: string;
|
|
30
|
-
/** 原始 ffprobe 输出信息 */
|
|
31
|
-
rawInfo: string;
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* 获取视频信息(使用 ffprobe)
|
|
35
|
-
* @param videoPath 视频文件路径
|
|
36
|
-
* @returns 视频信息对象
|
|
37
|
-
*/
|
|
38
|
-
export declare function getVideoInfo(videoPath: string): VideoInfo;
|
|
39
|
-
/**
|
|
40
|
-
* 批量获取视频信息
|
|
41
|
-
* @param videoPaths 视频文件路径数组
|
|
42
|
-
* @returns 视频信息对象数组
|
|
43
|
-
*/
|
|
44
|
-
export declare function getMultipleVideoInfo(videoPaths: string[]): VideoInfo[];
|
|
45
|
-
/**
|
|
46
|
-
* 获取详细的视频信息(包含更多元数据)
|
|
47
|
-
* @param videoPath 视频文件路径
|
|
48
|
-
* @returns 详细的视频信息
|
|
49
|
-
*/
|
|
50
|
-
export declare function getDetailedVideoInfo(videoPath: string): any;
|
|
4
|
+
export * from "./getVideoInfo.js";
|
|
5
|
+
export * from "./extractAudio.js";
|