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

Merged
Coder merged 1 commits from grok-image-edit into main 2026-11-04 20:45:52 +07:00
9 changed files with 345 additions and 3 deletions

View File

@@ -24,6 +24,8 @@ jobs:
# Секреты Gitea: Settings → Actions → Secrets → APP_USER / APP_PASSWORD # Секреты Gitea: Settings → Actions → Secrets → APP_USER / APP_PASSWORD
APP_USER: ${{ secrets.APP_USER }} APP_USER: ${{ secrets.APP_USER }}
APP_PASSWORD: ${{ secrets.APP_PASSWORD }} APP_PASSWORD: ${{ secrets.APP_PASSWORD }}
# Ключ xAI для редактирования через Grok Imagine (секрет XAI_API_KEY)
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
STACK_NAME: photo-editor STACK_NAME: photo-editor
# Если не задано — берётся первый endpoint из /api/endpoints # Если не задано — берётся первый endpoint из /api/endpoints
ENDPOINT_ID: ${{ vars.PORTAINER_ENDPOINT_ID }} ENDPOINT_ID: ${{ vars.PORTAINER_ENDPOINT_ID }}
@@ -42,7 +44,8 @@ jobs:
ENV_JSON=$(jq -nc \ ENV_JSON=$(jq -nc \
--arg u "${APP_USER:-}" \ --arg u "${APP_USER:-}" \
--arg p "${APP_PASSWORD:-}" \ --arg p "${APP_PASSWORD:-}" \
'[{name:"APP_USER",value:$u},{name:"APP_PASSWORD",value:$p}]') --arg x "${XAI_API_KEY:-}" \
'[{name:"APP_USER",value:$u},{name:"APP_PASSWORD",value:$p},{name:"XAI_API_KEY",value:$x}]')
# Определяем endpoint (окружение Docker), если не задан вручную # Определяем endpoint (окружение Docker), если не задан вручную
if [ -z "${ENDPOINT_ID:-}" ]; then if [ -z "${ENDPOINT_ID:-}" ]; then

View File

@@ -39,7 +39,7 @@ Codex CLI) и увеличивает разрешение через домаш
## Как это работает ## Как это работает
- **История версий**: каждая загрузка и результат (редактирование или апскейл) сохраняются в `data/history/` как отдельная версия. Доступно интерактивное дерево версий с ветвлением от исходника, каскадным удалением и свёрткой групп. Хранятся последние 50 версий (настраивается через `HISTORY_LIMIT`). - **История версий**: каждая загрузка и результат (редактирование или апскейл) сохраняются в `data/history/` как отдельная версия. Доступно интерактивное дерево версий с ветвлением от исходника, каскадным удалением и свёрткой групп. Хранятся последние 50 версий (настраивается через `HISTORY_LIMIT`).
- **Редактирование**: сервер передаёт выбранную версию и промпт агенту Codex CLI (`codex exec`). Агент редактирует изображение и сохраняет результат в JPEG. - **Редактирование**: сервер отправляет выбранную версию (и референс, если задан) с промптом в xAI Grok Imagine — `POST https://api.x.ai/v1/images/edits`, модель `grok-imagine-image-2.0`, изображения как base64 data URI, ответ `b64_json` (JPEG). Документация: https://docs.x.ai/docs/guides/image-generation. Прежний движок Codex CLI доступен через `EDIT_ENGINE=codex`; подсказки промптов по-прежнему генерирует Codex.
- **Увеличение разрешения**: 4× upscale через ComfyUI моделью `RealESRGAN_x4plus.safetensors` (или `4x_UltraSharp`). - **Увеличение разрешения**: 4× upscale через ComfyUI моделью `RealESRGAN_x4plus.safetensors` (или `4x_UltraSharp`).
- **Редактирование с референсом и областью**: можно задать референс — файл или версию из истории. На превью референса выделяется прямоугольник области; координаты передаются в промпт Codex. - **Редактирование с референсом и областью**: можно задать референс — файл или версию из истории. На превью референса выделяется прямоугольник области; координаты передаются в промпт Codex.
- **Подсказки промптов**: мгновенные варианты из недавней истории и генерация новых вариантов через Codex. - **Подсказки промптов**: мгновенные варианты из недавней истории и генерация новых вариантов через Codex.
@@ -71,6 +71,11 @@ Codex CLI) и увеличивает разрешение через домаш
| `MAX_QUEUED_JOBS` | number | `5` | Максимальный размер очереди на группу | | `MAX_QUEUED_JOBS` | number | `5` | Максимальный размер очереди на группу |
| `SSE_ENABLED` | bool | `true` | Включение Server-Sent Events | | `SSE_ENABLED` | bool | `true` | Включение Server-Sent Events |
| `TRUST_PROXY` | bool | `false` | Доверие заголовкам прокси (`X-Forwarded-For` для rate-limiting) | | `TRUST_PROXY` | bool | `false` | Доверие заголовкам прокси (`X-Forwarded-For` для rate-limiting) |
| `EDIT_ENGINE` | string | `grok` | Движок редактирования: `grok` (xAI Imagine API) или `codex` (Codex CLI) |
| `XAI_API_KEY` | string | — | API-ключ xAI (обязателен для `EDIT_ENGINE=grok`; в логи не выводится) |
| `XAI_BASE_URL` | string | `https://api.x.ai/v1` | Базовый URL xAI API |
| `XAI_IMAGE_MODEL` | string | `grok-imagine-image-2.0` | Модель редактирования изображений |
| `XAI_TIMEOUT_MS` | number | `180000` (3 мин) | Таймаут запроса к xAI |
--- ---

View File

@@ -33,6 +33,14 @@ services:
# (печатается в лог контейнера). # (печатается в лог контейнера).
- APP_USER=${APP_USER} - APP_USER=${APP_USER}
- APP_PASSWORD=${APP_PASSWORD} - APP_PASSWORD=${APP_PASSWORD}
# Редактирование фото через xAI Grok Imagine (POST /v1/images/edits).
# Ключ задаётся в переменных стека Portainer / секрете XAI_API_KEY.
- XAI_API_KEY=${XAI_API_KEY}
- EDIT_ENGINE=grok
# - XAI_IMAGE_MODEL=grok-imagine-image-2.0
# Node fetch не использует HTTP(S)_PROXY без этого флага; если прямой
# доступ к api.x.ai блокируется — раскомментируйте (Node >= 22.21):
# - NODE_USE_ENV_PROXY=1
# - OPENAI_API_KEY=${OPENAI_API_KEY} # - OPENAI_API_KEY=${OPENAI_API_KEY}
restart: unless-stopped restart: unless-stopped

View File

@@ -13,4 +13,6 @@ services:
environment: environment:
# Адрес ComfyUI (доступен из контейнера как исходящий адрес LAN) # Адрес ComfyUI (доступен из контейнера как исходящий адрес LAN)
- COMFY_URL=http://192.168.31.240:8188 - COMFY_URL=http://192.168.31.240:8188
# Ключ xAI для редактирования через Grok Imagine
- XAI_API_KEY=${XAI_API_KEY}
restart: unless-stopped restart: unless-stopped

View File

@@ -152,6 +152,16 @@ function loadConfig(env = process.env) {
const codexModel = env.CODEX_MODEL || null; const codexModel = env.CODEX_MODEL || null;
const codexReasoningEffort = env.CODEX_REASONING_EFFORT || 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) { if (issues.length > 0) {
throw new ConfigError(`Ошибки в конфигурации:\n- ${issues.join("\n- ")}`, issues); throw new ConfigError(`Ошибки в конфигурации:\n- ${issues.join("\n- ")}`, issues);
} }
@@ -187,11 +197,18 @@ function loadConfig(env = process.env) {
MAX_QUEUED_JOBS: maxQueuedJobs, MAX_QUEUED_JOBS: maxQueuedJobs,
SSE_ENABLED: sseEnabled, SSE_ENABLED: sseEnabled,
TRUST_PROXY: trustProxy, TRUST_PROXY: trustProxy,
EDIT_ENGINE: editEngine,
XAI_API_KEY: xaiApiKey,
XAI_BASE_URL: xaiBaseUrl,
XAI_IMAGE_MODEL: xaiImageModel,
XAI_TIMEOUT_MS: xaiTimeoutMs,
getSanitized() { getSanitized() {
const copy = { ...this }; const copy = { ...this };
delete copy.APP_PASSWORD; delete copy.APP_PASSWORD;
copy.APP_PASSWORD_SET = Boolean(this.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; 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 { ComfyRuntime } = require("./core/comfy/comfy-runtime");
const { CodexCli } = require("./core/codex/cli"); const { CodexCli } = require("./core/codex/cli");
const { EngineRegistry } = require("./core/engines/engine"); 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 { createCodexEditEngine, createCodexSuggestEngine } = require("./core/engines/codex-engine");
const { createComfyUpscaleEngine } = require("./core/engines/comfy-upscale-engine"); const { createComfyUpscaleEngine } = require("./core/engines/comfy-upscale-engine");
const { createApp } = require("./http/app"); const { createApp } = require("./http/app");
@@ -102,6 +104,16 @@ async function startServer() {
logger, 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(); const events = new EventEmitter();
events.setMaxListeners(100); events.setMaxListeners(100);
@@ -114,9 +126,12 @@ async function startServer() {
comfyClient, comfyClient,
comfyRuntime, comfyRuntime,
codexCli, 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("suggest", (ctx) => createCodexSuggestEngine(ctx));
engineRegistry.register("upscale", (ctx) => createComfyUpscaleEngine(ctx)); engineRegistry.register("upscale", (ctx) => createComfyUpscaleEngine(ctx));

60
test/grok-edit.test.js Normal file
View File

@@ -0,0 +1,60 @@
"use strict";
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { XaiImageClient } = require("../src/core/xai/client");
const { buildGrokEditPrompt } = require("../src/core/engines/grok-edit-engine");
const { loadConfig } = require("../src/config");
describe("xAI Grok image edit", () => {
test("sends JSON to /images/edits and decodes b64_json", async () => {
let captured;
const fetchImpl = async (url, opts) => {
captured = { url, opts, body: JSON.parse(opts.body) };
return new Response(JSON.stringify({ data: [{ b64_json: Buffer.from("JPEGDATA").toString("base64"), mime_type: "image/jpeg" }] }), { status: 200 });
};
const c = new XaiImageClient({ apiKey: "k", baseUrl: "https://api.x.ai/v1/", fetchImpl });
const r = await c.edit({ prompt: "p", images: [{ buffer: Buffer.from("a"), mimetype: "image/png" }] });
assert.equal(captured.url, "https://api.x.ai/v1/images/edits");
assert.equal(captured.opts.headers.Authorization, "Bearer k");
assert.equal(captured.body.model, "grok-imagine-image-2.0");
assert.equal(captured.body.response_format, "b64_json");
assert.equal(captured.body.image.type, "image_url");
assert.ok(captured.body.image.url.startsWith("data:image/png;base64,"));
assert.equal(r.buffer.toString(), "JPEGDATA");
});
test("uses images[] for multiple inputs and surfaces API errors", async () => {
let body;
const fetchImpl = async (_u, o) => {
body = JSON.parse(o.body);
return new Response(JSON.stringify({ error: "bad" }), { status: 400 });
};
const c = new XaiImageClient({ apiKey: "k", fetchImpl });
const img = { buffer: Buffer.from("a"), mimetype: "image/jpeg" };
await assert.rejects(c.edit({ prompt: "p", images: [img, img] }), /Grok API 400: bad/);
assert.equal(body.images.length, 2);
assert.equal(body.image, undefined);
});
test("fails clearly without API key", async () => {
const c = new XaiImageClient({ apiKey: "" });
await assert.rejects(c.edit({ prompt: "p", images: [{ buffer: Buffer.from("a") }] }), /XAI_API_KEY/);
});
test("prompt mentions reference region", () => {
const t = buildGrokEditPrompt({ prompt: "add hat", hasReference: true, region: { x: 1, y: 2, w: 3, h: 4 } });
assert.match(t, /add hat/);
assert.match(t, /x=1, y=2, width=3, height=4/);
});
test("config defaults and key is not in sanitized output", () => {
const cfg = loadConfig({ XAI_API_KEY: "secret" });
assert.equal(cfg.EDIT_ENGINE, "grok");
assert.equal(cfg.XAI_IMAGE_MODEL, "grok-imagine-image-2.0");
const s = cfg.getSanitized();
assert.equal(s.XAI_API_KEY, undefined);
assert.equal(s.XAI_API_KEY_SET, true);
assert.throws(() => loadConfig({ EDIT_ENGINE: "foo" }));
});
});