//! JS: generate-image.mjs -> `impeccable generate-image` use crate::jsp; use crate::util::{iso_now, json_pretty, node_read_error, utf16_len, Env}; use impeccable_common::Io; use serde_json::{Map, Value}; use std::io::Write; fn arg(args: &[String], name: &str) -> Option { let i = args.iter().position(|a| a == &format!("--{}", name))?; let v = args.get(i + 1)?; if !v.is_empty() && !v.starts_with("--") { Some(v.clone()) } else { None } } fn hash32(s: &str) -> u32 { let mut h: u32 = 0x811c9dc5; for cu in s.encode_utf16() { h ^= cu as u32; h = h.wrapping_mul(0x01000193); } h } fn hsl_to_rgb(h_deg: f64, s: f64, l: f64) -> [u8; 3] { let h = ((h_deg % 360.0) + 360.0) % 360.0 / 360.0; let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }; let p = 2.0 * l - q; let hue = |t: f64| -> f64 { let mut tt = t; if tt < 0.0 { tt += 1.0; } if tt > 1.0 { tt -= 1.0; } if tt < 1.0 / 6.0 { return p + (q - p) * 6.0 * tt; } if tt < 1.0 / 2.0 { return q; } if tt < 2.0 / 3.0 { return p + (q - p) * (2.0 / 3.0 - tt) * 6.0; } p }; let round = |c: f64| -> u8 { js_round(c * 255.0) as u8 }; [round(hue(h + 1.0 / 3.0)), round(hue(h)), round(hue(h - 1.0 / 3.0))] } /// Math.round: half up toward +inf fn js_round(x: f64) -> f64 { (x + 0.5).floor() } fn to_hex(c: [u8; 3]) -> String { format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) } fn palette(prompt: &str) -> Vec<[u8; 3]> { let h = hash32(prompt); let base = (h % 360) as f64; let bands = 2 + ((h >> 9) % 2) as usize; let spread = (40 + (h >> 3) % 120) as f64; let mut out = Vec::new(); for i in 0..bands { let hue = base + i as f64 * spread; let light = 0.32 + ((h >> (i * 5)) % 40) as f64 / 100.0; out.push(hsl_to_rgb(hue, 0.55, light)); } out } fn collapse_ws(s: &str) -> String { // .replace(/\s+/g, ' ').trim() let mut out = String::new(); let mut in_ws = false; for c in s.chars() { if c.is_whitespace() || c == '\u{FEFF}' { if !in_ws { out.push(' '); in_ws = true; } } else { in_ws = false; out.push(c); } } crate::util::js_trim(&out).to_string() } fn num(v: f64) -> String { crate::util::js_number_to_string(v) } fn svg_fake(prompt: &str, w: f64, h: f64) -> String { let colors: Vec = palette(prompt).into_iter().map(to_hex).collect(); let n = colors.len(); let stops: String = colors .iter() .enumerate() .map(|(i, c)| format!("", num(js_round(i as f64 / (n as f64 - 1.0) * 100.0)), c)) .collect(); let per_line = (12.0f64).max((w / 26.0).floor()) as usize; let words: Vec<&str> = { let collapsed = collapse_ws(prompt); // split(' ') on the collapsed string; leak for lifetime simplicity Box::leak(collapsed.into_boxed_str()).split(' ').collect() }; let mut lines: Vec = Vec::new(); let mut cur = String::new(); for word in words { let candidate = crate::util::js_trim(&format!("{} {}", cur, word)).to_string(); if utf16_len(&candidate) > per_line { if !cur.is_empty() { lines.push(cur.clone()); } cur = word.to_string(); } else { cur = candidate; } if lines.len() >= 10 { break; } } if !cur.is_empty() && lines.len() < 11 { lines.push(cur); } let escape = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">"); let font_size = js_round(w / 24.0); let start_y = h / 2.0 - ((lines.len() as f64 - 1.0) * font_size * 1.3) / 2.0; let text: String = lines .iter() .enumerate() .map(|(i, line)| { format!( "{}", num(w / 2.0), num(js_round(start_y + i as f64 * font_size * 1.3)), num(font_size), escape(line) ) }) .collect(); format!( "\n\n {stops}\n \n \n {text}\n \n SYNTHETIC COMP\n\n", w = num(w), h = num(h), stops = stops, text = text, x1 = num(w - js_round(w / 4.2)), y1 = num(h - js_round(h / 16.0)), bw = num(js_round(w / 4.2)), bh = num(js_round(h / 16.0)), tx = num(w - js_round(w / 8.4)), ty = num(h - js_round(h / 32.0)), fs = num(js_round(w / 60.0)), ) } fn crc32(data: &[u8]) -> u32 { let mut c = 0xffffffffu32; for b in data { c ^= *b as u32; for _ in 0..8 { c = if c & 1 == 1 { 0xedb88320 ^ (c >> 1) } else { c >> 1 }; } } c ^ 0xffffffff } fn png_chunk(ty: &[u8], data: &[u8]) -> Vec { let mut body = ty.to_vec(); body.extend_from_slice(data); let mut out = Vec::new(); out.extend_from_slice(&(data.len() as u32).to_be_bytes()); out.extend_from_slice(&body); out.extend_from_slice(&crc32(&body).to_be_bytes()); out } fn png_fake(prompt: &str, w: usize, h: usize) -> Vec { let colors = palette(prompt); let band_h = (h as f64 / colors.len() as f64).ceil() as usize; let stride = w * 3; let mut raw = vec![0u8; h * (stride + 1)]; for y in 0..h { let row = y * (stride + 1); raw[row] = 0; let idx = (colors.len() - 1).min(if band_h == 0 { 0 } else { y / band_h }); let [r, g, b] = colors[idx]; for x in 0..w { let p = row + 1 + x * 3; raw[p] = r; raw[p + 1] = g; raw[p + 2] = b; } } let mut ihdr = vec![0u8; 13]; ihdr[..4].copy_from_slice(&(w as u32).to_be_bytes()); ihdr[4..8].copy_from_slice(&(h as u32).to_be_bytes()); ihdr[8] = 8; ihdr[9] = 2; let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(9)); let _ = enc.write_all(&raw); let idat = enc.finish().unwrap_or_default(); let mut text = b"Comment".to_vec(); text.push(0); // latin1: chars > 0xff become their low byte in Node's 'latin1' encoding for c in format!("SYNTHETIC COMP: {}", collapse_ws(prompt)).encode_utf16() { text.push((c & 0xff) as u8); } let mut out = vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; out.extend(png_chunk(b"IHDR", &ihdr)); out.extend(png_chunk(b"tEXt", &text)); out.extend(png_chunk(b"IDAT", &idat)); out.extend(png_chunk(b"IEND", &[])); out } fn parse_size(s: &str) -> (usize, usize) { if let Some((a, b)) = s.split_once('x') { if !a.is_empty() && !b.is_empty() && a.chars().all(|c| c.is_ascii_digit()) && b.chars().all(|c| c.is_ascii_digit()) { return (a.parse().unwrap_or(1536), b.parse().unwrap_or(1024)); } } (1536, 1024) } pub fn run(args: &[String], io: &mut Io) -> i32 { let cwd = io.cwd.to_string_lossy().into_owned(); let env: Env = io.env.clone(); let abs = |p: &str| jsp::resolve(&cwd, &[p]); let read_prompt_file = |io: &mut Io, pf: &str| -> Result { match std::fs::read(abs(pf)) { Ok(b) => Ok(String::from_utf8_lossy(&b).into_owned()), Err(e) => { io.err(&format!("Error: {}\n", node_read_error(pf, &e))); Err(1) } } }; if env.get("IMPECCABLE_IMAGE_GEN_FAKE").map(|v| !v.is_empty()).unwrap_or(false) { let prompt = match arg(args, "prompt-file") { Some(pf) => match read_prompt_file(io, &pf) { Ok(p) => Some(p), Err(c) => return c, }, None => arg(args, "prompt"), }; let out = arg(args, "out"); let (Some(prompt), Some(out)) = (prompt.filter(|p| !p.is_empty()), out) else { io.err("generate-image: --prompt (or --prompt-file) and --out are required.\n"); return 1; }; let (w, h) = parse_size(&arg(args, "size").unwrap_or_else(|| "1536x1024".into())); let bytes = if out.ends_with(".svg") { svg_fake(&prompt, w as f64, h as f64).into_bytes() } else { png_fake(&prompt, w, h) }; if let Err(e) = std::fs::write(abs(&out), bytes) { io.err(&format!("Error: {}\n", node_read_error(&out, &e))); return 1; } io.out(&format!("IMAGE: {} ({}x{}, fake synthetic comp, $0.00, no API call)\n", out, w, h)); return 0; } let Some(key) = env.get("OPENAI_API_KEY").filter(|k| !k.is_empty()).cloned() else { io.err("generate-image: OPENAI_API_KEY is not set; use the harness-native image tool instead.\n"); return 1; }; let prompt = match arg(args, "prompt-file") { Some(pf) => match read_prompt_file(io, &pf) { Ok(p) => Some(p), Err(c) => return c, }, None => arg(args, "prompt"), }; let out = arg(args, "out"); let (Some(prompt), Some(out)) = (prompt.filter(|p| !p.is_empty()), out) else { io.err("generate-image: --prompt (or --prompt-file) and --out are required.\n"); return 1; }; let size = arg(args, "size").unwrap_or_else(|| "1536x1024".into()); let quality = arg(args, "quality").unwrap_or_else(|| "medium".into()); let mut refs: Vec = Vec::new(); for i in 0..args.len() { if args[i] == "--ref" { if let Some(n) = args.get(i + 1) { if !n.is_empty() && !n.starts_with("--") { refs.push(n.clone()); } } } } let agent = ureq::AgentBuilder::new().build(); let response = if !refs.is_empty() { let boundary = format!("----impeccable{:x}", crate::util::now_ms() as u64); let mut body: Vec = Vec::new(); let mut field = |name: &str, value: &str| { body.extend_from_slice(format!("--{}\r\nContent-Disposition: form-data; name=\"{}\"\r\n\r\n{}\r\n", boundary, name, value).as_bytes()); }; field("model", "gpt-image-2"); field("prompt", &prompt); field("size", &size); field("quality", &quality); field("n", "1"); for r in &refs { let bytes = match std::fs::read(abs(r)) { Ok(b) => b, Err(e) => { io.err(&format!("Error: {}\n", node_read_error(r, &e))); return 1; } }; let ty = if r.ends_with(".png") { "image/png" } else if r.ends_with(".webp") { "image/webp" } else { "image/jpeg" }; let filename = r.rsplit('/').next().unwrap_or(r); body.extend_from_slice( format!("--{}\r\nContent-Disposition: form-data; name=\"image[]\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n", boundary, filename, ty).as_bytes(), ); body.extend_from_slice(&bytes); body.extend_from_slice(b"\r\n"); } body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes()); agent .post("https://api.openai.com/v1/images/edits") .set("Authorization", &format!("Bearer {}", key)) .set("Content-Type", &format!("multipart/form-data; boundary={}", boundary)) .send_bytes(&body) } else { let mut m = Map::new(); m.insert("model".into(), Value::String("gpt-image-2".into())); m.insert("prompt".into(), Value::String(prompt.clone())); m.insert("size".into(), Value::String(size.clone())); m.insert("quality".into(), Value::String(quality.clone())); m.insert("n".into(), Value::from(1)); agent .post("https://api.openai.com/v1/images/generations") .set("Authorization", &format!("Bearer {}", key)) .set("content-type", "application/json") .send_string(&serde_json::to_string(&Value::Object(m)).unwrap()) }; let (status, text) = match response { Ok(r) => { let st = r.status(); (st, r.into_string().unwrap_or_default()) } Err(ureq::Error::Status(code, r)) => (code, r.into_string().unwrap_or_default()), Err(e) => { io.err(&format!("TypeError: fetch failed: {}\n", e)); return 1; } }; if !(200..300).contains(&status) { let snippet: String = text.chars().take(300).collect(); io.err(&format!("generate-image: API error {}: {}\n", status, snippet)); return 1; } let json: Value = serde_json::from_str(&text).unwrap_or(Value::Null); let b64 = json.get("data").and_then(|d| d.get(0)).and_then(|d| d.get("b64_json")).and_then(|b| b.as_str()).filter(|s| !s.is_empty()); let Some(b64) = b64 else { io.err("generate-image: no image in response\n"); return 1; }; let bytes = base64_decode(b64); let _ = std::fs::write(abs(&out), bytes); // best-effort embed + sidecar // JS-PARITY: generate-image.mjs#676 reports whether the embed actually // succeeded. The install-path-with-spaces half of #676 is a JS-only // subprocess concern (fileURLToPath vs URL.pathname); the engine embeds // in-process, so only the success tracking and message carry over here. let embedded; { let mut sub_io = Io::captured("", io.cwd.clone(), io.env.clone()).0; let ret = crate::embed_prompt::run(&[out.clone(), "--prompt".to_string(), prompt.clone()], &mut sub_io); embedded = ret == 0; if !embedded { io.err("generate-image: failed to embed prompt in the image\n"); } let mut m = Map::new(); m.insert("prompt".into(), Value::String(prompt.clone())); m.insert("createdAt".into(), Value::String(iso_now())); m.insert("tool".into(), Value::String("impeccable generate-image".into())); m.insert("model".into(), Value::String("gpt-image-2".into())); if !refs.is_empty() { m.insert("refs".into(), Value::Array(refs.iter().cloned().map(Value::String).collect())); } let _ = std::fs::write(abs(&format!("{}.json", out)), json_pretty(&Value::Object(m))); } io.out(&format!( "IMAGE: {} ({}, {}, gpt-image-2, billed to your OpenAI key); {} at {}.json\n", out, size, quality, if embedded { "prompt embedded + sidecar" } else { "sidecar" }, out )); 0 } fn base64_decode(s: &str) -> Vec { let table = |c: u8| -> Option { match c { b'A'..=b'Z' => Some((c - b'A') as u32), b'a'..=b'z' => Some((c - b'a' + 26) as u32), b'0'..=b'9' => Some((c - b'0' + 52) as u32), b'+' | b'-' => Some(62), b'/' | b'_' => Some(63), _ => None, } }; let mut out = Vec::new(); let mut acc: u32 = 0; let mut bits = 0; for b in s.bytes() { let Some(v) = table(b) else { continue }; acc = (acc << 6) | v; bits += 6; if bits >= 8 { bits -= 8; out.push(((acc >> bits) & 0xff) as u8); } } out }