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