← back to Visual Factory

src/stages/vision_check.js

58 lines

// Stage 5 — vision_check. llava describes what's actually on the rendered PNG.
// This is 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. llava sometimes degenerates (loops empty strings,
  // fails JSON parse). 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 || 'llava:latest',
      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 };