2026-05-20 21:39:12 +08:00

239 lines
8.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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),
}));
}
})();