Дерево истории, удаление версий, мультизагрузка, редактирование с референсом и областью, подсказки промптов

This commit is contained in:
2026-08-26 19:17:36 +07:00
parent 27a2ac304c
commit f91aa94336
5 changed files with 1619 additions and 190 deletions

View File

@@ -28,6 +28,28 @@ Codex CLI) и увеличивает разрешение через домаш
`GET /api/jobs/:id/status` каждые 3 секунды; страницу можно закрыть —
готовая версия появится в истории.
- **Дерево истории**: вместо плоской ленты версии показаны деревом — каждая
исходная фотография образует группу со своей веткой (исходник → правки →
апскейлы), потомки выстраиваются по `parentId` с отступами, а сироты
(версии, чей родитель уже удалён или оттеснён лимитом) показываются как
отдельные корни. Ветку можно свернуть, под каждой edit-версией виден её
промпт (обрезка до двух строк, полный текст — в подсказке).
- **Удаление версий**: на узле (при наведении) есть кнопка «Удалить» —
версия удаляется вместе со всеми потомками и их файлами
(`DELETE /api/history/:id`, каскадно по `parentId`).
- **Мультизагрузка**: в поле «Фотография» можно выбрать сразу несколько
файлов — каждый становится отдельной исходной версией (своей группой),
импорт идёт последовательно с прогрессом «Загрузка N из M…».
- **Редактирование с референсом и областью**: перед правкой можно задать
референс — файл или версию из истории. На превью референса рисуется
прямоугольник области; сервер передаёт Codex обе картинки (повторяемый
флаг `-i`) и кладёт координаты области в промпт. Если область не выбрана,
референс всё равно передаётся как ориентир по сюжету и ракурсу.
- **Подсказки промптов**: кнопка «💡 Предложить промпт» — мгновенные
варианты из истории (`GET /api/suggest-prompts`), а «Спросить Codex» —
асинхронный джоб `POST /api/suggest` (тот же механизм опроса статуса, что
и у редактирования).
## Требования
- Node.js 20 или новее
@@ -79,6 +101,11 @@ npm start
`GET /api/me`, `POST /api/logout`. Вход в систему — `POST /api/login`
с JSON-телом `{"username": "...", "password": "..."}`.
Остальные эндпоинты (все требуют авторизации): `GET /api/history`,
`POST /api/history/import`, `DELETE /api/history/:id`,
`GET /api/suggest-prompts`, `POST /api/suggest`, `POST /api/preview`,
`POST /api/edit`, `POST /api/upscale`, `GET /api/jobs/:id/status`.
## Логирование
Все действия сервера пишутся в стандартный вывод контейнера — смотрите их в

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23202321'/%3E%3Ctext x='32' y='45' font-size='38' font-weight='bold' text-anchor='middle' fill='%23ffffff' font-family='sans-serif'%3E%D0%9A%3C/text%3E%3C/svg%3E"
>
<link rel="stylesheet" href="/style.css?v=10">
<link rel="stylesheet" href="/style.css?v=11">
</head>
<body>
<header class="topbar">
@@ -66,6 +66,7 @@
id="photo-input"
type="file"
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
multiple
hidden
>
@@ -82,6 +83,26 @@
</div>
</div>
<div class="field-group">
<span class="field-label">Референс <span class="field-label-hint">(необязательно)</span></span>
<div class="reference-actions">
<button id="reference-upload-button" class="button button-secondary button-small" type="button">Загрузить файл</button>
<select id="reference-history-select" class="reference-select" aria-label="Выбрать референс из истории">
<option value="">— из истории —</option>
</select>
<button id="reference-clear-button" class="button button-secondary button-small" type="button">Очистить</button>
</div>
<input id="reference-file-input" type="file" accept="image/jpeg,image/png,image/webp" hidden>
<div id="reference-preview" class="reference-preview" hidden>
<canvas id="reference-canvas" class="reference-canvas"></canvas>
<div class="reference-preview-foot">
<span id="reference-region-info">Область не выбрана — потяните по превью</span>
<button id="reference-region-reset" class="button button-secondary button-small" type="button">Сбросить область</button>
</div>
</div>
<p id="reference-note" class="reference-note" hidden></p>
</div>
<div class="field-group">
<label class="field-label" for="prompt">Промпт</label>
<textarea
@@ -96,6 +117,18 @@
</div>
</div>
<button id="suggest-button" class="button button-secondary button-suggest" type="button" disabled>💡 Предложить промпт</button>
<div id="suggest-panel" class="suggest-panel" hidden>
<p class="suggest-section-title">Из истории</p>
<div id="suggest-history-chips" class="suggest-chips"></div>
<div class="suggest-actions">
<button id="suggest-codex-button" class="button button-secondary button-small" type="button" disabled>Спросить Codex</button>
<span id="suggest-codex-status" class="suggest-status" role="status" aria-live="polite"></span>
</div>
<p class="suggest-section-title">Варианты от Codex</p>
<div id="suggest-codex-chips" class="suggest-chips"></div>
</div>
<div class="actions">
<button
id="edit-button"
@@ -261,6 +294,6 @@
</div>
</noscript>
<script src="/app.js?v=10" defer></script>
<script src="/app.js?v=11" defer></script>
</body>
</html>

View File

@@ -186,6 +186,7 @@ h1 {
.control-stack {
display: grid;
min-width: 0;
gap: 23px;
}
@@ -916,71 +917,193 @@ textarea:disabled {
}
.history-list {
display: flex;
gap: 12px;
overflow-x: auto;
display: grid;
gap: 14px;
overflow-y: auto;
padding: 4px 4px 10px;
}
.history-item {
display: flex;
flex-direction: column;
gap: 7px;
flex: 0 0 150px;
width: 150px;
padding: 9px;
border: 1.5px solid var(--line);
.history-group {
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: var(--panel);
cursor: pointer;
text-align: left;
font: inherit;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.history-item:hover {
border-color: var(--accent);
transform: translateY(-1px);
.history-group-count {
display: block;
margin: 7px 2px 2px;
color: var(--muted);
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.history-item.is-current {
.history-node {
position: relative;
display: flex;
align-items: center;
gap: 6px;
margin-left: calc(var(--depth, 0) * 18px);
padding: 5px 6px;
border: 1.5px solid transparent;
border-radius: var(--radius-small);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
background 0.15s ease;
}
.history-node:hover {
background: rgba(108, 92, 231, 0.05);
}
.history-node.is-current {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.history-item img {
width: 100%;
height: 92px;
.history-node-toggle {
flex: 0 0 auto;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 7px;
color: var(--muted);
background: transparent;
cursor: pointer;
font-size: 12px;
line-height: 1;
}
.history-node-toggle:hover {
color: var(--ink);
background: #ecebe5;
}
.history-node-main {
display: grid;
flex: 1 1 auto;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 10px;
padding: 2px;
border: 0;
border-radius: 8px;
color: inherit;
background: transparent;
cursor: pointer;
text-align: left;
font: inherit;
}
.history-node-main:hover {
background: rgba(108, 92, 231, 0.07);
}
.history-node-thumb {
width: 52px;
height: 52px;
object-fit: cover;
border-radius: 8px;
background: #ecece6;
}
.history-item-meta strong {
display: block;
.history-node-meta {
display: grid;
min-width: 0;
gap: 2px;
}
.history-node-title {
font-size: 12.5px;
line-height: 1.3;
font-weight: 750;
color: var(--ink);
line-height: 1.3;
word-break: break-word;
}
.history-item-meta span {
display: block;
margin-top: 2px;
font-size: 11px;
color: var(--muted);
}
.history-item-badge {
align-self: flex-start;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
.history-node-badge {
justify-self: start;
padding: 2px 7px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent-dark);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.history-node-date {
font-size: 11px;
color: var(--muted);
}
.history-node-prompt {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 11.5px;
color: var(--muted);
line-height: 1.45;
}
.history-node-children {
display: grid;
gap: 6px;
}
.history-node-actions {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
display: flex;
gap: 6px;
opacity: 0;
transition: opacity 0.15s ease;
}
.history-node:hover .history-node-actions,
.history-node:focus-within .history-node-actions {
opacity: 1;
}
.history-node-action {
padding: 5px 9px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
background: var(--panel);
cursor: pointer;
font-size: 10.5px;
font-weight: 700;
text-decoration: none;
transition:
border-color 0.15s ease,
color 0.15s ease,
background 0.15s ease;
}
.history-node-action:hover {
border-color: var(--accent);
color: var(--accent-dark);
background: var(--accent-soft);
}
.history-node-action.history-delete {
color: var(--danger);
}
.history-node-action.history-delete:hover {
border-color: var(--danger);
color: var(--danger);
background: rgba(184, 67, 67, 0.07);
}
.history-empty {
@@ -989,6 +1112,173 @@ textarea:disabled {
color: var(--muted);
}
/* -- Референс ---------------------------------------------------- */
.reference-actions {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.button-small {
min-height: 36px;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 12px;
color: var(--ink);
background: var(--panel);
cursor: pointer;
font-size: 12px;
font-weight: 720;
transition:
border-color 150ms ease,
background 150ms ease,
transform 150ms ease,
opacity 150ms ease;
}
.button-small:not(:disabled):hover {
border-color: var(--accent);
background: var(--accent-soft);
transform: translateY(-1px);
}
.button-small:disabled {
cursor: not-allowed;
opacity: 0.43;
}
.reference-select {
min-height: 36px;
min-width: 0;
max-width: 100%;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 12px;
color: var(--ink);
background: var(--panel);
font-size: 12px;
outline: none;
transition:
border-color 160ms ease,
box-shadow 160ms ease;
}
.reference-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.12);
}
.reference-select:disabled {
cursor: not-allowed;
opacity: 0.43;
}
.reference-preview {
margin-top: 10px;
padding: 8px;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: var(--paper);
}
.reference-canvas {
display: block;
width: 100%;
max-height: 260px;
object-fit: contain;
border-radius: 8px;
background: #ecece6;
cursor: crosshair;
touch-action: none;
}
.reference-preview-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 8px;
color: var(--muted);
font-size: 11px;
line-height: 1.4;
}
.reference-note {
margin: 0;
color: var(--muted);
font-size: 11px;
line-height: 1.4;
}
/* -- Подсказки промптов ------------------------------------------- */
.button-suggest {
width: 100%;
}
.suggest-panel {
margin-top: 10px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: rgba(251, 250, 246, 0.7);
}
.suggest-section-title {
margin: 0 0 8px;
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.suggest-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.suggest-chip {
max-width: 100%;
padding: 6px 12px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--ink);
background: var(--panel);
cursor: pointer;
font-size: 11.5px;
line-height: 1.4;
text-align: left;
transition:
border-color 150ms ease,
background 150ms ease,
color 150ms ease;
}
.suggest-chip:hover {
border-color: var(--accent);
color: var(--accent-dark);
background: var(--accent-soft);
}
.suggest-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 10px 0;
}
.suggest-status {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
/* -- Авторизация -------------------------------------------------- */
.topbar-right {

391
server.js
View File

@@ -235,8 +235,11 @@ const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: MAX_UPLOAD_BYTES,
files: 1,
fields: 4
// /api/edit принимает два файла (photo + reference); остальные
// маршруты (preview, history/import, upscale, suggest) шлют по одному.
files: 2,
// /api/edit шлёт sourceId, prompt, region, referenceId.
fields: 8
},
fileFilter: (_request, file, callback) => {
const supported = new Set([
@@ -349,23 +352,65 @@ function extForMimetype(mimetype) {
return map[String(mimetype || "").toLowerCase()] || "jpg";
}
function buildCodexPrompt(prompt, dimensions, inputName) {
function buildCodexPrompt(prompt, dimensions, inputName, referenceName = null, region = null) {
const size = dimensions && dimensions.width
? `${dimensions.width}×${dimensions.height}`
: "";
return [
`Перед тобой фотография ${inputName}. Примени к ней следующее редактирование: «${prompt}».`,
const lines = [
`Перед тобой фотография ${inputName}. Примени к ней следующее редактирование: «${prompt}».`
];
if (referenceName) {
lines.push(
`Также дано вспомогательное изображение ${referenceName} — тот же сюжет и ракурс, что и ${inputName}. Красным прямоугольником на ${referenceName} выделена область, в которой нужно внести правку.`
);
}
if (referenceName && region) {
lines.push(
`Координаты области на ${referenceName}: x=${region.x}, y=${region.y}, ширина=${region.w}, высота=${region.h} (в пикселях).`
);
lines.push(
`Примени редактирование к ${inputName} именно в области, соответствующей прямоугольнику на ${referenceName}. Остальную часть ${inputName} не меняй.`
);
}
if (referenceName && !region) {
lines.push(
`Используй ${referenceName} как ориентир по сюжету и ракурсу при внесении правки в ${inputName}.`
);
}
lines.push(
"Сохрани отредактированное фото в файл output.jpg в текущей директории.",
"Не перерисовывай фото с нуля и не заменяй его на полностью новое изображение — редактируй исходное фото, сохраняя его сюжет, объекты, лица и пропорции.",
size
? `Сохрани исходный размер ${size}.`
: "Сохрани исходные пропорции.",
"Файл output.jpg должен быть валидным JPEG. По завершении кратко опиши, что сделал."
].join(" ");
);
return lines.join(" ");
}
function runCodexEdit(jobDir, inputName, prompt) {
function codexExecArgs(jobDir, inputNames) {
const args = [
"exec", "-C", jobDir,
"--skip-git-repo-check",
"--ephemeral",
"--dangerously-bypass-approvals-and-sandbox"
];
for (const name of inputNames) {
args.push("-i", name);
}
args.push("-o", "last_message.txt", "-");
return args;
}
function runCodexEdit(jobDir, inputNames, prompt) {
const lastMessagePath = path.join(jobDir, "last_message.txt");
const outputPath = path.join(jobDir, "output.jpg");
@@ -375,16 +420,7 @@ function runCodexEdit(jobDir, inputName, prompt) {
try {
child = spawn(
CODEX_EXE,
[
"exec",
"-C", jobDir,
"--skip-git-repo-check",
"--ephemeral",
"--dangerously-bypass-approvals-and-sandbox",
"-i", inputName,
"-o", "last_message.txt",
"-"
],
codexExecArgs(jobDir, inputNames),
{
cwd: jobDir,
env: process.env,
@@ -506,9 +542,9 @@ function runCodexEdit(jobDir, inputName, prompt) {
});
}
async function runEditJob(jobId, jobDir, inputName, promptText, prompt, sourceId, dimensions, jobStartedAt) {
async function runEditJob(jobId, jobDir, inputNames, promptText, prompt, sourceId, dimensions, jobStartedAt, referenceId = null) {
try {
const result = await runCodexEdit(jobDir, inputName, promptText);
const result = await runCodexEdit(jobDir, inputNames, promptText);
const durationMs = Date.now() - jobStartedAt;
if (!result.outputPath) {
@@ -538,6 +574,7 @@ async function runEditJob(jobId, jobDir, inputName, promptText, prompt, sourceId
label: `Редактирование: «${prompt.slice(0, 60)}${prompt.length > 60 ? "…" : ""}»`,
prompt,
parentId: sourceId || null,
referenceId,
buffer: fs.readFileSync(result.outputPath),
ext: "jpg",
width: dimensions?.width,
@@ -654,6 +691,121 @@ async function runUpscaleJob(jobId, sourceVersion, dimensions, scaledSize, runti
}
}
function buildSuggestPrompt(inputName) {
return `Перед тобой фотография ${inputName}. Предложи 3 коротких варианта промпта (по одному на строку, без нумерации) для её редактирования. Только варианты промптов, ничего больше.`;
}
function parseSuggestions(text) {
return String(text || "")
.split(/\r?\n/)
.map((line) => line.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, "").trim())
.filter((line) => line.length > 0 && line.length <= 200)
.slice(0, 3);
}
function runCodexSuggest(jobDir, inputNames, prompt) {
const lastMessagePath = path.join(jobDir, "last_message.txt");
return new Promise((resolve, reject) => {
let child;
try {
child = spawn(CODEX_EXE, codexExecArgs(jobDir, inputNames), {
cwd: jobDir,
env: process.env,
stdio: ["pipe", "ignore", "ignore"],
windowsHide: true
});
} catch (error) {
reject(error);
return;
}
const startedAt = Date.now();
let exitCode = null;
let settled = false;
let timedOut = false;
const settle = () => {
if (settled) return;
settled = true;
clearInterval(deadlineTimer);
const lastMessage = fs.existsSync(lastMessagePath)
? fs.readFileSync(lastMessagePath, "utf8").trim().slice(0, 4000)
: "";
resolve({ exitCode, timedOut, lastMessage });
};
const deadlineTimer = setInterval(() => {
if (Date.now() - startedAt > CODEX_EDIT_TIMEOUT_MS) {
timedOut = true;
try { child.kill(); } catch { /* уже завершён */ }
settle();
}
}, 1000);
child.on("error", (error) => {
if (settled) return;
settled = true;
clearInterval(deadlineTimer);
reject(error);
});
child.on("exit", (code) => {
exitCode = code;
// Короткая пауза после выхода, чтобы Codex успел дописать
// last_message.txt, затем завершаем джоб (таймаут — только backstop).
const exitAt = Date.now();
const pollLastMessage = () => {
if (settled) return;
const text = fs.existsSync(lastMessagePath)
? fs.readFileSync(lastMessagePath, "utf8").trim()
: "";
if (text.length > 0 || Date.now() - exitAt > 5000) {
settle();
return;
}
setTimeout(pollLastMessage, 250);
};
setTimeout(pollLastMessage, 250);
});
child.stdin.on("error", () => {});
child.stdin.end(prompt);
});
}
async function runSuggestJob(jobId, jobDir, inputName, jobStartedAt) {
try {
const result = await runCodexSuggest(jobDir, [inputName], buildSuggestPrompt(inputName));
const suggestions = parseSuggestions(result.lastMessage);
const job = jobs.get(jobId);
if (!job) return;
if (suggestions.length === 0) {
job.status = "error";
job.error = "Codex не вернул варианты промпта. Попробуйте ещё раз.";
job.details = result.lastMessage ? result.lastMessage.slice(0, 500) : undefined;
job.finishedAt = Date.now();
log("error", "Подсказка промпта не удалась", { jobId, exitCode: result.exitCode, timedOut: result.timedOut });
return;
}
job.status = "done";
job.finishedAt = Date.now();
job.payload = { job: { type: "suggest" }, suggestions };
log("info", "Подсказка промпта готова", {
jobId,
count: suggestions.length,
durationMs: Date.now() - jobStartedAt
});
} catch (error) {
log("error", "Подсказка промпта упала", {
jobId,
message: error instanceof Error ? error.message : String(error)
});
const job = jobs.get(jobId);
if (job) {
job.status = "error";
job.error = "Внутренняя ошибка при получении подсказки.";
job.details = error instanceof Error ? error.message : String(error);
job.finishedAt = Date.now();
}
} finally {
codexEditInFlight = false;
}
}
function contentTypeForExt(ext) {
const map = {
jpg: "image/jpeg",
@@ -720,6 +872,7 @@ function makeHistoryVersion({
label,
prompt,
parentId,
referenceId,
buffer,
ext,
width,
@@ -737,6 +890,7 @@ function makeHistoryVersion({
label,
prompt,
parentId: parentId || null,
referenceId: referenceId || null,
createdAt: new Date().toISOString(),
url: `/api/history/${id}/image.jpg`,
width: width || null,
@@ -745,6 +899,27 @@ function makeHistoryVersion({
};
}
function parseRegion(value) {
if (value === undefined || value === null || String(value).trim() === "") {
return null;
}
let parsed;
try {
parsed = JSON.parse(value);
} catch {
throw new AppError(400, "Область референса передана в неверном формате.");
}
const nums = ["x", "y", "w", "h"].map((key) => Number(parsed?.[key]));
if (nums.some((num) => !Number.isFinite(num) || num < 0)) {
throw new AppError(400, "Область референса должна содержать неотрицательные числовые поля x, y, w, h.");
}
return { x: Math.round(nums[0]), y: Math.round(nums[1]), w: Math.round(nums[2]), h: Math.round(nums[3]) };
}
async function resolvePhotoInput(request) {
const sourceId = String(request.body?.sourceId || "").trim();
@@ -1525,6 +1700,23 @@ app.get("/api/history", asyncRoute(async (_request, response) => {
response.json({ ok: true, versions: loadHistory() });
}));
app.get("/api/suggest-prompts", asyncRoute(async (_request, response) => {
const history = loadHistory();
const seen = new Set();
const prompts = [];
for (let i = history.length - 1; i >= 0 && prompts.length < 10; i--) {
const prompt = String(history[i]?.prompt || "").trim();
if (prompt && !seen.has(prompt)) {
seen.add(prompt);
prompts.push(prompt);
}
}
response.json({ ok: true, prompts });
}));
app.post(
"/api/history/import",
upload.single("photo"),
@@ -1570,6 +1762,69 @@ app.post(
})
);
app.delete("/api/history/:id", asyncRoute(async (request, response, next) => {
const { id } = request.params;
if (!JOB_ID_PATTERN.test(id)) {
throw new AppError(400, "Некорректный идентификатор версии.");
}
// Удаление сериализуется через historyWriteChain вместе с
// appendHistoryVersion: saveHistory пишет весь список без триминга,
// поэтому файлы чужих версий не затрагиваются.
let remaining = null;
await new Promise((resolve, reject) => {
historyWriteChain = historyWriteChain
.then(() => {
const history = loadHistory();
const target = history.find((entry) => entry.id === id);
if (!target) {
throw new AppError(404, "Версия не найдена в истории.");
}
const toDelete = new Set([id]);
let grew = true;
while (grew) {
grew = false;
for (const entry of history) {
if (entry.parentId && toDelete.has(entry.parentId) && !toDelete.has(entry.id)) {
toDelete.add(entry.id);
grew = true;
}
}
}
remaining = history.filter((entry) => !toDelete.has(entry.id));
saveHistory(remaining);
for (const entry of history) {
if (toDelete.has(entry.id)) {
try {
fs.rmSync(historyFilePath(entry), { force: true });
} catch {
// Файл мог отсутствовать.
}
}
}
log("info", "Удаление версии", {
versionId: id,
children: toDelete.size - 1,
removed: toDelete.size
});
resolve();
})
.catch(reject);
});
response.json({ ok: true, versions: remaining });
}));
app.get(
"/api/history/:id/image.jpg",
asyncRoute(async (request, response) => {
@@ -1605,8 +1860,11 @@ app.get(
app.post(
"/api/edit",
upload.single("photo"),
upload.fields([{ name: "photo", maxCount: 1 }, { name: "reference", maxCount: 1 }]),
asyncRoute(async (request, response) => {
const photoFile = request.files?.photo?.[0];
if (photoFile) request.file = photoFile;
const sourceVersion = await resolvePhotoInput(request);
const dimensions = await requirePhoto(request);
const prompt = String(request.body?.prompt || "").trim();
@@ -1622,10 +1880,28 @@ app.post(
if (codexEditInFlight) {
throw new AppError(
429,
"Редактирование уже выполняется. Дождитесь завершения текущего задания."
"Обработка уже выполняется. Дождитесь завершения текущего задания."
);
}
const referenceFile = request.files?.reference?.[0];
const region = parseRegion(request.body?.region);
let referenceId = null;
if (request.body?.referenceId) {
const rid = String(request.body.referenceId).trim();
if (!JOB_ID_PATTERN.test(rid)) {
throw new AppError(400, "Некорректный идентификатор референса.");
}
referenceId = rid;
}
if (region && !referenceFile) {
throw new AppError(400, "Область указана без изображения референса.");
}
codexEditInFlight = true;
const jobId = crypto.randomUUID();
const jobDir = path.join(CODEX_JOBS_DIR, jobId);
@@ -1636,7 +1912,22 @@ app.post(
const inputName = `input.${extForMimetype(request.file.mimetype)}`;
fs.writeFileSync(path.join(jobDir, inputName), request.file.buffer);
const promptText = buildCodexPrompt(prompt, dimensions, inputName);
let referenceName = null;
if (referenceFile) {
let refBuffer = referenceFile.buffer;
let refExt = extForMimetype(referenceFile.mimetype);
if (isHeic(referenceFile)) {
refBuffer = await convertHeicToJpeg(refBuffer);
refExt = "jpg";
}
referenceName = `reference_annotated.${refExt}`;
fs.writeFileSync(path.join(jobDir, referenceName), refBuffer);
}
const inputNames = referenceName ? [inputName, referenceName] : [inputName];
const promptText = buildCodexPrompt(prompt, dimensions, inputName, referenceName, region);
jobs.set(jobId, {
type: "edit",
@@ -1651,10 +1942,13 @@ app.post(
log("info", "Редактирование запущено", {
jobId,
sourceId: sourceVersion?.id || null,
prompt: truncate(prompt, 200)
prompt: truncate(prompt, 200),
hasReference: Boolean(referenceName),
region: region || null,
referenceId: referenceId || null
});
void runEditJob(jobId, jobDir, inputName, promptText, prompt, sourceVersion?.id || null, dimensions, jobStartedAt);
void runEditJob(jobId, jobDir, inputNames, promptText, prompt, sourceVersion?.id || null, dimensions, jobStartedAt, referenceId);
} catch (error) {
codexEditInFlight = false;
throw error;
@@ -1727,6 +2021,55 @@ app.post(
})
);
app.post(
"/api/suggest",
upload.single("photo"),
asyncRoute(async (request, response) => {
const sourceVersion = await resolvePhotoInput(request);
await requirePhoto(request);
if (codexEditInFlight) {
throw new AppError(
429,
"Обработка уже выполняется. Дождитесь завершения текущего задания."
);
}
codexEditInFlight = true;
const jobId = crypto.randomUUID();
const jobDir = path.join(CODEX_JOBS_DIR, jobId);
const jobStartedAt = Date.now();
try {
fs.mkdirSync(jobDir, { recursive: true });
const inputName = `input.${extForMimetype(request.file.mimetype)}`;
fs.writeFileSync(path.join(jobDir, inputName), request.file.buffer);
jobs.set(jobId, {
type: "suggest",
status: "running",
startedAt: Date.now(),
finishedAt: null,
payload: null,
error: null,
details: null
});
log("info", "Подсказка промпта запрошена", {
jobId,
sourceId: sourceVersion?.id || null
});
void runSuggestJob(jobId, jobDir, inputName, jobStartedAt);
} catch (error) {
codexEditInFlight = false;
throw error;
}
response.json({ ok: true, jobId, status: "running" });
})
);
app.get("/api/result", asyncRoute(async (request, response) => {
const filename = String(request.query.filename || "");
const subfolder = String(request.query.subfolder || "");