202 lines
6.7 KiB
JavaScript
Raw Permalink Normal View History

2026-05-20 21:39:12 +08:00
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,
};