← back to Visual Factory
src/stages/vision_check.js
60 lines
// Stage 5 — vision_check. The exo-ring vision model (Gemini fallback if the ring is
// down) describes what's actually on the rendered PNG — the ground-truth visual signal
// that the critic uses to compare against the brief.
const { ollama } = require('../llm');
const SYSTEM = `You are a precise visual reviewer for a generated image.
Output ONLY a JSON object:
text_content array of every word or phrase you can read (best effort — leave [] if you cannot read text reliably)
layout one short sentence on the composition
mood one short phrase
issues array — ONLY include CONCRETE visible problems: text clipping/overflow, lorem ipsum, broken/squished layout, all-blank canvas. Do NOT include subjective concerns. Empty array if the image looks intentional.
overall_quality integer 0-100 — your honest read of polish/intentionality. A clean editorial layout with clear hierarchy is 85+. A broken/blank/cluttered layout is <50.
JSON only — no prose, no fences, no explanations.`;
async function runVisionCheck({ pngPath, signal = null }) {
// Vision is best-effort. The vision model can still degenerate (loops empty
// strings, fails JSON parse) or the ring/fallback can both be unreachable. If
// it crashes, return a neutral "vision unavailable" shape so the pipeline can
// still finish and the critic can decide based on the brief alone. The PNG
// already exists — vision is just a signal.
try {
const desc = await ollama({
model: process.env.VISION_MODEL || 'mlx-community/Qwen3-VL-4B-Instruct-4bit',
system: SYSTEM,
prompt: 'Describe this image as JSON per the system instructions.',
images: [pngPath],
format: 'json',
temperature: 0.1,
signal
});
return {
text_content: Array.isArray(desc.text_content) ? desc.text_content.filter(s => s && s.trim()).slice(0, 30) : [],
layout: String(desc.layout || ''),
mood: String(desc.mood || ''),
issues: Array.isArray(desc.issues) ? desc.issues.filter(s => s && s.trim()) : [],
overall_quality: Number(desc.overall_quality) || 0,
_vision_ok: true
};
} catch (err) {
// Don't swallow stage cancellation as "vision unavailable" — propagate so
// the pipeline correctly reports the run as timed out instead of silently
// moving on with a neutral score.
if (signal?.aborted || /aborted/i.test(err.message)) throw err;
return {
text_content: [],
layout: '',
mood: '',
issues: [],
overall_quality: 75, // neutral default — assume rendered OK; critic can override on other signals
_vision_ok: false,
_vision_error: err.message.slice(0, 200)
};
}
}
module.exports = { runVisionCheck };