/** * Image content-filter risk — Gemini-Vision pixel classification (the * `check_image` port deferred from `qa-image-filter.ts`). * * Ported from `skills/video-replicator/scripts/bunty_image_filter_check.py` * `check_image()`: classify each slide image against the known first-frame * content-filter rejection patterns (weapon imagery, combat-pose silhouettes, * supernatural/demonic figures, dark industrial-noir compositions) BEFORE any * image-to-video generation runs — a rejected first frame wastes both credits * and wall time on futile retries. Verdict ladder (plus `error` for API/parse * failures, mirroring the Python): * * safe — clean composition, should pass the filter * risky — one or more high-risk elements; expect partial rejections * likely-blocked — matches known rejection patterns; expect rejection * * `ok` is false only when a slide is at or above `threshold` (default * `likely-blocked`, the Python's default) — `error` results are advisory * (the Python exits 2 separately for those; here the caller can inspect * `results`). The local, text-based keyword heuristic over generation * *prompts* lives in the pure sibling `qa-image-filter.ts` (same * {@link VERDICT_ORDER} ladder); this module is the network half that looks * at actual pixels. * * Transport (key pool / endpoint override / injectable fetcher / the two-line * `verdict:` / `reason:` reply contract) is the shared * {@link classifyImageWithGemini} in `gemini-vision-classify.ts`, used by both * assemble QA vision modules. */ import { classifyImageWithGemini, parseTwoLineVerdict, resolveVisionQaEndpoint, DEFAULT_GEMINI_VISION_QA_ENDPOINT, } from './gemini-vision-classify.js'; import { VERDICT_ORDER, type FilterVerdict } from './qa-image-filter.js'; import { VclawError } from '../errors.js'; export const DEFAULT_QA_IMAGE_VISION_ENDPOINT = DEFAULT_GEMINI_VISION_QA_ENDPOINT; const FILTER_VERDICTS = ['safe', 'risky', 'likely-blocked'] as const; export type ImageVisionVerdict = FilterVerdict | 'error'; export interface ImageVisionSlide { sceneIndex: number; /** Path to the slide/keyframe image that will seed image-to-video generation. */ imagePath: string; } export interface CheckImageVisionInput { slides: ImageVisionSlide[]; /** * Flag slides at or above this verdict (`ok` turns false). Default * `likely-blocked` — the Python's default; pass `risky` for a stricter gate. */ threshold?: Exclude; /** Override the Gemini endpoint (else VCLAW_GEMINI_API_ENDPOINT, else default). */ endpoint?: string; /** Explicit API key — bypasses the env-backed key pool. */ keyOverride?: string; /** Injectable fetch for offline tests. */ fetcher?: typeof fetch; } export interface ImageVisionResult { sceneIndex: number; verdict: ImageVisionVerdict; reason: string; } export interface CheckImageVisionResult { results: ImageVisionResult[]; /** Results at or above the threshold (never includes `error` results). */ flagged: ImageVisionResult[]; /** False only when at least one slide is at/above the threshold. */ ok: boolean; } /** * The audit prompt, generalized from the Python original (which was worded for * its cricket-recap deck). The four flagged-element categories and the EXACT * two-line `verdict:` / `reason:` reply contract are unchanged. */ export function buildImageVisionPrompt(): string { return [ 'You are auditing a video deck slide. The pipeline will use this slide as a first-frame image for AI video generation (e.g. Google Veo). The generator has an image content filter that rejects images containing:', '', '- Weapon imagery (swords, knives, guns, glowing energy weapons, objects held like weapons)', '- Combat-pose silhouettes (figures in fighting stances, dramatic battle poses)', '- Supernatural or demonic figures (glowing eyes, hooded dark figures, dramatic auras around people)', '- Dark industrial-noir compositions (rusted metal, dark helmeted silhouettes in destroyed environments)', '', 'Stylised comic-book or cinematic panels sometimes lean into action-hero/warrior aesthetics that look like combat imagery to the filter even when the subject is benign.', '', 'Classify the attached slide image. Reply in this EXACT format (no other text):', 'verdict: ', 'reason: ', ].join('\n'); } /** * Parse the two-line `verdict:` / `reason:` reply. An unrecognized verdict * value falls back to `error` with the full reply text as the reason, exactly * like the Python original. */ export function parseImageVisionReply(text: string): { verdict: ImageVisionVerdict; reason: string } { return parseTwoLineVerdict(text, FILTER_VERDICTS); } /** * Classify content-filter risk for every slide image, sequentially (one * Gemini call per slide, matching the Python). Per-slide failures (missing * image, HTTP error, unparseable reply) become `error` results rather than * aborting the deck. Throws only on invalid input. */ export async function checkImageVision( input: CheckImageVisionInput, ): Promise { if (!input || !Array.isArray(input.slides)) { throw new VclawError( 'unexpected_internal_error', 'checkImageVision: input.slides must be an array', ); } const threshold = input.threshold ?? 'likely-blocked'; // Runtime guard for JS callers — the type already excludes 'safe'. if (threshold !== 'risky' && threshold !== 'likely-blocked') { throw new VclawError( 'unexpected_internal_error', `checkImageVision: threshold must be 'risky' or 'likely-blocked', got '${threshold}'`, ); } const endpoint = resolveVisionQaEndpoint(input.endpoint); const prompt = buildImageVisionPrompt(); const results: ImageVisionResult[] = []; for (const slide of input.slides) { const classified = await classifyImageWithGemini({ imagePath: slide.imagePath, prompt, allowedVerdicts: FILTER_VERDICTS, endpoint, keyOverride: input.keyOverride, fetcher: input.fetcher, }); results.push({ sceneIndex: slide.sceneIndex, ...classified }); } const flagged = results.filter( (r) => r.verdict !== 'error' && VERDICT_ORDER[r.verdict] >= VERDICT_ORDER[threshold], ); return { results, flagged, ok: flagged.length === 0 }; }