const fs = require('node:fs'); const path = require('node:path'); const { clampNumber } = require('./lib/json_utils.cjs'); const { createWorkspace, resolveVideoPath, writeJson, emitEvent } = require('./lib/workspace.cjs'); const { extractFrames } = require('./lib/frame_extractor.cjs'); const { resolveModelConfig } = require('./lib/vision_client.cjs'); const { detectDamageCandidates } = require('./lib/damage_detector.cjs'); const { groundDamages } = require('./lib/damage_grounding.cjs'); const { reviewDamageAnnotations } = require('./lib/damage_reviewer.cjs'); const { selectBestFrames } = require('./lib/best_frame_selector.cjs'); function normalizeInput(raw) { if (!raw || typeof raw !== 'object') throw new Error('Input JSON object is required'); if (!raw.videoUrl) throw new Error('videoUrl is required'); return { videoUrl: String(raw.videoUrl), taskId: raw.taskId ? String(raw.taskId) : undefined, fps: clampNumber(raw.fps, 0.2, 10, 5), quality: clampNumber(raw.quality, 1, 100, 90), batchSize: clampNumber(raw.batchSize, 1, 80, 50), concurrency: clampNumber(raw.concurrency, 1, 8, 5), groundingWindow: clampNumber(raw.groundingWindow, 0.5, 5, 2), groundingFrameLimit: clampNumber(raw.groundingFrameLimit, 1, 10, 5), reviewConcurrency: clampNumber(raw.reviewConcurrency, 1, 6, 3), topN: clampNumber(raw.topN, 1, 5, 1), mode: ['full', 'frames-only', 'detect-only'].includes(raw.mode) ? raw.mode : 'full', }; } function readPrompt(name) { return fs.readFileSync(path.join(__dirname, '..', 'prompts', name), 'utf8'); } function resolveWorkspaceRoot(env) { if (env.RZYX_AI_WORKSPACE_ROOT) return env.RZYX_AI_WORKSPACE_ROOT; if (env.RZYX_AI_DATA_DIR) return path.join(env.RZYX_AI_DATA_DIR, 'workspace', 'vehicle-damage-inspection'); return path.join(__dirname, '..', '.workspace'); } function buildFinalOutput({ workspace, videoInfo, vehicleInfo, candidates, damages, bestFrameImages, reviewImages, uncertainDamages = [], artifacts }) { return { success: true, taskId: workspace.taskId, workspacePath: workspace.workspacePath, summary: { duration: videoInfo.duration, resolution: videoInfo.resolution, frameCount: videoInfo.extractedFrames, candidateDamageCount: candidates.length, mergedDamageCount: damages.length, uncertainDamageCount: uncertainDamages.length, bestFrameCount: bestFrameImages.length, reviewImageCount: reviewImages.length, needsReview: reviewImages.length > 0, }, vehicleInfo, damages, uncertainDamages, bestFrameImages, reviewImages, artifacts, }; } async function run(rawInput, env = process.env) { const input = normalizeInput(rawInput); const workspace = createWorkspace({ taskId: input.taskId, workspaceRoot: resolveWorkspaceRoot(env) }); const uploadRoot = env.RZYX_AI_UPLOAD_ROOT || (env.RZYX_AI_DATA_DIR ? path.join(env.RZYX_AI_DATA_DIR, 'uploads') : undefined); const videoPath = resolveVideoPath(input.videoUrl, { uploadRoot }); emitEvent('video', 'video path resolved', { videoPath }); const { videoInfo, frames } = await extractFrames({ workspace, videoPath, fps: input.fps, quality: input.quality, }); if (input.mode === 'frames-only') { const output = buildFinalOutput({ workspace, videoInfo, vehicleInfo: {}, candidates: [], damages: [], bestFrameImages: [], reviewImages: [], uncertainDamages: [], artifacts: { videoInfo: 'video_info.json' }, }); writeJson(workspace, 'run_summary.json', output); emitEvent('done', 'frames-only inspection complete', { frameCount: frames.length }); return output; } const modelConfig = resolveModelConfig(env); const detection = await detectDamageCandidates({ workspace, frames, modelConfig, prompt: readPrompt('damage_detect.md'), batchSize: input.batchSize, concurrency: input.concurrency, }); if (input.mode === 'detect-only' || detection.candidates.length === 0) { const output = buildFinalOutput({ workspace, videoInfo, vehicleInfo: detection.vehicleInfo, candidates: detection.candidates, damages: [], bestFrameImages: [], reviewImages: [], uncertainDamages: [], artifacts: { videoInfo: 'video_info.json', damageCandidates: 'damage_candidates.json', runSummary: 'run_summary.json' }, }); writeJson(workspace, 'run_summary.json', output); emitEvent('done', detection.candidates.length === 0 ? 'no candidates found' : 'detect-only inspection complete', { damageCount: 0 }); return output; } const grounding = await groundDamages({ workspace, frames, candidates: detection.candidates, modelConfig, prompt: readPrompt('grounding.md'), groundingWindow: input.groundingWindow, frameLimit: input.groundingFrameLimit, concurrency: Math.min(3, input.concurrency), }); const reviewed = await reviewDamageAnnotations({ workspace, annotations: grounding.annotations, modelConfig, prompt: readPrompt('damage_review.md'), concurrency: input.reviewConcurrency, }); const best = await selectBestFrames({ workspace, annotations: reviewed.accepted, topN: input.topN, modelConfig, prompt: readPrompt('best_frame.md'), }); const output = buildFinalOutput({ workspace, videoInfo, vehicleInfo: detection.vehicleInfo, candidates: detection.candidates, damages: best.damages, bestFrameImages: best.bestFrameImages, reviewImages: reviewed.reviewImages, uncertainDamages: reviewed.uncertain.map(item => ({ id: item.damageId, part: item.part, type: item.type, severity: item.severity, description: item.review?.reason || item.description, timestamps: (item.markedFrames || []).map(frame => frame.timestamp), review: item.review || null, })), artifacts: { videoInfo: 'video_info.json', damageCandidates: 'damage_candidates.json', damageAnnotations: 'damage_annotations.json', damageReview: 'damage_review.json', bestFrames: 'best_frames.json', runSummary: 'run_summary.json', }, }); writeJson(workspace, 'run_summary.json', output); emitEvent('done', 'full inspection complete', { damageCount: output.damages.length }); return output; } async function main() { try { const stdin = fs.readFileSync(0, 'utf8'); const input = JSON.parse(stdin || '{}'); const output = await run(input); process.stdout.write(JSON.stringify(output)); } catch (error) { process.stdout.write(JSON.stringify({ success: false, error: error.message })); process.exitCode = 0; } } if (require.main === module) { main(); } module.exports = { normalizeInput, buildFinalOutput, run, };