feat: convert img to video
This commit is contained in:
parent
f34ce4aed0
commit
6e2976e234
119
lib/encodeVideo.ts
Normal file
119
lib/encodeVideo.ts
Normal file
@ -0,0 +1,119 @@
|
||||
// file: compressImages.ts
|
||||
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { Buffer } from 'buffer';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export interface ImageInput {
|
||||
buffer: Buffer;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
export interface Av1CompressOptions {
|
||||
framerate?: number;
|
||||
crf?: number;
|
||||
preset?: number;
|
||||
pixFmt?: string;
|
||||
}
|
||||
|
||||
const mimeToExtensionMap: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/webp': 'webp',
|
||||
'image/bmp': 'bmp',
|
||||
};
|
||||
|
||||
/**
|
||||
* 将一个包含不同类型图片的Buffer数组压缩成一个使用SVT-AV1编码的视频Buffer。
|
||||
* (已修复顺序问题和视频截断问题)
|
||||
*
|
||||
* @param images 包含图片对象(buffer 和 contentType)的数组。
|
||||
* @param options 压缩选项。
|
||||
* @returns {Promise<Buffer>} 一个Promise,解析后为包含视频数据的Buffer。
|
||||
*/
|
||||
export async function compressImagesToAv1Video(
|
||||
images: ImageInput[],
|
||||
options: Av1CompressOptions = {}
|
||||
): Promise<Buffer> {
|
||||
if (!images || images.length === 0) {
|
||||
throw new Error('Input image array cannot be empty.');
|
||||
}
|
||||
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ffmpeg-av1-'));
|
||||
|
||||
try {
|
||||
const {
|
||||
framerate = 25,
|
||||
crf = 30,
|
||||
preset = 8,
|
||||
pixFmt = 'yuv420p',
|
||||
} = options;
|
||||
|
||||
// --- 修复点 1: 修正写入和 list.txt 生成的逻辑 ---
|
||||
|
||||
// 1. 先生成所有文件名,并并行写入所有文件
|
||||
const fileNames: string[] = [];
|
||||
const writePromises = images.map(async (image, index) => {
|
||||
const extension = mimeToExtensionMap[image.contentType.toLowerCase()];
|
||||
if (!extension) {
|
||||
throw new Error(`Unsupported content type: ${image.contentType}`);
|
||||
}
|
||||
const frameNumber = (index + 1).toString().padStart(5, '0');
|
||||
const fileName = `frame-${frameNumber}.${extension}`;
|
||||
fileNames[index] = fileName; // 按正确顺序填充文件名数组
|
||||
const filePath = path.join(tempDir, fileName);
|
||||
return fs.writeFile(filePath, image.buffer);
|
||||
});
|
||||
await Promise.all(writePromises);
|
||||
|
||||
// 2. 所有文件写入成功后,再生成 concat 文件内容
|
||||
const concatFileContent = fileNames.map(name => `file '${name}'`).join('\n');
|
||||
const concatFilePath = path.join(tempDir, 'list.txt');
|
||||
await fs.writeFile(concatFilePath, concatFileContent);
|
||||
|
||||
// --- 修复点 2: 修正 FFmpeg 命令选项 ---
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const command = ffmpeg(concatFilePath)
|
||||
.inputOptions([
|
||||
'-f', 'concat',
|
||||
'-safe', '0',
|
||||
`-r ${framerate}`, // 关键:将帧率作为输入选项
|
||||
])
|
||||
.videoCodec('libsvtav1')
|
||||
.outputOptions([
|
||||
`-crf ${crf}`,
|
||||
`-preset ${preset}`,
|
||||
'-movflags frag_keyframe+empty_moov',
|
||||
])
|
||||
.toFormat('mp4');
|
||||
|
||||
// 添加一个start监听器,方便调试,它会打印出最终执行的命令
|
||||
command.on('start', (commandLine) => {
|
||||
console.log('Spawning FFmpeg with command: ' + commandLine);
|
||||
});
|
||||
|
||||
const outputStream = command.pipe() as Readable;
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
outputStream.on('data', (chunk) => chunks.push(chunk));
|
||||
outputStream.on('error', reject);
|
||||
outputStream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
|
||||
command.on('error', (err, stdout, stderr) => {
|
||||
reject(new Error(`FFmpeg error: ${err.message}\nStderr: ${stderr}`));
|
||||
});
|
||||
}).finally(() => {
|
||||
//fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
//await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ... 示例代码 main() 函数无需修改,可以正常工作
|
||||
47
lib/schedule/compressPics.ts
Normal file
47
lib/schedule/compressPics.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getFileByObjectName, storeFile } from '../fileStorage'
|
||||
import { compressImagesToAv1Video } from '../encodeVideo'
|
||||
|
||||
export async function compressPics(hostname: string, startTime: Date, endTime: Date) {
|
||||
// Build query conditions
|
||||
const whereClause: any = { hostname }
|
||||
|
||||
if (startTime || endTime) {
|
||||
whereClause.timestamp = {}
|
||||
if (startTime) whereClause.timestamp.gte = startTime
|
||||
if (endTime) whereClause.timestamp.lte = endTime
|
||||
}
|
||||
|
||||
// Get records
|
||||
const records = await prisma.record.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
windows: false,
|
||||
screenshots: true
|
||||
},
|
||||
orderBy: {
|
||||
timestamp: 'desc'
|
||||
}
|
||||
})
|
||||
|
||||
// 有两种情况不进行处理:
|
||||
// 1. 每个记录中有多于一个截图
|
||||
// 2. 时间段内截图有多种分辨率
|
||||
|
||||
const picNames = records.map(record => {
|
||||
return record.screenshots.map(screenshot => screenshot.objectName)
|
||||
}).flat();
|
||||
|
||||
const picBuffers = (await Promise.all(picNames.map(name => getFileByObjectName(name)))).filter(buffer => buffer !== null && buffer !== undefined);
|
||||
|
||||
// 先默认不存在null吧
|
||||
|
||||
const vBuffer = await compressImagesToAv1Video(picBuffers)
|
||||
|
||||
storeFile(vBuffer, 'test.mp4', 'video/mp4', 'other', hostname)
|
||||
|
||||
}
|
||||
|
||||
//DESKTOP-JHHNH9C startTime=1751104800 1751108400
|
||||
|
||||
compressPics('DESKTOP-JHHNH9C', new Date(1751104800000), new Date(1751108400000))
|
||||
Loading…
x
Reference in New Issue
Block a user