'use strict'; // Clover 图片质量测试 · codeproxy 调用核心 // 复用自 xhs-tool/backend/infrastructure/aiClient.js(已实测跑通的对的路),简化:去降级、去sharp const path = require('path'); require('dotenv').config({ path: path.join(__dirname, '.env') }); const FormData = require('form-data'); const https = require('https'); const fs = require('fs'); const KEY = process.env.AI_API_KEY; const BASE_URL = process.env.AI_BASE_URL; const MODEL_IMAGE = process.env.MODEL_IMAGE || 'gpt-image-2'; const IMAGE_TIMEOUT = 300000; if (!KEY) throw new Error('AI_API_KEY 未配置(见 test/.env)'); const sleep = ms => new Promise(r => setTimeout(r, ms)); function toBuffer(img) { if (Buffer.isBuffer(img)) return img; if (typeof img === 'string' && img.startsWith('data:')) return Buffer.from(img.replace(/^data:[^;]+;base64,/, ''), 'base64'); return fs.readFileSync(img); // 本地文件路径 } // 单次请求:POST /images/edits (multipart) function postEditOnce({ imgList, prompt, m, sz }) { const form = new FormData(); form.append('model', m); form.append('prompt', prompt); form.append('size', sz); form.append('n', '1'); for (let i = 0; i < imgList.length; i++) { const buf = toBuffer(imgList[i]); form.append('image[]', buf, { filename: `img_${i}.jpg`, contentType: 'image/jpeg' }); } return new Promise((resolve, reject) => { const ac = new AbortController(); const timer = setTimeout(() => { ac.abort(); req.destroy(new Error('300s timeout')); }, IMAGE_TIMEOUT); const req = https.request(`${BASE_URL}/images/edits`, { method: 'POST', headers: { ...form.getHeaders(), Authorization: `Bearer ${KEY}` }, signal: ac.signal, }, (r) => { let body = ''; r.on('data', d => body += d); r.on('end', () => { clearTimeout(timer); resolve({ status: r.statusCode, body }); }); r.on('error', reject); }); req.on('error', (e) => { clearTimeout(timer); reject(e); }); form.pipe(req); }); } // 可重试错误:上游/网关临时故障 + 限流 + 超时 const RETRYABLE = new Set([429, 500, 502, 503, 504]); // 带参考图出图:失败自动退避重试(复用 xhs-tool 的重试思路) async function imageEdit({ images, prompt, traceId = 't', model, size, maxRetry = 3 }) { const m = model || MODEL_IMAGE; const sz = size || process.env.IMAGE_SIZE || '1024x1536'; const imgList = Array.isArray(images) ? images : [images]; let lastErr; for (let attempt = 1; attempt <= maxRetry; attempt++) { const t0 = Date.now(); try { const res = await postEditOnce({ imgList, prompt, m, sz }); console.log(`[imageEdit][${traceId}] try=${attempt}/${maxRetry} model=${m} size=${sz} imgs=${imgList.length} status=${res.status} ms=${Date.now()-t0}`); if (res.status === 200) { const d = JSON.parse(res.body).data[0]; if (d.url) return { type: 'url', value: d.url }; if (d.b64_json) return { type: 'b64', value: d.b64_json }; throw new Error('返回既无 url 也无 b64_json'); } lastErr = new Error(`HTTP ${res.status}: ${res.body.slice(0, 300)}`); if (!RETRYABLE.has(res.status)) throw lastErr; // 401/404等不重试,直接抛 } catch (e) { lastErr = e; const code = (e.message.match(/HTTP (\d+)/) || [])[1]; if (code && !RETRYABLE.has(Number(code))) throw e; } if (attempt < maxRetry) { const wait = 3000 * attempt; // 3s,6s 退避 console.log(` ↻ 上游临时故障,${wait/1000}s 后重试…`); await sleep(wait); } } throw lastErr; } // 保存结果到文件(url则下载,b64则解码) async function saveResult(result, outPath) { if (result.type === 'b64') { fs.writeFileSync(outPath, Buffer.from(result.value, 'base64')); return outPath; } // url:下载 await new Promise((resolve, reject) => { https.get(result.value, (r) => { const chunks = []; r.on('data', c => chunks.push(c)); r.on('end', () => { fs.writeFileSync(outPath, Buffer.concat(chunks)); resolve(); }); r.on('error', reject); }).on('error', reject); }); return outPath; } module.exports = { imageEdit, saveResult, toBuffer, sleep };