#!/usr/bin/env node /** * 自动将 E2E 测试视频转换为 GIF * * 使用方法: * node scripts/video-to-gif.js * node scripts/video-to-gif.js --test "测试名称" * node scripts/video-to-gif.js --test "测试名称" --trim 2 # 裁剪掉开头 2 秒 * node scripts/video-to-gif.js --test "测试名称" --trim 2:30 # 从第 2 秒开始,持续 30 秒 * * 依赖: * - ffmpeg (brew install ffmpeg) */ const { execSync, spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); // 配置 const CONFIG = { testResultsDir: 'apps/web-e2e/test-results', outputDir: 'apps/web/public/user-manual/gifs', fps: 10, // 帧率 width: 800, // 宽度,-1 表示按比例自动计算高度 quality: 'lanczos' // 缩放算法 }; // 检查 ffmpeg 是否安装 function checkFfmpeg() { try { execSync('ffmpeg -version', { stdio: 'ignore' }); return true; } catch { console.error('❌ 未找到 ffmpeg,请先安装:'); console.error(' macOS: brew install ffmpeg'); console.error(' Ubuntu: sudo apt install ffmpeg'); return false; } } // 查找所有视频文件 function findVideos(baseDir, testNameFilter) { const videos = []; function searchDir(dir) { if (!fs.existsSync(dir)) return; const items = fs.readdirSync(dir); for (const item of items) { const fullPath = path.join(dir, item); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { searchDir(fullPath); } else if (item.endsWith('.webm')) { // 从目录名提取测试名称 const testName = path.basename(path.dirname(fullPath)); // 如果指定了过滤器,检查是否匹配 if (testNameFilter && !testName.includes(testNameFilter)) { continue; } videos.push({ path: fullPath, testName: testName, outputName: testName.replace(/[^a-zA-Z0-9\u4e00-\u9fa5-]/g, '-') + '.gif' }); } } } searchDir(baseDir); return videos; } // 转换视频为 GIF // trimOptions: { start: 秒, duration: 秒 | null } function convertToGif(videoPath, outputPath, trimOptions = null) { const { fps, width, quality } = CONFIG; // 确保输出目录存在 const outputDir = path.dirname(outputPath); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } // 构建裁剪参数 let trimArgs = ''; if (trimOptions) { if (trimOptions.start > 0) { trimArgs += ` -ss ${trimOptions.start}`; } if (trimOptions.duration) { trimArgs += ` -t ${trimOptions.duration}`; } } // ffmpeg 命令 // 使用两遍处理以获得更好的调色板 const paletteFile = outputPath.replace('.gif', '-palette.png'); try { // 第一遍:生成调色板 execSync( `ffmpeg -y${trimArgs} -i "${videoPath}" -vf "fps=${fps},scale=${width}:-1:flags=${quality},palettegen=stats_mode=diff" "${paletteFile}"`, { stdio: 'ignore' } ); // 第二遍:使用调色板生成 GIF execSync( `ffmpeg -y${trimArgs} -i "${videoPath}" -i "${paletteFile}" -lavfi "fps=${fps},scale=${width}:-1:flags=${quality}[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" -loop 0 "${outputPath}"`, { stdio: 'ignore' } ); // 删除临时调色板文件 if (fs.existsSync(paletteFile)) { fs.unlinkSync(paletteFile); } return true; } catch (error) { console.error(` 转换失败: ${error.message}`); return false; } } // 解析裁剪参数 // 格式: "2" (开始秒) 或 "2:30" (开始秒:持续秒) function parseTrimArg(trimStr) { if (!trimStr) return null; const parts = trimStr.split(':'); const start = parseFloat(parts[0]) || 0; const duration = parts[1] ? parseFloat(parts[1]) : null; return { start, duration }; } // 主函数 async function main() { console.log('🎬 E2E 视频转 GIF 工具\n'); // 检查 ffmpeg if (!checkFfmpeg()) { process.exit(1); } // 解析命令行参数 const args = process.argv.slice(2); let testNameFilter = null; let trimOptions = null; const testIndex = args.indexOf('--test'); if (testIndex !== -1 && args[testIndex + 1]) { testNameFilter = args[testIndex + 1]; console.log(`📋 过滤测试名称: "${testNameFilter}"`); } const trimIndex = args.indexOf('--trim'); if (trimIndex !== -1 && args[trimIndex + 1]) { trimOptions = parseTrimArg(args[trimIndex + 1]); if (trimOptions) { let trimDesc = `从第 ${trimOptions.start} 秒开始`; if (trimOptions.duration) { trimDesc += `,持续 ${trimOptions.duration} 秒`; } console.log(`✂️ 裁剪: ${trimDesc}`); } } console.log(''); // 查找视频 const videos = findVideos(CONFIG.testResultsDir, testNameFilter); if (videos.length === 0) { console.log('❌ 未找到视频文件'); console.log(' 请先运行: pnpm manual:video'); process.exit(1); } console.log(`📹 找到 ${videos.length} 个视频文件\n`); // 转换每个视频 let successCount = 0; for (const video of videos) { const outputPath = path.join(CONFIG.outputDir, video.outputName); console.log(`🔄 转换: ${video.testName}`); console.log(` 输入: ${video.path}`); console.log(` 输出: ${outputPath}`); if (convertToGif(video.path, outputPath, trimOptions)) { const stats = fs.statSync(outputPath); const sizeMB = (stats.size / 1024 / 1024).toFixed(2); console.log(` ✅ 完成 (${sizeMB} MB)\n`); successCount++; } else { console.log(` ❌ 失败\n`); } } console.log(`\n🎉 转换完成: ${successCount}/${videos.length} 个文件`); console.log(`📁 输出目录: ${CONFIG.outputDir}`); } main().catch(console.error);