From 6e2976e234a2f14b846f38eee3a15df83f73ad89 Mon Sep 17 00:00:00 2001 From: feie9454 Date: Mon, 30 Jun 2025 13:11:46 +0800 Subject: [PATCH] feat: convert img to video --- lib/encodeVideo.ts | 119 +++++++++++++++++++++++++++++++++++ lib/schedule/compressPics.ts | 47 ++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 lib/encodeVideo.ts create mode 100644 lib/schedule/compressPics.ts diff --git a/lib/encodeVideo.ts b/lib/encodeVideo.ts new file mode 100644 index 0000000..67f2635 --- /dev/null +++ b/lib/encodeVideo.ts @@ -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 = { + '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} 一个Promise,解析后为包含视频数据的Buffer。 + */ +export async function compressImagesToAv1Video( + images: ImageInput[], + options: Av1CompressOptions = {} +): Promise { + 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((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() 函数无需修改,可以正常工作 \ No newline at end of file diff --git a/lib/schedule/compressPics.ts b/lib/schedule/compressPics.ts new file mode 100644 index 0000000..fcd4498 --- /dev/null +++ b/lib/schedule/compressPics.ts @@ -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)) \ No newline at end of file