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:
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
145
src/core/engines/grok-edit-engine.js
Normal file
145
src/core/engines/grok-edit-engine.js
Normal 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
87
src/core/xai/client.js
Normal 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 };
|
||||
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user