Files
pbakaus_impeccable/skill/scripts/image-gen.mjs
T
Abdul WahabandCursor 98ab04b94b Improve visual cue heroes for color real estate and Gemini safety.
Ban hex codes from image prompts to stop Nano Banana swatch strips, require crisp deep focus and set-dressed surfaces inside close product-world scenes, and add wordless and real-estate inspection retries. The Gemini wrapper now converts JPEG bytes to PNG and infers AQ.-prefixed keys.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00

364 lines
16 KiB
JavaScript

#!/usr/bin/env node
// image-gen.mjs — image generation for keyless harnesses.
// Playbook: skill/reference/image-api.md (canonical; this help text is not).
//
// node image-gen.mjs --prompt "..." --out /abs/path.png
// [--ref /abs/ref.png] [--width 1408] [--height 1408]
//
// One CLI, several providers. IMAGE_GEN_PROVIDER in .impeccable/.env picks
// the backend:
// bfl FLUX (Black Forest Labs). No ref: flux-pro-1.1 text-to-image;
// with ref: flux-kontext-max image-to-image, aspect ratio 1:1.
// gemini Google Nano Banana (Gemini image models), always square 1:1.
// <else> delegates to a project-local .impeccable/image-gen.mjs that
// implements this same CLI (see image-api.md for the contract).
// When the provider line is missing it is inferred from the key's shape
// (Google keys start with "AIza"; anything else is treated as bfl).
//
// Prints the absolute output path on success; exits non-zero with the
// error on stderr on failure. Dependency-free; needs curl and (as a DNS
// fallback) dig on PATH.
//
// Reads IMAGE_GEN_API_KEY from the environment, falling back to
// ./.impeccable/.env relative to the working directory, so callers never
// need to `source` anything: run it from the project root and it finds
// the key itself.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import dns from "node:dns";
import { execFileSync, spawnSync } from "node:child_process";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ------------------------------------------------------------------ env
// The key lives in .impeccable/.env per the document seed flow. Loading it
// here (instead of requiring the caller to export it) removes the one setup
// step subagents historically forgot, which cost a failed call each time.
// IMAGE_API_KEY is accepted as a legacy alias: early seed runs wrote that
// name, and those .env files are still in the wild.
function loadEnv(...names) {
for (const name of names) if (process.env[name]) return process.env[name];
const envPath = path.join(process.cwd(), ".impeccable", ".env");
if (!fs.existsSync(envPath)) return undefined;
const vars = {};
for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/);
if (m) vars[m[1]] = m[2].replace(/^["']|["']$/g, "");
}
for (const name of names) if (vars[name]) return vars[name];
return undefined;
}
// ------------------------------------------------------------------ DNS
// Sandboxed harnesses (Claude Code among them) often block the default
// resolver for the providers' hosts while the hosts stay reachable by IP.
// So every request resolves the host here — system resolver first, then
// dig against the default, Google, and Cloudflare resolvers — and pins
// curl to the IP with --resolve. fetch() is never used; it dies at the
// DNS stage.
async function resolveIp(hostname) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const { address } = await dns.promises.lookup(hostname, { family: 4 });
if (address) return address;
} catch {
// fall through to dig
}
for (const server of [null, "8.8.8.8", "1.1.1.1"]) {
try {
const args = ["+short", "+time=3", "A", hostname];
if (server) args.push(`@${server}`);
const ips = execFileSync("dig", args, { encoding: "utf8" })
.trim()
.split("\n")
.map((l) => l.trim())
.filter((l) => /^\d+\.\d+\.\d+\.\d+$/.test(l));
if (ips.length > 0) return ips[ips.length - 1];
} catch {
// next resolver
}
}
await sleep(1000);
}
throw new Error(`cannot resolve ${hostname} via system resolver, dig, 8.8.8.8, or 1.1.1.1`);
}
// ----------------------------------------------------------------- curl
// Returns { status, json, text } instead of throwing on HTTP errors, so
// callers can branch on 402 (credits) and 429 (rate/quota) rather than
// seeing one opaque curl failure. Request bodies always travel via a temp
// file: a base64 reference image passed as a literal -d argument overflows
// argv (E2BIG) and kills the call before it reaches the network.
async function curlJson(url, { method = "GET", headers = {}, body } = {}) {
const { hostname } = new URL(url);
const ip = await resolveIp(hostname);
const args = ["-sS", "--max-time", "180", "--resolve", `${hostname}:443:${ip}`, "-X", method, "-w", "\n%{http_code}"];
for (const [k, v] of Object.entries(headers)) args.push("-H", `${k}: ${v}`);
let bodyFile;
if (body !== undefined) {
bodyFile = path.join(os.tmpdir(), `image-gen-body-${process.pid}-${Date.now()}.json`);
fs.writeFileSync(bodyFile, body);
args.push("-d", `@${bodyFile}`);
}
args.push(url);
try {
const out = execFileSync("curl", args, { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
const nl = out.lastIndexOf("\n");
const status = parseInt(out.slice(nl + 1), 10);
const text = out.slice(0, nl);
let json = null;
try {
json = JSON.parse(text);
} catch {
// non-JSON body (edge HTML error page); callers see json === null
}
return { status, json, text };
} finally {
if (bodyFile) fs.rmSync(bodyFile, { force: true });
}
}
async function download(url, outPath) {
const { hostname } = new URL(url);
let lastErr;
// Re-resolve on every attempt: CDN delivery hosts are the flakiest to
// resolve, and a fresh IP is usually what fixes a failure.
for (let attempt = 0; attempt < 3; attempt++) {
try {
const ip = await resolveIp(hostname);
execFileSync("curl", ["-sS", "-f", "--max-time", "60", "--resolve", `${hostname}:443:${ip}`, "-o", outPath, url]);
if (fs.existsSync(outPath) && fs.statSync(outPath).size > 0) return;
lastErr = new Error("download produced an empty file");
} catch (e) {
lastErr = e;
}
await sleep(2000 * (attempt + 1));
}
throw lastErr;
}
// ----------------------------------------------------------------- args
function getArg(name, def) {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : def;
}
function fail(msg) {
console.error(msg);
process.exit(1);
}
// ------------------------------------------------------------------ bfl
// FLUX is asynchronous: submit returns a polling_url, poll until Ready,
// download the signed result URL inside its 10-minute expiry. Transient
// failures are absorbed internally so a network blip costs this script
// seconds instead of costing a caller one of its generation attempts.
// Only two failures are final on the spot: 402 means the account is out
// of credits (a human must top up; retrying is pointless), and a
// moderation status means the prompt itself must change.
async function generateBfl({ apiKey, prompt, ref, width, height, out }) {
for (const [label, v] of [["width", width], ["height", height]]) {
if (Number.isNaN(v) || v < 256 || v > 1440 || v % 32 !== 0) {
fail(`${label} ${v} out of range: BFL takes 256-1440 in multiples of 32`);
}
}
const base = "https://api.bfl.ai";
let endpoint, body;
if (ref) {
endpoint = "/v1/flux-kontext-max";
body = { prompt, input_image: fs.readFileSync(ref).toString("base64"), aspect_ratio: "1:1", output_format: "png" };
} else {
endpoint = "/v1/flux-pro-1.1";
body = { prompt, width, height, output_format: "png" };
}
const authHeaders = { "x-key": apiKey, "Content-Type": "application/json", accept: "application/json" };
let submit;
for (let attempt = 0; ; attempt++) {
try {
submit = await curlJson(base + endpoint, { method: "POST", headers: authHeaders, body: JSON.stringify(body) });
} catch (e) {
submit = { status: 0, json: null, text: e.message };
}
if (submit.status === 200 && submit.json?.polling_url) break;
if (submit.status === 402) fail("BFL account is out of credits; add credits at dashboard.bfl.ai and re-run");
if (submit.status === 401 || submit.status === 403) fail(`BFL rejected the key (HTTP ${submit.status}): check IMAGE_GEN_API_KEY`);
if (attempt >= 2) fail(`Submit failed after 3 attempts (last HTTP ${submit.status}): ${submit.text?.slice(0, 300)}`);
// 429 is the active-task cap (24 tasks; 6 for kontext-max): wait longer.
await sleep(submit.status === 429 ? 10000 : 2000 * (attempt + 1));
}
// Poll the returned polling_url (never a reconstructed one; the global
// endpoint requires it). Tolerate a few consecutive transient poll
// failures — the task keeps running server-side regardless.
let result;
let pollFailures = 0;
for (let i = 0; i < 150; i++) {
await sleep(2000);
let poll;
try {
poll = await curlJson(submit.json.polling_url, { headers: { "x-key": apiKey, accept: "application/json" } });
} catch {
poll = null;
}
if (!poll || poll.status >= 500 || !poll.json) {
if (++pollFailures >= 5) fail("Polling failed 5 times in a row; giving up");
continue;
}
pollFailures = 0;
if (poll.json.status === "Ready") {
result = poll.json.result;
break;
}
if (["Error", "Failed", "Content Moderated", "Request Moderated", "Task not found"].includes(poll.json.status)) {
fail(`Generation failed with status "${poll.json.status}": ${JSON.stringify(poll.json).slice(0, 300)}`);
}
}
if (!result) fail("Timed out waiting for the generation (5 minutes)");
// The sample URL is signed and expires after 10 minutes; download now.
await download(result.sample, out);
}
// --------------------------------------------------------------- gemini
// Nano Banana is synchronous: one generateContent call returns the image
// as base64 in the response, no polling, no delivery CDN. The aspect ratio
// is pinned 1:1 in imageConfig, so output is always square regardless of
// --width/--height (Gemini picks its own pixel size per tier; the pipeline
// only requires square). Moderation shows up as a response with no image
// part plus a block reason, not as an HTTP error.
async function generateGemini({ apiKey, prompt, ref, out }) {
// IMAGE_GEN_MODEL overrides for users on a different tier; the default
// is the high-volume Nano Banana model.
let model = loadEnv("IMAGE_GEN_MODEL") || "gemini-3.1-flash-image";
const parts = [{ text: prompt }];
if (ref) parts.push({ inlineData: { mimeType: "image/png", data: fs.readFileSync(ref).toString("base64") } });
const body = JSON.stringify({
contents: [{ parts }],
generationConfig: { responseModalities: ["IMAGE"], imageConfig: { aspectRatio: "1:1" } },
});
const headers = { "x-goog-api-key": apiKey, "Content-Type": "application/json" };
const urlFor = (m) => `https://generativelanguage.googleapis.com/v1beta/models/${m}:generateContent`;
let res;
for (let attempt = 0; ; attempt++) {
try {
res = await curlJson(urlFor(model), { method: "POST", headers, body });
} catch (e) {
res = { status: 0, json: null, text: e.message };
}
if (res.status === 200) break;
const msg = res.json?.error?.message || res.text?.slice(0, 300) || "";
if (res.status === 400 && /API key not valid/i.test(msg)) fail(`Gemini rejected the key: check IMAGE_GEN_API_KEY (${msg.slice(0, 200)})`);
if (res.status === 401 || res.status === 403) fail(`Gemini rejected the key (HTTP ${res.status}): ${msg.slice(0, 200)}`);
// Model ids drift between stable and -preview suffixes as Google
// promotes them; try the sibling name once before giving up.
if (res.status === 404 && !model.endsWith("-preview")) {
model = `${model}-preview`;
continue;
}
if (res.status === 429 && attempt >= 4) fail(`Gemini quota or rate limit exhausted after 5 attempts: ${msg.slice(0, 200)}; check the plan and billing for this key`);
if (attempt >= 4) fail(`Gemini call failed after 5 attempts (last HTTP ${res.status}): ${msg.slice(0, 300)}`);
await sleep(res.status === 429 ? 15000 : 2000 * (attempt + 1));
}
const blocked = res.json?.promptFeedback?.blockReason;
if (blocked) fail(`Prompt was moderated (${blocked}); reword the prompt and re-run`);
const cand = res.json?.candidates?.[0];
const imgPart = cand?.content?.parts?.find((p) => p.inlineData?.data || p.inline_data?.data);
if (!imgPart) {
const reason = cand?.finishReason || "no image part in the response";
fail(`Generation returned no image (${reason}); reword the prompt and re-run`);
}
// Gemini often returns JPEG bytes whatever the caller's filename says,
// and the pipelines' compile steps decode PNG only, so convert here
// rather than making every caller rediscover the mismatch.
writeAsPng(Buffer.from(imgPart.inlineData?.data || imgPart.inline_data.data, "base64"), out);
}
// Writes image bytes to `out` as a real PNG. PNG input passes through;
// anything else (JPEG, WebP) is converted with the first available system
// tool: sips ships with macOS, ImageMagick and ffmpeg cover Linux.
function writeAsPng(buf, out) {
if (buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
fs.writeFileSync(out, buf);
return;
}
const tmp = path.join(os.tmpdir(), `image-gen-raw-${process.pid}-${Date.now()}.img`);
fs.writeFileSync(tmp, buf);
const converters = [
["sips", ["-s", "format", "png", tmp, "--out", out]],
["magick", [tmp, `png:${out}`]],
["convert", [tmp, `png:${out}`]],
["ffmpeg", ["-y", "-i", tmp, out]],
];
try {
for (const [cmd, args] of converters) {
try {
execFileSync(cmd, args, { stdio: "ignore" });
if (fs.existsSync(out) && fs.statSync(out).size > 0) return;
} catch {
// tool missing or failed; try the next one
}
}
fail("Provider returned non-PNG image bytes and no converter is available (tried sips, magick, convert, ffmpeg); install one and re-run");
} finally {
fs.rmSync(tmp, { force: true });
}
}
// ----------------------------------------------------------------- main
const prompt = getArg("prompt");
const out = getArg("out");
const ref = getArg("ref");
// 1408 is the default square: comfortably under BFL's 1440 cap and
// divisible by 32. Gemini ignores it (aspect ratio 1:1 pins its square).
const width = parseInt(getArg("width", "1408"), 10);
const height = parseInt(getArg("height", "1408"), 10);
const apiKey = loadEnv("IMAGE_GEN_API_KEY", "IMAGE_API_KEY");
// Users and earlier runs write provider names loosely ("flux" for bfl,
// "nano-banana" for gemini); normalize the known spellings instead of
// failing on them. Google API keys start with "AIza" (classic) or "AQ."
// (newer), so a missing provider line is recoverable from the key itself.
const PROVIDER_ALIASES = {
bfl: "bfl", flux: "bfl", "black-forest-labs": "bfl",
gemini: "gemini", google: "gemini", "nano-banana": "gemini", nanobanana: "gemini",
};
const looksGoogle = apiKey?.startsWith("AIza") || apiKey?.startsWith("AQ.");
const rawProvider = (loadEnv("IMAGE_GEN_PROVIDER") || (looksGoogle ? "gemini" : "bfl")).toLowerCase();
const provider = PROVIDER_ALIASES[rawProvider] || rawProvider;
if (!prompt || !out) fail("Usage: --prompt <p> --out <abs path> [--ref <abs path>] [--width n] [--height n]");
if (provider !== "bfl" && provider !== "gemini") {
// Unknown provider: hand the same argv to a project-local wrapper that
// implements this CLI. The env guard stops a copied shipped script from
// delegating to itself forever.
const custom = path.join(process.cwd(), ".impeccable", "image-gen.mjs");
if (process.env.IMPECCABLE_IMAGE_GEN_DELEGATED || !fs.existsSync(custom)) {
fail(`Unknown IMAGE_GEN_PROVIDER "${provider}" and no ${custom}; supported providers are bfl and gemini, or write that file implementing the same CLI (see reference/image-api.md)`);
}
const child = spawnSync(process.execPath, [custom, ...process.argv.slice(2)], {
stdio: "inherit",
env: { ...process.env, IMPECCABLE_IMAGE_GEN_DELEGATED: "1" },
});
process.exit(child.status ?? 1);
}
if (!apiKey) fail("Missing IMAGE_GEN_API_KEY (environment or ./.impeccable/.env)");
fs.mkdirSync(path.dirname(out), { recursive: true });
if (provider === "gemini") await generateGemini({ apiKey, prompt, ref, out });
else await generateBfl({ apiKey, prompt, ref, width, height, out });
console.log(path.resolve(out));