Initial TrueGrowth source import

This commit is contained in:
2026-07-07 09:36:36 +08:00
commit 3b6781d695
2283 changed files with 691996 additions and 0 deletions

207
scripts/video-to-gif.js Normal file
View File

@@ -0,0 +1,207 @@
#!/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);