239 lines
8.4 KiB
JavaScript
Raw Permalink Normal View History

2026-05-20 21:39:12 +08:00
#!/usr/bin/env node
'use strict';
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const crypto = require('node:crypto');
const { spawn } = require('node:child_process');
function emit(stage, message, status = 'running', extra = {}) {
process.stderr.write(JSON.stringify({
type: 'process_event',
stage,
message,
status,
timestamp: new Date().toISOString(),
...extra,
}) + '\n');
}
function readInput() {
const raw = String(process.argv[2] || process.env.SKILL_INPUT || process.env.AIFLOW_SKILL_INPUT || fs.readFileSync(0, 'utf8')).trim();
return raw ? JSON.parse(raw) : {};
}
function clampNumber(value, min, max, fallback) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.max(min, Math.min(max, number));
}
function safeTaskId(value) {
const raw = String(value || '').trim();
if (/^[a-zA-Z0-9_-]{1,80}$/.test(raw)) return raw;
return `task_${Date.now().toString(36)}_${crypto.randomBytes(4).toString('hex')}`;
}
function workspaceRoot(env) {
if (env.VEHICLE_SCRATCH_WORKSPACE_ROOT) return path.resolve(env.VEHICLE_SCRATCH_WORKSPACE_ROOT);
if (env.RZYX_AI_WORKSPACE_ROOT) {
const root = path.resolve(env.RZYX_AI_WORKSPACE_ROOT);
return path.basename(root) === 'vehicle-scratch-inspection' ? root : path.join(root, 'vehicle-scratch-inspection');
}
if (env.RZYX_AI_DATA_DIR) return path.join(path.resolve(env.RZYX_AI_DATA_DIR), 'workspace', 'vehicle-scratch-inspection');
return path.join(os.tmpdir(), 'vehicle-scratch-inspection');
}
function createWorkspace(taskId, env) {
const root = workspaceRoot(env);
const id = safeTaskId(taskId);
const workspacePath = path.join(root, id);
for (const dir of ['', 'source', 'frames', 'marked_frames', 'best_frames', 'report']) {
fs.mkdirSync(path.join(workspacePath, dir), { recursive: true });
}
return { taskId: id, workspaceRoot: root, workspacePath };
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function runProcess(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], ...options });
let stdout = '';
let stderr = '';
child.stdout.on('data', chunk => { stdout += chunk.toString(); });
child.stderr.on('data', chunk => { stderr += chunk.toString(); });
child.on('error', reject);
child.on('close', code => {
if (code === 0) resolve({ stdout, stderr });
else reject(new Error(`${command} exited with ${code}: ${stderr.trim().slice(-1200)}`));
});
});
}
async function downloadVideo(url, targetPath) {
emit('download', '下载远程视频到workspace');
const response = await fetch(url);
if (!response.ok) throw new Error(`视频下载失败: HTTP ${response.status}`);
const buffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync(targetPath, buffer);
return targetPath;
}
async function resolveVideo(videoUrl, workspace, env) {
const value = String(videoUrl || '').trim();
if (!value) throw new Error('videoUrl不能为空');
if (fs.existsSync(value)) return path.resolve(value);
const normalized = value.replace(/\\/g, '/');
if (normalized.startsWith('/upload/')) {
const uploadRoot = env.RZYX_AI_UPLOAD_ROOT || (env.RZYX_AI_DATA_DIR ? path.join(env.RZYX_AI_DATA_DIR, 'uploads') : '');
if (!uploadRoot) throw new Error('缺少RZYX_AI_UPLOAD_ROOT无法解析/upload路径');
const relative = normalized.slice('/upload/'.length);
const resolved = path.resolve(uploadRoot, relative);
const root = path.resolve(uploadRoot);
if (!resolved.startsWith(root + path.sep)) throw new Error('upload路径非法');
if (!fs.existsSync(resolved)) throw new Error(`视频文件不存在: ${resolved}`);
return resolved;
}
if (/^https?:\/\//i.test(value)) {
const url = new URL(value);
if (['localhost', '127.0.0.1'].includes(url.hostname)) {
try {
return await resolveVideo(decodeURIComponent(url.pathname), workspace, env);
} catch {
// Fall through to HTTP download for local static servers that do not map to files.
}
}
const ext = path.extname(url.pathname) || '.mp4';
return downloadVideo(value, path.join(workspace.workspacePath, 'source', `input${ext}`));
}
throw new Error(`视频文件不存在: ${value}`);
}
function parseRatio(value) {
const text = String(value || '');
const match = text.match(/^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/);
if (match) {
const a = Number(match[1]);
const b = Number(match[2]);
return b ? a / b : 0;
}
const n = Number(text);
return Number.isFinite(n) ? n : 0;
}
function ffmpegCommand(env) {
if (env.FFMPEG_PATH) return env.FFMPEG_PATH;
try { return require('@ffmpeg-installer/ffmpeg').path; } catch {}
return 'ffmpeg';
}
function ffprobeCommand(env) {
if (env.FFPROBE_PATH) return env.FFPROBE_PATH;
try { return require('@ffprobe-installer/ffprobe').path; } catch {}
return 'ffprobe';
}
async function probeVideo(videoPath, env) {
const { stdout } = await runProcess(ffprobeCommand(env), [
'-v', 'error',
'-print_format', 'json',
'-show_format',
'-show_streams',
videoPath,
]);
const meta = JSON.parse(stdout || '{}');
const stream = (meta.streams || []).find(item => item.codec_type === 'video') || {};
const duration = Number(meta.format?.duration || stream.duration || 0);
const videoFps = parseRatio(stream.avg_frame_rate || stream.r_frame_rate);
const totalFramesRaw = Number(stream.nb_frames);
const totalFrames = Number.isFinite(totalFramesRaw) && totalFramesRaw > 0
? totalFramesRaw
: Math.round(duration * (videoFps || 0));
return {
totalFrames,
videoFps: Number((videoFps || 0).toFixed(3)),
duration: Number((duration || 0).toFixed(3)),
resolution: `${stream.width || 0}x${stream.height || 0}`,
};
}
async function extractFrames({ videoPath, workspace, fps, quality, env }) {
const framesDir = path.join(workspace.workspacePath, 'frames');
for (const file of fs.readdirSync(framesDir)) {
if (/\.jpe?g$/i.test(file)) fs.rmSync(path.join(framesDir, file), { force: true });
}
const qscale = Math.max(2, Math.min(31, Math.round(31 - (quality / 100) * 29)));
emit('extract_frames', '开始按指定fps抽帧', 'running', { fps, quality });
await runProcess(ffmpegCommand(env), [
'-hide_banner',
'-y',
'-i', videoPath,
'-vf', `fps=${fps}`,
'-q:v', String(qscale),
path.join(framesDir, 'frame_%06d.jpg'),
]);
const files = fs.readdirSync(framesDir).filter(file => /\.jpe?g$/i.test(file)).sort();
return files.map((file, index) => ({
index: index + 1,
timestamp: Number((index / fps).toFixed(3)),
fileName: file,
path: path.join(framesDir, file),
relativePath: `frames/${file}`,
}));
}
async function run(input, env = process.env) {
if (!input || typeof input !== 'object') throw new Error('输入必须是JSON对象');
const fps = clampNumber(input.fps, 0.2, 10, 5);
const quality = clampNumber(input.quality, 1, 100, 90);
const workspace = createWorkspace(input.taskId, env);
emit('workspace', 'workspace已创建', 'running', { taskId: workspace.taskId, workspacePath: workspace.workspacePath });
const videoPath = await resolveVideo(input.videoUrl, workspace, env);
const baseInfo = await probeVideo(videoPath, env);
const frames = await extractFrames({ videoPath, workspace, fps, quality, env });
if (frames.length === 0) throw new Error('视频无法抽帧请检查视频编码或fps参数');
const videoInfo = {
...baseInfo,
extractedFrames: frames.length,
extractFps: fps,
};
const result = {
success: true,
taskId: workspace.taskId,
workspacePath: workspace.workspacePath,
frameCount: frames.length,
videoInfo,
};
writeJson(path.join(workspace.workspacePath, 'video_info.json'), {
taskId: workspace.taskId,
workspacePath: workspace.workspacePath,
videoUrl: input.videoUrl,
videoPath,
frames,
videoInfo,
});
emit('extract_frames', '抽帧完成并写入video_info.json', 'completed', { frameCount: frames.length });
return result;
}
(async () => {
try {
process.stdout.write(JSON.stringify(await run(readInput())));
} catch (err) {
process.stdout.write(JSON.stringify({
success: false,
error: err && err.message ? err.message : String(err),
}));
}
})();