feat: редактирование фото через xAI Grok Imagine API (/v1/images/edits) вместо Codex CLI

EDIT_ENGINE=grok по умолчанию, XAI_API_KEY/XAI_BASE_URL/XAI_IMAGE_MODEL/XAI_TIMEOUT_MS;
Codex остаётся для подсказок и как fallback (EDIT_ENGINE=codex).
This commit is contained in:
2026-11-04 19:01:50 +07:00
parent b4ea8df840
commit bea2c61216
9 changed files with 345 additions and 3 deletions

View File

@@ -152,6 +152,16 @@ function loadConfig(env = process.env) {
const codexModel = env.CODEX_MODEL || null;
const codexReasoningEffort = env.CODEX_REASONING_EFFORT || null;
// xAI Grok Imagine (редактирование изображений)
const editEngine = (env.EDIT_ENGINE || "grok").toLowerCase();
if (!["grok", "codex"].includes(editEngine)) {
issues.push(`EDIT_ENGINE должен быть grok или codex, получено: "${env.EDIT_ENGINE}"`);
}
const xaiApiKey = env.XAI_API_KEY || "";
const xaiBaseUrl = env.XAI_BASE_URL || "https://api.x.ai/v1";
const xaiImageModel = env.XAI_IMAGE_MODEL || "grok-imagine-image-2.0";
const xaiTimeoutMs = parseNumber(env.XAI_TIMEOUT_MS, 180000, "XAI_TIMEOUT_MS", issues, 1000);
if (issues.length > 0) {
throw new ConfigError(`Ошибки в конфигурации:\n- ${issues.join("\n- ")}`, issues);
}
@@ -187,11 +197,18 @@ function loadConfig(env = process.env) {
MAX_QUEUED_JOBS: maxQueuedJobs,
SSE_ENABLED: sseEnabled,
TRUST_PROXY: trustProxy,
EDIT_ENGINE: editEngine,
XAI_API_KEY: xaiApiKey,
XAI_BASE_URL: xaiBaseUrl,
XAI_IMAGE_MODEL: xaiImageModel,
XAI_TIMEOUT_MS: xaiTimeoutMs,
getSanitized() {
const copy = { ...this };
delete copy.APP_PASSWORD;
copy.APP_PASSWORD_SET = Boolean(this.APP_PASSWORD);
delete copy.XAI_API_KEY;
copy.XAI_API_KEY_SET = Boolean(this.XAI_API_KEY);
return copy;
},
};

View File

@@ -0,0 +1,145 @@
"use strict";
const { downscaleImage } = require("../images/resize");
const { getImageDimensions } = require("../images/dimensions");
const { scaleRegion } = require("../codex/prompts");
const { truncate } = require("../../logger");
const { AppError } = require("../../errors");
function mimeFromExt(ext) {
return ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
}
function extFromMime(m) {
return m && m.includes("png") ? "png" : m && m.includes("webp") ? "webp" : "jpg";
}
/** Промпт для Grok Imagine: исходное фото — первое изображение, референс — второе. */
function buildGrokEditPrompt({ prompt, hasReference = false, region = null }) {
const lines = [`Edit the first image: ${prompt}.`];
if (hasReference) {
lines.push(
"The second image is the same scene with a red rectangle marking the area to edit; it is only a guide — do not copy the red rectangle into the result."
);
if (region) {
lines.push(`Rectangle in pixels: x=${region.x}, y=${region.y}, width=${region.w}, height=${region.h}.`);
}
lines.push("Apply the change only inside that area and keep everything else unchanged.");
}
lines.push("Keep the original composition, subjects, faces, proportions and framing; do not redraw the photo from scratch.");
return lines.join(" ");
}
/**
* Движок редактирования через xAI Grok Imagine (POST /v1/images/edits).
* @param {import('./engine').EngineContext & {xaiClient: import('../xai/client').XaiImageClient}} ctx
*/
function createGrokEditEngine(ctx) {
const { config, logger, history, xaiClient } = ctx;
return {
async run(job) {
const { sourceId, inputBuffer, inputMimetype, prompt, referenceId, referenceBuffer, referenceMimetype, region } =
job.payload || {};
if (!prompt || !String(prompt).trim()) {
throw new AppError(400, "Промпт обязателен для редактирования.");
}
let rawBuffer;
if (sourceId) {
rawBuffer = (await history.getVersionFile(sourceId)).buffer;
} else if (inputBuffer) {
rawBuffer = Buffer.isBuffer(inputBuffer) ? inputBuffer : Buffer.from(inputBuffer);
} else {
throw new AppError(400, "Исходная фотография не найдена.");
}
const maxDim = config.EDIT_MAX_DIMENSION || 0;
const scaled = await downscaleImage(rawBuffer, maxDim);
const factor = scaled.factor || 1;
const images = [{ buffer: scaled.buffer, mimetype: mimeFromExt(extFromMime(inputMimetype)) }];
// sharp может вернуть другой формат — определим по сигнатуре
images[0].mimetype = sniffMime(scaled.buffer) || images[0].mimetype;
let refRaw = null;
if (referenceId) {
try {
refRaw = (await history.getVersionFile(referenceId)).buffer;
} catch (err) {
logger.warn("Не удалось прочитать референс по referenceId", { referenceId, error: err.message });
}
} else if (referenceBuffer) {
refRaw = Buffer.isBuffer(referenceBuffer) ? referenceBuffer : Buffer.from(referenceBuffer);
}
let scaledRegion = null;
if (refRaw) {
let refBuf = refRaw;
scaledRegion = region || null;
if (scaled.downscaled && factor < 1) {
refBuf = (await downscaleImage(refRaw, maxDim)).buffer;
if (region) scaledRegion = scaleRegion(region, factor);
}
images.push({ buffer: refBuf, mimetype: sniffMime(refBuf) || mimeFromExt(extFromMime(referenceMimetype)) });
}
const promptText = buildGrokEditPrompt({ prompt, hasReference: Boolean(refRaw), region: scaledRegion });
logger.info("Запуск Grok редактирования", {
jobId: job.id,
model: xaiClient.model,
prompt: truncate(prompt, 100),
downscaled: scaled.downscaled,
width: scaled.width,
height: scaled.height,
});
let result;
try {
result = await xaiClient.edit({ prompt: promptText, images, timeoutMs: config.XAI_TIMEOUT_MS });
} catch (err) {
logger.warn("Grok редактирование завершилось ошибкой", { jobId: job.id, error: err.message });
return {
ok: false,
error: err.timedOut ? "Обработка заняла больше времени и была остановлена по таймауту." : err.message,
details: { status: err.status, timedOut: Boolean(err.timedOut) },
};
}
const ext = extFromMime(result.mimetype);
const dims = getImageDimensions(result.buffer) || { width: scaled.width, height: scaled.height };
const version = await history.createVersion({
kind: "edit",
label: `Правка: ${truncate(prompt, 30)}`,
prompt,
parentId: sourceId || null,
referenceId: referenceId || null,
buffer: result.buffer,
ext,
width: dims.width,
height: dims.height,
});
return {
ok: true,
version,
extra: {
engine: "grok",
model: xaiClient.model,
downscaledFrom: scaled.downscaled ? { width: scaled.originalWidth, height: scaled.originalHeight } : null,
durationMs: result.durationMs,
},
};
},
};
}
function sniffMime(buf) {
if (!buf || buf.length < 12) return null;
if (buf[0] === 0xff && buf[1] === 0xd8) return "image/jpeg";
if (buf[0] === 0x89 && buf[1] === 0x50) return "image/png";
if (buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") return "image/webp";
return null;
}
module.exports = { createGrokEditEngine, buildGrokEditPrompt };

87
src/core/xai/client.js Normal file
View File

@@ -0,0 +1,87 @@
"use strict";
/**
* Клиент xAI Imagine API для редактирования изображений.
* Документация: https://docs.x.ai/docs/guides/image-generation (раздел Image Editing)
* POST {XAI_BASE_URL}/images/edits, JSON:
* { model, prompt, image: {type:"image_url", url}, images?: [...], response_format: "b64_json" }
* Ответ: { data: [{ b64_json, mime_type }], usage }
*/
class XaiImageClient {
constructor({ apiKey, baseUrl = "https://api.x.ai/v1", model = "grok-imagine-image-2.0", timeoutMs = 180000, fetchImpl } = {}) {
this.apiKey = apiKey;
this.baseUrl = String(baseUrl).replace(/\/+$/, "");
this.model = model;
this.timeoutMs = timeoutMs;
this.fetch = fetchImpl || globalThis.fetch;
}
static toDataUri(buffer, mimetype = "image/jpeg") {
return `data:${mimetype};base64,${Buffer.from(buffer).toString("base64")}`;
}
/**
* @param {Object} o
* @param {string} o.prompt
* @param {{buffer: Buffer, mimetype: string}[]} o.images - первое — исходное фото (до 5 шт.)
* @returns {Promise<{buffer: Buffer, mimetype: string, durationMs: number, usage?: any}>}
*/
async edit({ prompt, images, timeoutMs }) {
if (!this.apiKey) {
throw new Error("XAI_API_KEY не задан — редактирование через Grok недоступно.");
}
if (!images || images.length === 0) throw new Error("Нет исходного изображения.");
const parts = images.slice(0, 5).map((img) => ({
type: "image_url",
url: XaiImageClient.toDataUri(img.buffer, img.mimetype),
}));
const body = { model: this.model, prompt, response_format: "b64_json" };
if (parts.length === 1) body.image = parts[0];
else body.images = parts;
const startedAt = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs);
let res;
try {
res = await this.fetch(`${this.baseUrl}/images/edits`, {
method: "POST",
headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
} catch (err) {
if (err.name === "AbortError") {
const e = new Error("Grok: превышен таймаут запроса.");
e.timedOut = true;
throw e;
}
throw new Error(`Grok: ошибка сети: ${err.message}`);
} finally {
clearTimeout(timer);
}
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch { /* not json */ }
if (!res.ok) {
const msg = json?.error?.message || json?.error || json?.code || text.slice(0, 300);
const e = new Error(`Grok API ${res.status}: ${typeof msg === "string" ? msg : JSON.stringify(msg)}`);
e.status = res.status;
throw e;
}
const item = json?.data?.[0];
if (!item) throw new Error("Grok API вернул пустой ответ.");
let buffer;
if (item.b64_json) buffer = Buffer.from(item.b64_json, "base64");
else if (item.url) {
const r = await this.fetch(item.url);
if (!r.ok) throw new Error(`Grok: не удалось скачать результат (${r.status}).`);
buffer = Buffer.from(await r.arrayBuffer());
} else throw new Error("Grok API не вернул изображение.");
return { buffer, mimetype: item.mime_type || "image/jpeg", durationMs: Date.now() - startedAt, usage: json.usage };
}
}
module.exports = { XaiImageClient };

View File

@@ -18,6 +18,8 @@ const { ComfyClient } = require("./core/comfy/comfy-client");
const { ComfyRuntime } = require("./core/comfy/comfy-runtime");
const { CodexCli } = require("./core/codex/cli");
const { EngineRegistry } = require("./core/engines/engine");
const { XaiImageClient } = require("./core/xai/client");
const { createGrokEditEngine } = require("./core/engines/grok-edit-engine");
const { createCodexEditEngine, createCodexSuggestEngine } = require("./core/engines/codex-engine");
const { createComfyUpscaleEngine } = require("./core/engines/comfy-upscale-engine");
const { createApp } = require("./http/app");
@@ -102,6 +104,16 @@ async function startServer() {
logger,
});
const xaiClient = new XaiImageClient({
apiKey: config.XAI_API_KEY,
baseUrl: config.XAI_BASE_URL,
model: config.XAI_IMAGE_MODEL,
timeoutMs: config.XAI_TIMEOUT_MS,
});
if (config.EDIT_ENGINE === "grok" && !config.XAI_API_KEY) {
logger.warn("XAI_API_KEY не задан: редактирование через Grok будет возвращать ошибку");
}
const events = new EventEmitter();
events.setMaxListeners(100);
@@ -114,9 +126,12 @@ async function startServer() {
comfyClient,
comfyRuntime,
codexCli,
xaiClient,
};
engineRegistry.register("edit", (ctx) => createCodexEditEngine(ctx));
engineRegistry.register("edit", (ctx) =>
config.EDIT_ENGINE === "codex" ? createCodexEditEngine(ctx) : createGrokEditEngine(ctx)
);
engineRegistry.register("suggest", (ctx) => createCodexSuggestEngine(ctx));
engineRegistry.register("upscale", (ctx) => createComfyUpscaleEngine(ctx));