202 lines
6.7 KiB
JavaScript
202 lines
6.7 KiB
JavaScript
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,
|
|
};
|